Cloud Functions — Firestore Triggers & Background Logic
Cloud Functions — Firestore Triggers & Background Logic
Cloud Functions for Firebase lets you run server-side code in response to events emitted by Firebase products without managing your own server. When combined with Firestore, you can react to document changes — creations, updates, and deletions — and execute background tasks such as denormalizing data, sending transactional emails, or enforcing business rules that must not live on the client.
The Three Core Firestore Triggers
The functions.firestore.document(path) builder exposes three event handlers:
- onCreate — fires when a document is created for the first time at the specified path.
- onUpdate — fires when an existing document is written to (but not created or deleted).
- onDelete — fires when a document is removed.
- onWrite — fires on create, update, or delete (a catch-all, used less often for trigger-specific logic).
onCreate — Welcome Email on New User Profile
// functions/src/index.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import * as nodemailer from 'nodemailer';
admin.initializeApp();
// Triggered every time a document is created under /users/{userId}
export const onUserCreated = functions.firestore
.document('users/{userId}')
.onCreate(async (snapshot, context) => {
const userId = context.params.userId;
const data = snapshot.data(); // the newly created document
const email = data?.email as string;
const username = data?.username as string;
// Send a welcome email via SMTP transporter
const transporter = nodemailer.createTransport({ /* smtp config */ });
await transporter.sendMail({
to: email,
subject: `Welcome, ${username}!`,
text: 'Your account has been created successfully.',
});
// Optionally write back to Firestore (avoid infinite loops!)
await admin.firestore()
.collection('users')
.doc(userId)
.update({ welcomeEmailSentAt: admin.firestore.FieldValue.serverTimestamp() });
console.log(`Welcome email sent to ${email}`);
});
Data Denormalization with onUpdate
Firestore is a NoSQL database; you frequently denormalize data — storing copies of the same value in multiple documents so reads are fast and query-friendly. When a source document changes, a Cloud Function can propagate that change to all dependent documents automatically.
onUpdate — Propagate Username Change to All Posts
// Triggered when a /users/{userId} document is updated
export const onUserUpdated = functions.firestore
.document('users/{userId}')
.onUpdate(async (change, context) => {
const before = change.before.data(); // document state BEFORE the write
const after = change.after.data(); // document state AFTER the write
// Only run if the username actually changed
if (before?.username === after?.username) {
console.log('Username unchanged — skipping denormalization.');
return null;
}
const userId = context.params.userId;
const newUsername = after?.username as string;
// Find all posts authored by this user and update the cached username
const db = admin.firestore();
const posts = await db
.collection('posts')
.where('authorId', '==', userId)
.get();
if (posts.empty) return null;
const batch = db.batch();
posts.docs.forEach((doc) => {
batch.update(doc.ref, { authorUsername: newUsername });
});
await batch.commit();
console.log(`Updated authorUsername on ${posts.size} posts for user ${userId}`);
return null;
});
db.batch()) when updating multiple documents at once. A batch is atomic (all succeed or all fail) and counts as a single write operation for billing purposes rather than N individual writes.Cleanup with onDelete
When a document is deleted you often need to clean up related sub-collections, storage files, or cached copies. Cloud Functions make this automatic and reliable — the client never has to orchestrate cascading deletes.
onDelete — Cascade-Delete User Sub-Collections & Storage
import * as storage from '@google-cloud/storage';
export const onUserDeleted = functions.firestore
.document('users/{userId}')
.onDelete(async (snapshot, context) => {
const userId = context.params.userId;
const data = snapshot.data(); // the now-deleted document
const db = admin.firestore();
// 1. Delete the user's posts sub-collection
const posts = await db
.collection('posts')
.where('authorId', '==', userId)
.get();
const batch = db.batch();
posts.docs.forEach((doc) => batch.delete(doc.ref));
await batch.commit();
// 2. Delete the user's avatar from Cloud Storage
const avatarPath = data?.avatarPath as string | undefined;
if (avatarPath) {
const bucket = admin.storage().bucket();
await bucket.file(avatarPath).delete().catch(() => {
// File may already be gone — swallow the error
console.warn(`Avatar not found at ${avatarPath}`);
});
}
console.log(`Cleaned up data for deleted user ${userId}`);
return null;
});
Understanding Cold Starts
Cloud Functions run on demand. When no instance of your function is currently running, Google must provision a new container — this is called a cold start. Cold starts add latency (often 1–5 seconds) before your function begins executing. Key implications:
- Heavy
require/importstatements at the module level (e.g., loading large SDKs) increase cold-start time. Initialize only what you need at the top level. - Call
admin.initializeApp()exactly once, guarded by a check so it is not re-initialized on warm invocations. - Background triggers (like Firestore triggers) are more tolerant of cold starts than HTTP functions called from a user-facing UI, because the user is not blocking on the response.
- Consider setting minimum instances in the function configuration to keep a warm instance alive for latency-sensitive background jobs.
async/await). If you return undefined while asynchronous work is still pending, Cloud Functions may terminate the container before that work completes, leading to silent data loss.Avoiding Infinite Loops
A Cloud Function that writes back to the same document it was triggered by will trigger itself again — creating an infinite loop that drains your quota and wallet. Always guard re-entrant writes:
- Check whether the relevant field actually changed before writing (
before.field === after.field). - Set a sentinel field (e.g.,
processedAt) and skip execution if it is already set. - Write to a different path or sub-collection so the same trigger is not fired.
Summary
Firestore triggers unlock powerful server-side automation without managing infrastructure. Use onCreate for side-effects on new data (emails, counters), onUpdate for propagating denormalized copies, and onDelete for cascading cleanup. Keep cold-start implications in mind — initialize SDKs at module scope, avoid heavy lazy imports, and always return a resolved Promise so Google's runtime knows when your work is done.