Data

Documents and graphs: when a second database earns its place

Firestore is superb at keyed reads and poor at friends-of-friends. When a graph database earns its place, and how to keep two stores honest.

Published

Updated

—

Reading time

15 min

Most products start with one database, and most should stay that way for a long time. Then a feature arrives that the database was never shaped for: "people you may know", "mutual connections", "followers of people you follow who also like this". In a document store those questions turn into loops of reads that grow with every user's social circle, and the bill grows with them. This article is for engineers running a document database such as Firestore who are wondering whether a graph database like Neo4j is worth the operational weight. I have run exactly this pairing on a large-scale social platform I'm building, and my answer is "yes, but only for a narrow set of facts, and only if you are strict about who owns what".

What a document store is genuinely good at#

It is worth being fair to the incumbent before replacing any part of it. Firestore shines when you know the key of what you want:

  • Keyed reads. users/{uid} is one read, one round trip, constant cost regardless of how many users exist.
  • Denormalised views. A feed item can carry the author's display name and avatar URL, so rendering a list is one query rather than N lookups. You pay on write (fan-out updates when the name changes) to make reads cheap.
  • Realtime listeners. A client can subscribe to a document or query and receive changes over a persistent connection, which is hard to replicate cheaply elsewhere.
  • Operational simplicity. No servers, no vacuum, no failover drills. It scales horizontally without you thinking about it, as long as you respect its best practices around hotspots.

The shape Firestore rewards is: "give me this document" or "give me the documents in this collection where an indexed field matches". Anything that fits that shape is fast and priced predictably.

Where it hurts: multi-hop questions#

The trouble starts when the answer depends on relationships between relationships. Consider "people you may know", ranked by how many mutual friends you share. Assume friendships are stored as a subcollection, users/{uid}/friends/{friendUid}.

pymk-firestore.tsts
import { getFirestore } from "firebase-admin/firestore";
 
const db = getFirestore();
 
export async function peopleYouMayKnow(uid: string, limit = 20) {
  // Hop 1: my friends. One read per friend document.
  const mine = await db.collection(`users/${uid}/friends`).get();
  const myFriends = new Set(mine.docs.map((d) => d.id));
 
  // Hop 2: each friend's friends. One query per friend, one read per result.
  const counts = new Map<string, number>();
  await Promise.all(
    [...myFriends].map(async (friendId) => {
      const theirs = await db.collection(`users/${friendId}/friends`).get();
      for (const d of theirs.docs) {
        if (d.id === uid || myFriends.has(d.id)) continue;
        counts.set(d.id, (counts.get(d.id) ?? 0) + 1);
      }
    }),
  );
 
  return [...counts.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, limit)
    .map(([id, mutuals]) => ({ id, mutuals }));
}

This code is correct. It is also a cost trap. Firestore bills per document read, so a user with 300 friends, each with 300 friends, costs roughly 300 + 90,000 reads for one recommendation request. Those numbers are illustrative, but the shape is not: cost is the product of two fan-outs, and social graphs have long tails. Your most active users, the ones you most want to keep happy, are the most expensive to serve. Filtering out blocked users, private accounts or people who already received a request adds more reads on top.

You can mitigate this (cap hop 2 at the 50 most recent friends, sample, cache the result for a day), and for a while you should. But every mitigation makes the answer worse, and none of them changes the fact that the database has to ship you the whole second hop so that your application can do the join.

What a graph database does differently#

A graph database stores relationships as first-class records with direct pointers between nodes. In Neo4j, traversing from a node to its neighbours does not require an index lookup per hop; it follows stored references. The engine does the join, close to the data, and returns only the answer.

The same question in Cypher:

pymk.cyphercypher
MATCH (me:User {id: $uid})-[:FRIENDS_WITH]-(f:User)-[:FRIENDS_WITH]-(fof:User)
WHERE fof <> me
  AND NOT EXISTS { (me)-[:FRIENDS_WITH]-(fof) }
  AND NOT EXISTS { (me)-[:BLOCKED]-(fof) }
RETURN fof.id AS id, count(DISTINCT f) AS mutuals
ORDER BY mutuals DESC
LIMIT toInteger($limit)

One round trip, twenty rows back. Adding a third hop, a weighting by shared interests, or an exclusion rule is a change to the pattern, not a new loop in application code. That expressiveness is the real win. Graph databases are not magic; a traversal over a super-node with millions of edges is still expensive. But for the bounded, local traversals that social features need, they are the right tool.

WarningInteger parameters

The JavaScript driver sends plain numbers as floats, and Cypher rejects a float in LIMIT or SKIP. Either wrap the parameter with toInteger() as above, or pass neo4j.int(20) from the driver. This bites everyone once.

The real cost of a second database#

