About two years ago I was running a VPS for every Flutter project I shipped. Custom Node.js servers, PM2 process managers, nginx reverse proxies, SSL cert renewals — the whole circus. Then one night at 2 AM I got a Slack alert: the server for a food delivery app had run out of disk space because I forgot to rotate logs. The app was down for 40 minutes before I could SSH in and fix it.
That was the last time I managed my own backend for a Flutter project. I moved everything to Firebase Cloud Functions the next week, and I haven't looked back. Over the past two years I've shipped 8 Flutter apps that rely on Cloud Functions for everything from push notifications to payment processing. This post covers the exact patterns I use on every project — not the "hello world" tutorial stuff, but the production patterns that survive real users doing real things to your app.
Why I Stopped Running My Own Servers
The math was simple. I was spending 6-8 hours per month per project on server maintenance — patching security updates, monitoring uptime, debugging nginx configs. Multiply that by 4 active projects and I was burning 30+ hours a month on infrastructure instead of writing Flutter code. With Cloud Functions, that number dropped to basically zero. Firebase handles scaling, patching, SSL, and monitoring. I just deploy functions and forget about them.
The cost difference was even more dramatic. My VPS bills were running $40-60/month per project. Most of my Cloud Functions usage falls under the free Spark tier (2 million invocations/month), and the projects that outgrew it cost $8-15/month on the Blaze plan. For context, one of my apps with 15,000 daily active users and heavy function usage costs about $12/month total.
The tradeoff is real though — you give up fine-grained control. I can't tune V8 flags, I can't run a Redis sidecar, I can't do long-running WebSocket connections. For most Flutter apps, that's a tradeoff I'm happy to make. When I need that level of control, I reach for a different stack (more on that later).
Project Setup the Way I Actually Do It
Every tutorial starts with firebase init functions and then shows you a one-liner. Let me show
you how I actually structure things on a real project, because the default setup falls apart the moment you
have more than 3 functions.
Flutter Side: Calling Functions
First, the Flutter side. I add the cloud_functions package along with firebase_core:
# pubspec.yaml
dependencies:
firebase_core: ^3.8.1
cloud_functions: ^5.2.1
firebase_messaging: ^15.2.1
Then I create a thin wrapper class that every feature module uses. I don't scatter
FirebaseFunctions.instance calls all over the codebase — that makes it impossible to mock in
tests and impossible to swap regions later:
import 'package:cloud_functions/cloud_functions.dart';
class CloudFunctionService {
final FirebaseFunctions _functions;
CloudFunctionService({String region = 'us-central1'})
: _functions = FirebaseFunctions.instanceFor(region: region);
Future<T> call<T>(String name, {Map<String, dynamic>? data}) async {
final callable = _functions.httpsCallable(
name,
options: HttpsCallableOptions(timeout: const Duration(seconds: 30)),
);
final result = await callable.call(data);
return result.data as T;
}
}
I learned the region thing the hard way — one of my early projects had functions deployed to
us-central1 but Firestore in asia-south1. The cross-region latency added 200-400ms
to every function invocation. Now I always co-locate functions and Firestore in the same region from day
one.
⚠️ Region Gotcha
If you deploy functions to a region other than us-central1, you must specify that region
when calling from Flutter with FirebaseFunctions.instanceFor(region: 'your-region').
Forgetting this causes a 404 error that's incredibly confusing to debug — I lost half a day to it on my
first regional deploy.
Functions Side: Folder Structure
On the functions side, I split by domain. Here's what my functions/ directory looks like on a
typical e-commerce app (I'm using TypeScript — I switched from plain JS after one too many runtime type
errors in production):
functions/
├── src/
│ ├── auth/
│ │ └── onUserCreate.ts # welcome email, default profile
│ ├── orders/
│ │ ├── onOrderCreate.ts # notify restaurant, start timer
│ │ └── onOrderUpdate.ts # status push notifications
│ ├── notifications/
│ │ └── sendPush.ts # FCM helper
│ ├── storage/
│ │ └── onImageUpload.ts # resize + generate thumbnails
│ ├── scheduled/
│ │ └── cleanupExpired.ts # daily cleanup job
│ └── payments/
│ └── stripeWebhook.ts # payment confirmation
├── index.ts # barrel exports
├── package.json
└── tsconfig.json
The index.ts just re-exports everything:
// index.ts
export { onUserCreate } from './src/auth/onUserCreate';
export { onOrderCreate, onOrderUpdate } from './src/orders/onOrderCreate';
export { onImageUpload } from './src/storage/onImageUpload';
export { cleanupExpired } from './src/scheduled/cleanupExpired';
export { stripeWebhook } from './src/payments/stripeWebhook';
This structure matters a lot when you hit 20+ functions. The alternative — one giant index.ts
with all your logic — becomes unreadable fast. I also built my clean architecture patterns with this separation in mind:
each function handles one concern, one trigger, one outcome.
Pattern 1: Push Notifications with FCM Triggers
This is the single most common Cloud Function I write. Almost every Flutter app I build needs "when X happens in Firestore, send a push notification to user Y." I've written this pattern so many times I can do it from memory.
The Firestore Trigger
Here's the real production version — not the simplified one from the docs. This handles token cleanup, multi-device support, and the FCM v1 API (the legacy API was deprecated in June 2024):
import { onDocumentCreated } from 'firebase-functions/v2/firestore';
import { getMessaging } from 'firebase-admin/messaging';
import { getFirestore } from 'firebase-admin/firestore';
export const onOrderCreate = onDocumentCreated(
'orders/{orderId}',
async (event) => {
const order = event.data?.data();
if (!order) return;
const db = getFirestore();
const userDoc = await db.doc(`users/${order.userId}`).get();
const tokens: string[] = userDoc.data()?.fcmTokens ?? [];
if (tokens.length === 0) return;
const message = {
notification: {
title: 'Order Confirmed!',
body: `Your order #${order.orderNumber} is being prepared.`,
},
data: {
orderId: event.params.orderId,
type: 'order_confirmed',
},
tokens,
};
const response = await getMessaging().sendEachForMulticast(message);
// Clean up invalid tokens
const tokensToRemove: string[] = [];
response.responses.forEach((resp, idx) => {
if (resp.error?.code === 'messaging/registration-token-not-registered') {
tokensToRemove.push(tokens[idx]);
}
});
if (tokensToRemove.length > 0) {
await db.doc(`users/${order.userId}`).update({
fcmTokens: FieldValue.arrayRemove(...tokensToRemove),
});
}
}
);
The token cleanup step at the bottom is critical and most tutorials skip it. Without it, your token arrays grow forever with stale tokens from uninstalled apps. After a few months you're sending to hundreds of dead tokens per user, which wastes FCM quota and slows down your function. I learned this when a client's notification function went from 200ms to 3 seconds because it was sending to 400+ dead tokens per user.
Handling Notifications in Flutter
On the Flutter side, I use firebase_messaging with a setup pattern I've refined over dozens of projects. The key insight: you need to handle three different notification states, and most apps only handle one. I covered push notifications in more depth in my FCM implementation guide, but here's the core setup:
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:firebase_core/firebase_core.dart';
// This MUST be a top-level function — not inside a class
@pragma('vm:entry-point')
Future<void> _onBackgroundMessage(RemoteMessage message) async {
await Firebase.initializeApp();
// Handle background data message (no UI access here)
debugPrint('Background message: ${message.data}');
}
class NotificationService {
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
Future<void> initialize() async {
// Request permission (iOS requires this, Android 13+ too)
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus != AuthorizationStatus.authorized) return;
// Get token and save to Firestore
final token = await _messaging.getToken();
if (token != null) await _saveToken(token);
// Listen for token refresh (happens after app reinstall)
_messaging.onTokenRefresh.listen(_saveToken);
// Background handler
FirebaseMessaging.onBackgroundMessage(_onBackgroundMessage);
// Foreground messages
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
// Tap on notification while app was in background
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
// Check if app was opened from a terminated state via notification
final initialMessage = await _messaging.getInitialMessage();
if (initialMessage != null) _handleNotificationTap(initialMessage);
}
void _handleForegroundMessage(RemoteMessage message) {
// Show a local notification or in-app banner
}
void _handleNotificationTap(RemoteMessage message) {
// Navigate to the right screen based on message.data
final orderId = message.data['orderId'];
if (orderId != null) {
// Navigate to order detail
}
}
Future<void> _saveToken(String token) async {
// Save to Firestore under the current user's document
}
}
💡 Three Notification States
Your Flutter app receives notifications in three states: foreground (app is open), background (app is in memory but not visible), and terminated (app was killed). Each state has a different handler. Miss any one of them and your users will report "notifications don't work" — because for them, they don't. I've seen this bug on at least 5 different client projects.
Pattern 2: Image Processing Pipeline
When users upload profile photos or product images, I never serve the raw upload. I always run it through a Cloud Function that generates multiple sizes. This pattern saves massive bandwidth — serving a 4MB phone photo as a 40px avatar is a crime against your users' data plans.
import { onObjectFinalized } from 'firebase-functions/v2/storage';
import * as sharp from 'sharp';
import { getStorage } from 'firebase-admin/storage';
import * as path from 'path';
const SIZES = [
{ suffix: 'thumb', width: 150, height: 150 },
{ suffix: 'medium', width: 600, height: 600 },
];
export const onImageUpload = onObjectFinalized(
{ bucket: 'my-app.appspot.com' },
async (event) => {
const filePath = event.data.name;
if (!filePath.startsWith('uploads/')) return;
if (filePath.includes('_thumb') || filePath.includes('_medium')) return;
const bucket = getStorage().bucket(event.data.bucket);
const [fileBuffer] = await bucket.file(filePath).download();
const ext = path.extname(filePath);
const baseName = filePath.replace(ext, '');
const uploads = SIZES.map(async ({ suffix, width, height }) => {
const resized = await sharp(fileBuffer)
.resize(width, height, { fit: 'cover' })
.webp({ quality: 80 })
.toBuffer();
const newPath = `${baseName}_${suffix}.webp`;
await bucket.file(newPath).save(resized, {
metadata: { contentType: 'image/webp' },
});
});
await Promise.all(uploads);
}
);
The guard clause at the top (if filePath.includes('_thumb')) prevents infinite loops — without
it, the function would trigger itself when it uploads the resized images. I found this out the hard way when
I burned through my entire monthly Cloud Functions quota in 20 minutes during testing. The Firebase bill
that month was... educational.
I convert everything to WebP because it's typically 30-40% smaller than JPEG at the same visual quality. If you're dealing with images in your Flutter app, check out my performance optimization guide where I cover image caching and lazy loading on the client side too.
Pattern 3: Scheduled Cleanup Jobs
Every app accumulates junk data over time — expired sessions, orphaned uploads, unverified accounts. I use scheduled functions (powered by Cloud Scheduler) to clean this up automatically:
import { onSchedule } from 'firebase-functions/v2/scheduler';
import { getFirestore, Timestamp } from 'firebase-admin/firestore';
import { getStorage } from 'firebase-admin/storage';
export const cleanupExpired = onSchedule(
{
schedule: 'every day 03:00',
timeZone: 'Asia/Karachi',
retryCount: 3,
},
async () => {
const db = getFirestore();
const thirtyDaysAgo = Timestamp.fromDate(
new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
);
// Delete unverified accounts older than 30 days
const staleUsers = await db
.collection('users')
.where('emailVerified', '==', false)
.where('createdAt', '<', thirtyDaysAgo)
.limit(500)
.get();
const batch = db.batch();
staleUsers.docs.forEach((doc) => batch.delete(doc.ref));
await batch.commit();
// Delete orphaned temp uploads
const bucket = getStorage().bucket();
const [files] = await bucket.getFiles({ prefix: 'temp/' });
const oldFiles = files.filter((f) => {
const created = new Date(f.metadata.timeCreated);
return created < new Date(Date.now() - 24 * 60 * 60 * 1000);
});
await Promise.all(oldFiles.map((f) => f.delete()));
console.log(
`Cleaned: ${staleUsers.size} users, ${oldFiles.length} temp files`
);
}
);
I run these at 3 AM local time so they don't compete with peak traffic for resources. The
limit(500) on the query is intentional — Firestore batch writes max out at 500 operations. If
you have more to clean, the function runs again the next night. Trying to delete thousands of documents in
one function execution is asking for timeouts.
💰 Cost Tip
Scheduled functions on the Blaze plan cost about $0.10/month for a daily job. That's cheaper than the Firestore reads you'd waste by keeping stale data around. The cleanup pays for itself.
Pattern 4: Firestore Denormalization Triggers
Firestore is a document database. If you try to use it like a SQL database with normalized data and joins, you'll hate it. I denormalize aggressively and use Cloud Functions to keep the copies in sync. Here's a pattern I use on every social or e-commerce app:
import { onDocumentUpdated } from 'firebase-functions/v2/firestore';
import { getFirestore } from 'firebase-admin/firestore';
// When a user updates their profile, update their name/avatar
// everywhere it's denormalized (reviews, comments, orders)
export const onProfileUpdate = onDocumentUpdated(
'users/{userId}',
async (event) => {
const before = event.data?.before.data();
const after = event.data?.after.data();
// Only propagate if name or avatar actually changed
if (
before?.displayName === after?.displayName &&
before?.avatarUrl === after?.avatarUrl
) {
return;
}
const userId = event.params.userId;
const db = getFirestore();
const updateData = {
'author.name': after?.displayName,
'author.avatarUrl': after?.avatarUrl,
};
// Update all reviews by this user
const reviews = await db
.collection('reviews')
.where('author.uid', '==', userId)
.get();
const batch = db.batch();
reviews.docs.forEach((doc) => batch.update(doc.ref, updateData));
await batch.commit();
}
);
The early return when nothing changed is critical — otherwise every profile save (even just updating
lastActive timestamp) triggers a cascade of unnecessary writes across your entire database. I
watched a client's Firestore bill spike from $3 to $45 in one month because they were missing this guard on
a similar trigger. If you want to understand more about structuring Flutter apps to work well with
Firestore, read my banking app case study where I covered the full
architecture.
Pattern 5: Callable Functions for Secure Business Logic
Some operations should never happen client-side. Applying discount codes, calculating final order totals, granting premium access — any logic where the client could cheat needs to run server-side. That's where callable functions come in:
import { onCall, HttpsError } from 'firebase-functions/v2/https';
import { getFirestore } from 'firebase-admin/firestore';
export const applyDiscount = onCall(async (request) => {
// Callable functions automatically verify Firebase Auth
if (!request.auth) {
throw new HttpsError('unauthenticated', 'Must be signed in');
}
const { code, cartTotal } = request.data;
if (!code || typeof cartTotal !== 'number') {
throw new HttpsError('invalid-argument', 'Missing code or cartTotal');
}
const db = getFirestore();
const discountDoc = await db.doc(`discounts/${code}`).get();
if (!discountDoc.exists) {
throw new HttpsError('not-found', 'Invalid discount code');
}
const discount = discountDoc.data()!;
// Validate usage limits
if (discount.usageCount >= discount.maxUses) {
throw new HttpsError('resource-exhausted', 'Code has expired');
}
// Check if user already used this code
const existingUse = await db
.collection('discount_usage')
.where('userId', '==', request.auth.uid)
.where('code', '==', code)
.limit(1)
.get();
if (!existingUse.empty) {
throw new HttpsError('already-exists', 'Code already used');
}
// Calculate discount server-side (never trust client math)
const discountAmount = Math.min(
cartTotal * (discount.percentage / 100),
discount.maxDiscount
);
// Record usage atomically
const batch = db.batch();
batch.set(db.collection('discount_usage').doc(), {
userId: request.auth.uid,
code,
discountAmount,
usedAt: new Date(),
});
batch.update(discountDoc.ref, {
usageCount: discount.usageCount + 1,
});
await batch.commit();
return { discountAmount, finalTotal: cartTotal - discountAmount };
});
And calling it from Flutter:
Future<double> applyDiscount(String code, double cartTotal) async {
final result = await CloudFunctionService().call<Map>(
'applyDiscount',
data: {'code': code, 'cartTotal': cartTotal},
);
return (result['finalTotal'] as num).toDouble();
}
The reason I use callable functions instead of HTTPS functions for authenticated operations is that callables automatically validate the Firebase Auth token. With a raw HTTPS function, I'd need to manually extract and verify the Bearer token from headers. One less thing to get wrong. For more on securing Flutter apps, I wrote a whole security guide that covers the client-side of this.
Pattern 6: Stripe Payment Webhooks
Almost every Flutter e-commerce project I build uses Stripe for payments. The webhook handler is an HTTPS function (not callable, since Stripe can't authenticate with Firebase) that processes payment events:
import { onRequest } from 'firebase-functions/v2/https';
import { getFirestore } from 'firebase-admin/firestore';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export const stripeWebhook = onRequest(async (req, res) => {
const signature = req.headers['stripe-signature'];
if (!signature) {
res.status(400).send('Missing signature');
return;
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.rawBody,
signature,
webhookSecret
);
} catch (err) {
res.status(400).send('Invalid signature');
return;
}
const db = getFirestore();
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object as Stripe.PaymentIntent;
await db.doc(`orders/${pi.metadata.orderId}`).update({
paymentStatus: 'paid',
paidAt: new Date(),
stripePaymentId: pi.id,
});
break;
}
case 'payment_intent.payment_failed': {
const pi = event.data.object as Stripe.PaymentIntent;
await db.doc(`orders/${pi.metadata.orderId}`).update({
paymentStatus: 'failed',
failureReason: pi.last_payment_error?.message ?? 'Unknown error',
});
break;
}
}
res.status(200).send('OK');
});
🔒 Webhook Security
Always verify the Stripe webhook signature before processing any event. I've seen apps get hit with fake
webhook calls that attempted to mark orders as paid without actual payment. The
constructEvent method throws if the signature doesn't match, which is your first line of
defense. I also covered this kind of server-side validation in my Flutter security guide.
Fixing Cold Starts — The Real Problem
Cold starts are the one thing that will make you curse Cloud Functions. The first invocation after a function has been idle can take 1-5 seconds depending on your function's bundle size and runtime. For user-facing operations that's unacceptable.
I hit this problem hard on a fintech project where users were waiting 3-4 seconds after tapping "Send Money" because the callable function had gone cold. Here's how I fixed it:
1. Set minInstances for critical functions:
export const processPayment = onCall(
{
minInstances: 1, // Always keep one instance warm
memory: '256MiB',
timeoutSeconds: 60,
region: 'asia-south1',
},
async (request) => {
// Payment logic...
}
);
2. Keep function bundles small. Tree-shake aggressively. If only 3 of your 20 functions need
the Stripe SDK, don't import it in index.ts — use dynamic imports so the Stripe bundle only
loads when those 3 functions spin up:
// BAD: loads Stripe for every function
import Stripe from 'stripe';
// GOOD: only loads Stripe when this function runs
export const stripeWebhook = onRequest(async (req, res) => {
const { default: Stripe } = await import('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// ...
});
3. Use v2 functions. The v2 API (which I've been using in all examples above) runs on Cloud Run under the hood, which has better cold start characteristics than the v1 Cloud Functions runtime.
After these three changes, my payment function's median response time dropped from 3.2 seconds to 380ms. The
minInstances: 1 setting costs about $5-8/month per function, which is worth every penny for
payment flows.
Cloud Functions vs Custom Backend vs Edge Functions
I get asked this on almost every project: "Should we use Cloud Functions or build a proper backend?" After years of doing both, here's my honest take:
Use Cloud Functions when:
- You're already on Firebase for auth + Firestore + storage (which describes 80% of my Flutter projects)
- Your backend logic is event-driven — "when X happens, do Y"
- You have fewer than 50 backend operations
- You don't need persistent WebSocket connections
Use a custom backend (FastAPI, NestJS, etc.) when:
- You need real-time WebSocket communication (chat, live collaboration)
- You have complex multi-step workflows that span minutes
- You need to run background workers or queues
- Your function execution consistently exceeds the 9-minute limit
Consider Supabase Edge Functions when:
- You want SQL (Postgres) instead of NoSQL (Firestore)
- You're comfortable with Deno/TypeScript
- You want edge-deployed functions that run closer to users globally
For what it's worth, I reach for Cloud Functions on roughly 7 out of 10 Flutter projects. The remaining 3 typically involve either real-time features that need WebSockets or complex backend workflows that outgrow the serverless model.
Monitoring and Debugging in Production
Deploying functions is the easy part. Knowing when they break in production is what separates a hobby project from a real app. Here's my monitoring setup:
Structured logging — I don't use console.log with random strings. I use
structured JSON logs that Cloud Logging can index and filter:
import { logger } from 'firebase-functions';
// Instead of: console.log('Order processed', orderId)
logger.info('Order processed', {
orderId,
userId,
amount,
processingTimeMs: Date.now() - startTime,
});
Error alerting — I set up Cloud Monitoring alerts for any function with an error rate above 1% or a p95 latency above 5 seconds. These send Slack notifications so I catch issues before users start complaining.
Firebase Crashlytics — On the Flutter side, uncaught errors from function calls get reported to Crashlytics automatically if you've set it up. I pair this with the server-side logs to get the full picture when debugging: "The app threw this error because the function returned this response because Firestore returned this data." Tracing through both sides is the only way to debug production issues properly.
If you're not monitoring your functions yet, start with the Firebase Console Functions tab. It shows invocation count, error count, and execution time for every function. That alone catches 90% of problems.
Mistakes I Made So You Don't Have To
Here are the real mistakes from real projects — not theoretical warnings, but things that actually cost me time and money:
1. Deploying all functions on every change. When you run
firebase deploy --only functions, it redeploys everything. With 25 functions, that takes 4-5
minutes and causes brief cold starts across all of them. Now I deploy individual functions:
firebase deploy --only functions:onOrderCreate. For large projects, I use function groups to organize deploys.
2. Not setting timeout and memory limits. The default timeout is 60 seconds and the default
memory is 256MB. Both are fine for simple functions, but my image processing function was silently failing
because it ran out of memory trying to resize a 20MB photo. Setting memory: '1GiB' fixed it
instantly. Always set explicit limits based on what your function actually does.
3. Ignoring the idempotency problem. Cloud Functions can fire more than once for the same
event. If your Firestore trigger creates an order confirmation email, you might send 2-3 emails for the same
order. I fix this by checking a processed flag or using Firestore transactions to ensure
at-most-once processing. I went deep on idempotency patterns in my banking app article — it's even more critical for financial
operations.
4. Testing in production. My first year working with Cloud Functions, I'd write the function, deploy it, trigger it manually, check the logs. The feedback loop was painfully slow. Now I use the Firebase Emulator Suite for local testing — Firestore emulator, Functions emulator, the whole thing runs locally. I can iterate in seconds instead of minutes. If you're building any kind of testing strategy, the emulator suite is non-negotiable.
5. Not using TypeScript from day one. My early projects used plain JavaScript for Cloud
Functions. Then I shipped a function where user.email was sometimes undefined
because the auth provider didn't require email. The function crashed in production for those users.
TypeScript would have caught that at compile time. Every new project starts with TypeScript now — no
exceptions.
🚀 Quick Deploy Tip
Add these aliases to your package.json scripts so you can deploy individual functions
fast:
"deploy:orders": "firebase deploy --only functions:onOrderCreate,functions:onOrderUpdate"
"deploy:all": "firebase deploy --only functions"
📚 Related Articles
Frequently Asked Questions
Are Firebase Cloud Functions good enough for production Flutter apps?
I've shipped 8+ Flutter apps that rely on Cloud Functions in production — handling everything from
payment webhooks to real-time notifications for tens of thousands of users. The free Spark plan
covers most MVPs, and the Blaze plan scales predictably. The only real production concern is cold
starts, which I solve with minInstances and keep-alive pings.
How do I fix Firebase Cloud Functions cold starts in Flutter?
Set minInstances to 1 for critical functions so at least one instance stays warm. For
less critical functions, I use a Cloud Scheduler ping every 5 minutes. Also keep your function
bundles small — tree-shake unused imports and lazy-load heavy dependencies. Switching from Node.js
to Python can sometimes help too, since Python functions have lighter cold starts for simple tasks.
Firebase Cloud Functions vs AWS Lambda for Flutter — which should I pick?
If you're already on Firebase for auth, Firestore, and storage — just use Cloud Functions. The native integration with Firestore triggers, FCM, and Auth events saves you weeks of glue code. I only reach for AWS Lambda when a project needs services outside the Firebase ecosystem, like SQS queues or Step Functions for complex workflows.
Can Firebase Cloud Functions handle payment webhooks from Stripe?
Yes, and I do this on almost every e-commerce Flutter project. You deploy an HTTPS function that receives the Stripe webhook, verify the signature using the Stripe SDK, then update your Firestore order document. The critical detail: always verify the webhook signature server-side. Never trust the payload without verification — I've seen apps get hit with fake webhook calls.
How much do Firebase Cloud Functions cost for a Flutter app?
The free tier gives you 2 million invocations per month, which covers most early-stage apps. Once you move to Blaze (pay-as-you-go), you're looking at $0.40 per million invocations plus compute time. For context, one of my apps with 15,000 DAU and heavy Cloud Function usage costs about $12/month. The biggest cost driver isn't invocations — it's compute time on functions that do heavy processing like image resizing.
What's the best way to structure Firebase Cloud Functions for a large Flutter project?
I group functions by feature in separate files — auth.ts, orders.ts,
notifications.ts, storage.ts — then barrel-export them from
index.ts. Each file handles one domain. For really large projects (50+ functions), I
split into function groups so deploys don't redeploy everything. And I always write the function
logic in a separate testable module, keeping the trigger handler thin.