Denormalization

Firestore has no joins. If a post document stores an author ID and you want to show the author's name next to the post, you either fetch the user document separately or you store a copy of the name on the post itself. Fetching separately is the classic N+1: a feed of fifty posts becomes fifty-one reads, each one billed and each one adding latency, and there is no query that will do it in a single round trip. So the answer in Firestore is almost always to duplicate the data.

Denormalization is the standard Firestore modelling technique, not a hack. The problem is what happens afterwards.

Why duplicated data goes stale

The copy has no relationship to the original. When a user changes their display name, the name embedded in their ten thousand posts does not change with it. Nothing errors. The feed simply renders the old name, and the bug is reported weeks later as "the name didn't update" by someone who has no idea which of the six collections holds a copy.

The usual fix is a hand-written Cloud Function per relationship, and those functions are where the real footguns live. Updating ten thousand documents is a fan-out write: Firestore bills every one of them, batches cap out at 500 operations, and a function that forgets to check whether the synced field actually changed will re-run its fan-out on every unrelated edit to the source document. Worse, a function that writes back into the collection it triggers on will retrigger itself, and Firestore triggers deliver at least once, so the loop is not theoretical.

Declaring the relationship

Two decorators describe the same kind of link from opposite ends. Use whichever direction reads more naturally on the model you are editing.

typescript
@Collection('users')
class User extends BaseModel {
  @SyncTo('posts', { field: 'authorName', sourceField: 'name' })
  @Field({ type: 'string' })
  name!: string;
}

@Collection('posts')
class Post extends BaseModel {
  @DenormalizedFrom('users', { fields: ['name', 'avatar'] })
  @Field({ type: 'map' })
  author!: { name: string; avatar: string };
}

// firemap generate:functions creates
// Cloud Functions v2 with batch writes

@SyncTo is the push direction, declared on the source field. It says: whenever name on a user changes, write it into the authorName field of the related post documents. The source and target field names are separate options precisely because they usually differ — name is unambiguous on a user and meaningless on a post.

@DenormalizedFrom is the pull direction, declared on the destination field. It lists several source fields at once and lands them in a map field, which is the shape you want when you are embedding a small snapshot of another entity rather than a single scalar. Grouping the copy under one author map also keeps it visually obvious in the console that the data is a copy and not the post's own state.

What gets generated

Each declared relationship becomes one Cloud Functions v2 onDocumentUpdated trigger on the source collection. The generated function does the three things a hand-written one usually forgets. It compares the before and after snapshots and returns early if none of the synced fields actually changed, so unrelated edits cost nothing. It writes with the Admin SDK in batches, chunked so a single fan-out stays under Firestore's 500-operation batch ceiling — the chunk size is configurable and defaults to 400. And because it only ever assigns the current source value, re-delivery of the same trigger is harmless.

One convention matters: the function finds the documents to update by querying the target collection for a field named after the source collection with an Id suffix — a users source means posts are matched on usersId. If your target collection uses a different foreign key name, the query will match nothing and the sync will silently do no work. Models declared with a parent collection get the subcollection wildcard in their trigger path.

Generating and deploying

bash
firemap generate:functions

firebase deploy --only functions

Two things to plan for. Syncs are eventually consistent: the copy trails the source by however long the trigger takes to fire and complete, which is normally under a second but is not a transaction — a client reading immediately after the write can still see the old value, so do not build invariants on top of a denormalized field. And the generated functions only handle updates going forward; documents that already hold a stale copy stay stale until you backfill them. Denormalize the fields you actually render, not everything on the source entity — every extra synced field multiplies the write cost of a single profile edit. Once the copies exist, the queries that read them usually need a composite index, and the reads themselves go through the typed model API.