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.
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}.
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:
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.
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.
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#
| Approach | Multi-hop query cost | Consistency | Ops burden | Good fit |
|---|---|---|---|---|
| Firestore fan-out at read time | Grows with friends × friends-of-friends | Strong (single store) | None extra | Small graphs, infrequent queries |
| Precomputed fan-out (materialised recommendations) | One read at request time, heavy batch writes | Stale by design | Batch jobs | Stable graphs, daily refresh acceptable |
| Postgres with self-joins or recursive CTEs | Moderate, index-dependent | Strong if Postgres is the primary | One relational DB | Teams already on Postgres |
| Firestore + Neo4j synced by events | One traversal query, near-constant for bounded hops | Eventual | Second DB + pipeline | Relationship-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 likeusers/{uid}/friendsstill 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.
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.
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:
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.
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:
- Direction is canonical. Friendship is symmetric, so I always write
a -> bwitha < band query without direction (-[:FRIENDS_WITH]-). That avoids duplicate edges for the same pair. - Removals are soft. If an unfriend arrives and you
DELETEthe edge, a delayed, older "befriend" event will recreate it, and the version that would have rejected it went with the edge. Keeping the edge withactive = falsepreserves the version. Filter withr.activein traversals and garbage-collect inactive edges older than your maximum redelivery window. - 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:
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:
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:
- Deploy the sync worker and start consuming live events first.
- Run the backfill, which reads Firestore and applies the same idempotent upsert. Because both paths use version guards, they can overlap safely.
- 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:
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#
| Signal | Stay on one store | Add a graph store |
|---|---|---|
| Deepest relationship query | 1 hop (my followers, my friends) | 2+ hops, ranked or filtered |
| Frequency of multi-hop queries | Rare, batch, or admin-only | On a hot, user-facing path |
| Tolerance for stale answers | Needs strong consistency | Seconds to minutes is fine |
| Team capacity | No one to own a second system | Someone owns the pipeline and on-call |
| Cost model | Document reads for the query are affordable | Fan-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.sqlsqlSELECT 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.
Related articles
All articles →Pub/Sub will deliver some messages twice, and that is by design. Here is how to build consumers whose side effects happen once anyway, with working code.
17 min
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