Adding Neo4j is not free, and the licence or hosting fee is the smallest part of it. Before you commit, price in these:

  • Synchronisation. Every fact that lives in both stores needs a pipeline to move it, and that pipeline has its own bugs, retries and backlog.
  • Consistency. The second store will lag the first. For a few hundred milliseconds, or minutes during an incident, a user may unfriend someone and still see them in recommendations.
  • Two failure modes. Your API can now fail because either database is down, slow or misconfigured. Every read path needs a decision about what to do when the graph is unavailable.
  • Operations. Backups, upgrades, connection limits, driver versions, credentials rotation, monitoring and a second query language the team has to learn.
  • Managed-tier behaviour. Managed graph services have their own quirks. On Neo4j AuraDB, free-tier instances are paused automatically after a period of inactivity (three days at the time of writing), and a paused instance simply stops resolving. Rarely-used staging environments are the usual victims: the first request after a quiet weekend fails with a DNS or connection error that looks like a network bug. Know your tier's pause policy and alert on it.
CostPrice both sides of the ledger

The case for a graph store is usually a cost case: fewer document reads on multi-hop features. Weigh that against a fixed monthly instance fee that you pay even at zero traffic, plus the sync pipeline's own messaging and compute. Check current numbers on the Firestore pricing and AuraDB pricing pages at the time of writing, and model your real read volumes rather than a guess. At low traffic, a single store with a cached, capped query is often cheaper.

Options compared#

ApproachMulti-hop query costConsistencyOps burdenGood fit
Firestore fan-out at read timeGrows with friends × friends-of-friendsStrong (single store)None extraSmall graphs, infrequent queries
Precomputed fan-out (materialised recommendations)One read at request time, heavy batch writesStale by designBatch jobsStable graphs, daily refresh acceptable
Postgres with self-joins or recursive CTEsModerate, index-dependentStrong if Postgres is the primaryOne relational DBTeams already on Postgres
Firestore + Neo4j synced by eventsOne traversal query, near-constant for bounded hopsEventualSecond DB + pipelineRelationship-heavy features at scale

The decision: one owner per fact#

The single most important rule in polyglot persistence is that every fact has exactly one source of truth. Everything else is a projection that can be rebuilt.

In my setup:

  • Firestore owns user profiles, settings, content, and the canonical record of every relationship (one friendships/{pairId} document per pair, block lists, follow records). Per-user subcollections like users/{uid}/friends still exist, but as denormalised views for the UI. Writes from the API go here and nowhere else.
  • Neo4j owns nothing. It holds a projection of the relationship facts, plus the minimal node properties needed to filter traversals (an id, a visibility flag, perhaps a coarse region). It never holds display names or avatars.
  • Derived answers (recommendations, mutual counts) come from Neo4j as lists of ids, then get hydrated from Firestore.

This split answers most of the hard questions for you. If the two stores disagree, Firestore wins and the graph gets repaired. If the graph is lost entirely, you rebuild it from Firestore. If you are tempted to write a fact into Neo4j first, stop: you are about to create a second source of truth.

Firestore owns every fact; Neo4j is a rebuildable projection fed by events.

Implementation#

Step 1: model the graph narrowly#

Create uniqueness constraints before anything writes. They make MERGE safe under concurrency and give you an index on the lookup key.

schema.cyphercypher
CREATE CONSTRAINT user_id IF NOT EXISTS
FOR (u:User) REQUIRE u.id IS UNIQUE;

Keep node properties to what traversals filter on. Every property you copy into the graph is a property you must keep in sync.

Step 2: emit change events from the source of truth#

You need a reliable signal whenever a relationship document changes. Two common options on Google Cloud:

  • Firestore triggers through Cloud Run functions and Eventarc. The event fires after the write commits, so there is no dual-write problem. Delivery is at-least-once and not ordered.
  • A transactional outbox. Inside the same Firestore transaction that writes the friendship, write an outbox/{eventId} document. A trigger or poller publishes outbox entries to Pub/Sub and marks them sent. This gives you a durable, inspectable event log and lets you shape the event payload deliberately.

Never publish to Pub/Sub directly from the API after writing to Firestore. If the process dies between the two calls, the graph silently misses a fact, and nothing will ever tell you.

The event needs enough to be applied idempotently and in any order:

events.tsts
export type RelationshipEvent = {
  type: "friendship";
  a: string;           // lower uid of the pair, so the edge id is stable
  b: string;           // higher uid
  active: boolean;     // false = removed
  version: number;     // monotonic per pair, stored on the Firestore doc
};

The version field is what makes out-of-order delivery safe. Increment it in the same transaction that changes the relationship.

Step 3: apply events with idempotent upserts#

Because delivery is at-least-once and unordered, the consumer must tolerate duplicates and stale events. MERGE handles duplicates. A version guard handles staleness.

