Serverless APIs with Firebase Cloud Functions: Patterns and Pitfalls
Firebase Cloud Functions let you deploy serverless API code without managing infrastructure. The promise is seductive: write a function, deploy it, and it scales infinitely. The reality is more nuanced. Cold starts, dependency bloat, and runaway costs can turn the dream into a nightmare if you don't understand how the platform works.
This guide covers production patterns for Cloud Functions and the pitfalls I've encountered running them at scale in production applications like PeptiSync and ProfitPlate.
Cold Starts: The Silent Performance Killer
When a Cloud Function hasn't been invoked for a while, the platform spins it down. The next invocation must cold-start — loading your runtime, dependencies, and function code before executing.
Cold start latency depends on:
| Factor | Impact | Mitigation |
|---|---|---|
| Runtime | Node.js ~100ms, Python ~200ms, Java ~1s | Use Node.js or Python |
| Dependencies | 10MB = ~300ms, 50MB = ~1.5s | Minimize dependencies |
| Function count | More functions = slower cold starts | Combine related logic |
| Region | Less busy regions = faster cold starts | Choose appropriate region |
Measuring Cold Starts
Add cold start detection to your functions:
```typescript import * as functions from 'firebase-functions';
let coldStart = true;
export const myFunction = functions.https.onRequest(async (req, res) => { if (coldStart) { console.log('Cold start detected'); coldStart = false; } // Function logic }); ```
Minimizing Cold Starts
1. Keep instances warm:
``typescript export const criticalFunction = functions .runWith({ minInstances: 1, // Keep at least 1 instance always warm maxInstances: 10, // Cap at 10 to control costs }) .https.onRequest(async (req, res) => { // ... }); ``
minInstances increases your bill (you pay for idle compute) but eliminates cold starts for critical endpoints.
2. Reduce dependency footprint:
```typescript // Instead of importing the entire Firebase Admin SDK: import * as admin from 'firebase-admin'; // ~15MB admin.initializeApp();
// Import only what you need: import { getFirestore } from 'firebase-admin/firestore'; import { getAuth } from 'firebase-admin/auth'; ```
3. Use dependency caching:
```typescript // Bad: imported on every invocation for every function import { stripe } from './shared/stripe';
// Good: lazy initialization let stripeClient: Stripe | null = null;
function getStripe(): Stripe { if (!stripeClient) { stripeClient = new Stripe(functions.config().stripe.secret, { apiVersion: '2025-02-24.acacia', }); } return stripeClient; } ```
Dependency Management
Cloud Functions bundle your dependencies into the deployment package. Large packages increase cold start time.
Better Approach: Function-Specific Dependencies
Instead of a single package.json with everything:
``json // functions/package.json { "dependencies": { "firebase-admin": "^12.0.0", "firebase-functions": "^5.0.0" // Shared dependencies only } } ``
Install function-specific packages only when needed:
``typescript // stripe-webhook/index.ts // Import heavy packages only in functions that need them import Stripe from 'stripe'; ``
Use TypeScript Compilation
Compile TypeScript to JavaScript before deploying to reduce bundle size:
``json { "predeploy": ["npm run build"] } ``
Configure tsconfig.json to exclude tests and type definitions:
``json { "compilerOptions": { "outDir": "lib" }, "include": ["src"], "exclude": ["/*.test.ts", "/__tests__"] } ``
Function Organization Patterns
Single Responsibility Functions
Each function should do one thing:
```typescript // Good export const createUser = functions.auth.user().onCreate(...); export const deleteUser = functions.auth.user().onDelete(...); export const handleStripeWebhook = functions.https.onRequest(...); export const processInvoice = functions.firestore .document('invoices/{id}') .onCreate(...);
// Bad — generic Swiss Army knife export const api = functions.https.onRequest(async (req, res) => { switch (req.path) { case '/users': // ... case '/orders': // ... case '/payments': // ... } }); ```
Express App for Complex APIs
For multiple HTTP endpoints, use an Express app and export it as a single Cloud Function:
```typescript import as express from 'express'; import as functions from 'firebase-functions';
const app = express();
app.use(authenticate); app.use(rateLimit);
app.get('/api/v1/users', getUsers); app.post('/api/v1/users', createUser); app.get('/api/v1/users/:id', getUserById); app.patch('/api/v1/users/:id', updateUser);
export const api = functions.https.onRequest(app); ```
This combines the cold start cost across all endpoints while keeping your code modular.
Cost Optimization
Function Execution Costs
Firebase bills by compute time (GB-seconds) and invocations. The free tier covers 2M invocations per month, but heavy usage adds up quickly.
| Optimization | Impact |
|---|---|
| Reduce timeout duration | Lower cost per invocation |
| Use 256MB memory (not 1GB) for simple functions | 4x cheaper per second |
| Cap maxInstances | Prevents cost spikes |
| Use batch operations | Fewer invocations |
Database and Egress Costs
Every function that reads Firestore documents incurs read costs. Optimize by:
```typescript // Bad — reads per invocation export const getUserData = functions.https.onCall(async (data, context) => { const userDoc = await getDoc(doc(db, 'users', context.auth!.uid)); const postsSnap = await getDocs(query( collection(db, 'posts'), where('authorId', '==', context.auth!.uid) )); const statsSnap = await getDoc(doc(db, 'stats', context.auth!.uid));
return { user: userDoc.data(), posts: postsSnap.docs.map(d => d.data()), stats: statsSnap.data() }; });
// Better — aggregate data on write, read once export const aggregateUserData = functions.firestore .document('posts/{postId}') .onWrite(async (change, context) => { // When a post is created/updated, update the user's aggregate document }); ```
Monitoring and Observability
Error Reporting
Cloud Functions integrates with Error Reporting:
```typescript import * as functions from 'firebase-functions';
export const fragileFunction = functions.https.onRequest(async (req, res) => { try { // risky operation } catch (error) { console.error('Operation failed:', error); // Error Reporting automatically captures console.error res.status(500).json({ error: 'Internal server error' }); } }); ```
Custom Metrics
Log structured metrics for monitoring:
```typescript function logMetric(name: string, value: number, labels: Record<string, string> = {}) { console.log(JSON.stringify({ severity: 'INFO', metric: name, value, labels, timestamp: new Date().toISOString(), })); }
// Usage export const processOrder = functions.firestore .document('orders/{orderId}') .onCreate(async (snap) => { const start = Date.now(); const order = snap.data();
try { await chargeCustomer(order); logMetric('order_processing_ms', Date.now() - start, { status: 'success', tier: order.tier, }); } catch (error) { logMetric('order_processing_ms', Date.now() - start, { status: 'failed', error: error.message, }); } }); ```
Timeout Handling
Set appropriate timeouts to prevent runaway executions:
``typescript export const longRunningFunction = functions .runWith({ timeoutSeconds: 540, // Max: 9 minutes for HTTP functions memory: '1GB', }) .https.onRequest(async (req, res) => { // Heavy computation }); ``
Pitfalls I've Learned the Hard Way
- Environment variables in config: Use
functions.config()not.envfiles. Config is encrypted and version-controlled. - Promise handling: Always return or await Promises in background functions. Unhandled promises cause silent failures.
- Region selection: Deploy functions close to your users. Firestore auto-scaling works best when functions are in the same region.
- Gradual rollouts: Use
trafficsettings in Google Cloud Run to gradually shift traffic to new function versions. - Function naming: Once deployed, renaming a function creates a new one. Old URLs break if consumers cached them.
Conclusion
Firebase Cloud Functions are a powerful tool for building serverless APIs, but they require deliberate architectural decisions to perform well in production. Manage cold starts with minInstances for critical paths, keep dependencies lean, organize functions by responsibility, and monitor execution costs from day one. When treated with the same discipline as traditional backend services, Cloud Functions deliver on the serverless promise without the hidden surprises.
Building a serverless API and want a review of your architecture? Let's talk about optimizing your Cloud Functions for production.
---