sync-worker.tsts
import neo4j from "neo4j-driver";
import type { RelationshipEvent } from "./events";
 
// Create once per process and reuse: the driver pools connections.
const driver = neo4j.driver(
  process.env.NEO4J_URI!,
  neo4j.auth.basic(process.env.NEO4J_USER!, process.env.NEO4J_PASSWORD!),
);
 
const APPLY = `
MERGE (a:User {id: $a})
MERGE (b:User {id: $b})
MERGE (a)-[r:FRIENDS_WITH]->(b)
  ON CREATE SET r.version = -1
WITH r
WHERE r.version < $version
SET r.version = $version,
    r.active  = $active
`;
 
export async function applyEvent(evt: RelationshipEvent): Promise<void> {
  await driver.executeQuery(
    APPLY,
    { a: evt.a, b: evt.b, active: evt.active, version: evt.version },
    { database: "neo4j" },
  );
}

Three details matter here:

  1. Direction is canonical. Friendship is symmetric, so I always write a -> b with a < b and query without direction (-[:FRIENDS_WITH]-). That avoids duplicate edges for the same pair.
  2. Removals are soft. If an unfriend arrives and you DELETE the edge, a delayed, older "befriend" event will recreate it, and the version that would have rejected it went with the edge. Keeping the edge with active = false preserves the version. Filter with r.active in traversals and garbage-collect inactive edges older than your maximum redelivery window.
  3. The worker acks only after the write succeeds. Pub/Sub will redeliver on failure; configure a dead-letter topic so a poison message does not retry forever, and alert on its depth.

With soft removals, the recommendation query adds WHERE r.active on both hops:

pymk-active.cyphercypher
MATCH (me:User {id: $uid})-[r1:FRIENDS_WITH]-(f:User)-[r2:FRIENDS_WITH]-(fof:User)
WHERE r1.active AND r2.active AND fof <> me
  AND NOT EXISTS { (me)-[x:FRIENDS_WITH]-(fof) WHERE x.active }
RETURN fof.id AS id, count(DISTINCT f) AS mutuals
ORDER BY mutuals DESC
LIMIT toInteger($limit)

Step 4: design the read path#

The graph answers "which ids", Firestore answers "what do they look like". Hydrate with batched reads rather than one get() per id:

recommendations.tsts
import { getFirestore } from "firebase-admin/firestore";
 
const db = getFirestore();
 
export async function hydrate(ids: string[]) {
  if (ids.length === 0) return [];
  const refs = ids.map((id) => db.doc(`users/${id}`));
  const snaps = await db.getAll(...refs);   // one round trip, one read per doc
  return snaps.filter((s) => s.exists).map((s) => ({ id: s.id, ...s.data() }));
}

Then decide what happens when Neo4j is unreachable. For a recommendation widget, the right answer is almost always to degrade: return an empty list or a cached result with a short timeout on the graph call, and never let a secondary store take down a primary page. Cache the final list per user for minutes or hours; recommendations do not need to be fresh to the second, and the cache absorbs most of the traversal load.

Filter on read, too. The projection lags, so re-check anything security-relevant (blocks, account deletion, privacy settings) against Firestore during hydration. The graph can suggest; only the source of truth can permit.

Step 5: backfill and migrate#

Introducing the graph to an existing product needs a backfill that plays well with the live pipeline. The order matters:

  1. Deploy the sync worker and start consuming live events first.
  2. Run the backfill, which reads Firestore and applies the same idempotent upsert. Because both paths use version guards, they can overlap safely.
  3. Run a reconciliation pass and only then turn on the read path, behind a flag.

Stream the source collection in pages and write in batches with UNWIND, which is far cheaper than one transaction per edge:

backfill.tsts
import { getFirestore, FieldPath } from "firebase-admin/firestore";
import neo4j from "neo4j-driver";
 
const db = getFirestore();
const driver = neo4j.driver(
  process.env.NEO4J_URI!,
  neo4j.auth.basic(process.env.NEO4J_USER!, process.env.NEO4J_PASSWORD!),
);
 
const BATCH = `
UNWIND $rows AS row
MERGE (a:User {id: row.a})
MERGE (b:User {id: row.b})
MERGE (a)-[r:FRIENDS_WITH]->(b)
  ON CREATE SET r.version = -1
WITH r, row
WHERE r.version < row.version
SET r.version = row.version, r.active = row.active
`;
 
async function backfill(pageSize = 500) {
  let last: string | undefined;
  for (;;) {
    let q = db
      .collection("friendships")          // one doc per pair: {a, b, active, version}
      .orderBy(FieldPath.documentId())
      .limit(pageSize);
    if (last) q = q.startAfter(last);
    const page = await q.get();
    if (page.empty) break;
 
    const rows = page.docs.map((d) => d.data());
    await driver.executeQuery(BATCH, { rows }, { database: "neo4j" });
    last = page.docs[page.docs.length - 1].id;
  }
  await driver.close();
}
 
backfill().catch((err) => {
  console.error(err);
  process.exit(1);
});

This assumes a top-level friendships collection keyed by pair, which I recommend anyway: it gives you one document per relationship to version, trigger on and backfill from, while per-user subcollections remain available as denormalised views for the UI. The backfill reads every relationship document once, so budget for that read volume.

Step 6: reconcile continuously#

Pipelines drift. A deploy with a bug, a dead-lettered message nobody replayed, a manual fix in one store. Schedule a job that samples users, compares their active edges in both stores, and re-emits events for any difference. Emit a metric for the drift rate. If it is not near zero, you have a bug in the pipeline, and you want to know from a dashboard rather than from a user.

Trade-offs and failure modes#

  • Lag is visible. A user who unfriends someone may still see them suggested for a moment. Mitigate by re-checking against Firestore during hydration and by invalidating the per-user cache on relationship writes.
  • Backlogs compound. If the worker falls behind, recommendations degrade silently. Alert on subscription backlog age, not only on errors.
  • Super-nodes. Accounts with enormous followings make traversals through them expensive. Cap expansion per hop or exclude nodes above a degree threshold from recommendation patterns.
  • Two sets of credentials and connection limits. Serverless platforms scale out fast; each instance opens its own driver pool. Size pools small and cap concurrency so a traffic spike does not exhaust the database's connection limit.
  • Paused or deleted instances. Covered above, but worth repeating: an idle managed instance that pauses produces errors that look nothing like "the database is paused". Health-check it and page on it.
  • Schema drift between stores. Every new property you project is a new sync concern. Resist the temptation to copy "just one more field" into the graph for convenience.

Decision matrix#

SignalStay on one storeAdd a graph store
Deepest relationship query1 hop (my followers, my friends)2+ hops, ranked or filtered
Frequency of multi-hop queriesRare, batch, or admin-onlyOn a hot, user-facing path
Tolerance for stale answersNeeds strong consistencySeconds to minutes is fine
Team capacityNo one to own a second systemSomeone owns the pipeline and on-call
Cost modelDocument reads for the query are affordableFan-out reads dominate the bill

If you tick most boxes in the right column, the graph earns its place. If you tick one, it probably does not yet.

Checklist

  • Every fact has exactly one source of truth, written down in the repo
  • The graph holds only ids, relationship edges and the properties traversals filter on
  • Uniqueness constraints exist before the first write
  • Change events come from committed writes (trigger or transactional outbox), never a dual write from the API
  • Events carry a monotonic version, and upserts ignore stale versions
  • Removals are soft until the redelivery window has passed
  • Dead-letter topic configured, with an alert on its depth
  • Read path degrades gracefully when the graph is unreachable, with a short timeout
  • Security-relevant filters (blocks, deletions, privacy) are re-checked against the source of truth
  • Backfill runs after the live consumer, using the same idempotent upsert
  • A reconciliation job measures drift and repairs it
  • Managed-tier pause and connection limits are monitored

When not to do this#

Do not add a graph database because relationships exist in your domain. Almost every domain has relationships; very few need multi-hop traversal on a hot path. Before reaching for a second store, try the cheaper options:

  • If you are on Postgres already, a self-join handles friends-of-friends well with the right indexes, and recursive CTEs cover variable-depth traversal:

    pymk.sqlsql
    SELECT f2.friend_id AS id, count(*) AS mutuals
    FROM friendships f1
    JOIN friendships f2 ON f2.user_id = f1.friend_id
    WHERE f1.user_id = $1
      AND f2.friend_id <> $1
      AND NOT EXISTS (
        SELECT 1 FROM friendships x
        WHERE x.user_id = $1 AND x.friend_id = f2.friend_id
      )
    GROUP BY f2.friend_id
    ORDER BY mutuals DESC
    LIMIT 20;

    With an index on (user_id, friend_id) and edges stored in both directions, this is fast at considerable scale, with strong consistency and no pipeline.

  • If your graph is small or slow-changing, keep adjacency lists in documents and compute recommendations in a nightly batch job, writing the results to one document per user. One read at request time is hard to beat on cost.

  • If the query is rare, run it offline in BigQuery against an export and ship the result.

A second database is a long-term commitment to a pipeline, an on-call surface and a consistency model. Make it when the numbers say so, give it one clear job, and keep the source of truth where it already lives.

Share
All articles →

A layered CLAUDE.md, tight permissions, hooks, skills and scoped MCP servers: the setup that keeps Claude Code safe and cheap across dozens of repositories.

16 min

What a Cloud Run cold start is made of, how to measure each phase, and which fixes, and which min-instances bill, actually shorten it for your service.

14 min