Why LiveGraph needed a rethink
LiveGraph, Figma's real-time data-fetching service, sits at the center of multiplayer collaboration. It exposes a web API for GraphQL-like queries, returning results as JSON trees, and uses a custom React Hook so front-end components automatically re-render on updates. As Figma's user base has expanded, so has LiveGraph's load: sessions have tripled since 2021, and view requests have grown 5x in the past year alone. Underneath, the database is shifting from a single Postgres instance to many vertical and horizontal shards, and LiveGraph must keep pace.
The team launched the "LiveGraph 100x" initiative: a long-term plan to scale current read and database update load by 100x. The architecture had to meet several requirements:
- Keep Figma fast: Maintain or improve SLOs for initial load times and updates.
- Enable database scale: Support more vertical and horizontal shards without hurting reliability or performance.
- Use multiple scaling levers: Scale reads and query updates independently as traffic patterns shift.
- Migrate safely: Make incremental improvements transparent to LiveGraph users.
Growing pains from the original design
LiveGraph originally consisted of a single server with an in-memory, mutation-based query cache. The server tailed the PostgreSQL replication stream—the write-ahead log that carries row-level changes with monotonically increasing sequence numbers—to receive updates. Every query hit the primary database, and for each row mutation, the corresponding query result was directly modified in the cache.
That design worked while there was a single Postgres instance, relying on a globally ordered stream of updates. When the database splintered into vertical shards, global ordering was no longer guaranteed. The team's stopgap solution was to artificially combine all replication streams into one, preserving the ordering assumption that was baked into LiveGraph's mechanics.
But the stopgap exposed fundamental limitations:
- Excessive fan-out: Mutations were sent to every server, so scaling the fleet to handle more sessions consumed ever more bandwidth.
- Excessive fan-in: Each server processed every event from every shard, putting LiveGraph in the blocking path of database growth.
- Tight coupling of reads and updates: Sizing up the fleet was the only lever, which worsened both fan-in and fan-out.
- Fragmented caches: Clients with the same views could land on different servers, lowering cache hit rates as the fleet grew. Caches were lost on every deploy, creating a thundering herd as all clients reconnected and hit an empty cache simultaneously.
- Large blast radius from transient failures: LiveGraph powers optimistic updates, which rely on all shards producing updates to move the global stream forward. If one shard was unavailable, all optimistic updates stalled, even though only a fraction of users were affected.
These issues were too structural for incremental fixes. The stopgap bought time, but the re-architecture became existential as the database team prepared for horizontal sharding.
Data-driven design insights
One proposed direction was to extract the query cache into a separate service, sharded the same way as the database. That idea failed because LiveGraph would need to know database topology to route queries—a concern already handled by dbproxy—and would become implicated in every re-sharding operation.
Analysis of traffic patterns led to a critical realization: LiveGraph's traffic is dominated by initial reads, not live updates. Most query results never change after first load. This meant an invalidation-based cache—where caches are notified that results might be stale and re-query the database—would work well. Instrumentation confirmed the theory: most updates invalidate only a few queries, so fan-out is limited.
The mutation-based cache had historical roots: when Figma ran on a single Postgres primary, the database was extremely sensitive to query spikes. The mutation-based approach delivered new results without re-queries. With database scaling, though, capacity concerns shifted. An invalidation-based cache simplified the system considerably:
- Caches need only be notified that results might be stale, not receive each exact change.
- Re-querying on invalidation always fetches the newest result, so update ordering doesn't matter.
- The singular stream can be broken up across shards.
- Clients can simply invalidate and re-query for optimistic updates, rather than waiting for changes to flow through the system.
A second key discovery came from analyzing the LiveGraph schema. A schema inspection tool examined query structures and their frequencies, finding that in almost all cases, given a database row mutation, it's straightforward to determine which queries should be re-fetched. Because invalidators can be made aware of the shapes queries can take, they can correctly generate invalidations without tracking active subscriptions.
This means the invalidator service can be entirely stateless. It can be aware of database topology and cache sharding to deliver invalidations only to relevant caches, eliminating the excessive fan-in and fan-out. Systems where affected queries are hard to compute must be designed differently, so this property was significant for the design that followed.
The conclusion: a global invalidation-based cache sharded by query hash, with stateless invalidators. The team invested heavily in observability tooling to understand the existing system, letting usage patterns drive the architecture. The result was a new multi-tier design, spelled out across many documents (bearing various shades of the name "LiveGraph 100x"—dash or no dash, capital G or not).
A New Architecture for LiveGraph
LiveGraph 100x is written in Go and consists of three distinct services:
- The edge handles client view requests, expands them into multiple queries, and reconstructs results into fully loaded views. It subscribes to queries in the cache and re-fetches data upon invalidation to push updated results to clients.
- The read-through cache stores database query results, sharded by query hash. It is topology-agnostic, consuming only the invalidations that fall within its hash range. Upon invalidation, it evicts entries from memory before forwarding the invalidation upstream via a probabilistic filter. Deploying the cache separately from the edge—with hot replicas on standby—eliminates thundering herd concerns during rollouts.
- The invalidator is sharded in tandem with the physical databases and tails a single replication stream. It is the only component aware of database topology, generating invalidations for relevant cache shards on every mutation.

This design directly resolves the issues that plagued the prior stack:
- Controlled fan-in and fan-out: Clever sharding plus probabilistic filters keep node-to-node traffic bounded; capacity scales by adding caches or edges.
- Native sharding support: Both vertical and horizontal shards are supported without complicating re-shard operations.
- Unfragmented caching: A single global invalidation-based cache reduces memory overhead and code complexity; separate cache and edge deployments prevent deploy-related thundering herds.
- Resilience to transient failures: Optimistic updates rely on straightforward logic that tolerates temporary disruptions.
Over the past eighteen months, we have migrated this architecture into production. To ship incrementally, we targeted the least scalable piece first: the cache and its downstream dependencies. Two challenges stood out during that migration.
Invalidations: The Easy and the Hard
Most invalidations are straightforward—but how does that work in practice? Queries in LiveGraph derive from the schema’s object graph. The schema evolves at human speed, changing day-to-day with code deploys, far slower than the sub-second cadence required for invalidation generation. That lets us pre-distribute query definitions to services long before requests arrive, guaranteeing invalidations are ready when needed.
Consider a query for comments on a specific file. The schema edge would be:
type File {
id: String!
name: String!
updatedAt: Date!
comments: [Comment] @filter("Comment.fileId=id AND Comment.deletedAt=null")
}
Our cache serves SQL—relationships between objects become SQL filters against Postgres. The File → comments edge translates to:
SELECT * FROM comments WHERE file_id = $1 AND deleted_at = NULL
Every unparameterized query is identified by an ID, which we call a “query shape.” The cache key is then the pair of shape ID and arguments. For a shape named file_comments, the query file_comments("live-graph-love") maps to:
SELECT * FROM comments WHERE file_id = "live-graph-love" AND deleted_at = NULL
Critically, substitution works in reverse. When a mutation arrives from the database, we scan all query shapes and invalidate any whose parameterized form matches values in the pre- or post-image. In this case, substituting the file_id column value invalidates file_comments("live-graph-love"). This requires only the schema’s query shapes—not the full set of live queries.
{
"table": "comments",
"preImage": {
"id": "123",
"created_at": "January 1, 2000",
"deleted_at": null,
"file_id": "live-graph-love",
},
"postImage": {
"id": "123",
"created_at": "January 1, 2000",
"deleted_at": "October 3, 2004",
"file_id": "live-graph-love",
}
}
Some cases resist this treatment. Imagine querying comments within a time range:
type File {
Id: String!
name: String!
updatedAt: Date!
comments: [Comment] @filter("Comment.fileId=id AND Comment.createdAt > File.updatedAt")
}
That yields:
SELECT * FROM comments WHERE file_id = $1 AND created_at > $2
Call this shape new_comments. Given the same mutation, which queries should be invalidated? Conceptually, every query with a created_at argument on or before January 1, 2000 could have changed. That set is effectively infinite—bounded only by time granularity—making both generation and propagation infeasible. Queries with possibly unbounded fan-out on update are what we call “hard.”
We decompose each query into expressions, marking each “easy” or “hard.” In the example, the created_at > ? predicate is what makes the query hard. The remainder—new_comments_easy:file_key = ?—is easy. Range predicates aren’t the only source of difficulty:

Hard queries are rare—currently only 11 of roughly 700—but they are fundamental patterns. The crucial observation is that every query in our schema normalizes to (easy-expr) AND (hard-expr), a constraint we enforce going forward. Queries without a hard component simply omit it. That lets us invalidate only easy expressions, ignoring the hard parts entirely.
The mechanism is a caching trick. Caches are sharded by hash(easy-expr), not hash(query). This co-locates all hard queries sharing an easy expression on one instance, so invalidations target a single cache. Hard queries are then stored in two layers:
- A top-level
{easy-expr}key holding anonce - The result key
{easy-expr}-{nonce}-{hard-expr}
Invalidating the easy expression deletes the top-level key, evicting every hard query that shares it. To illustrate with the mutation above, new_comments_easy("live-graph-love") would purge all dependent hard queries. A consequence: hard-query lookups require indirection—fetch the nonce first, then the result.

With this scheme, invalidations specify only easy expressions until they reach the edge. There, active hard queries are re-queried against the cache. Because the edge knows its live user sessions, only in-flight queries are refreshed, eliminating the infinite fan-out of naive invalidation. A TTL sweeps stale entries.
The tradeoffs are clear: a stateless invalidator and fast invalidations cost us over-invalidation of hard queries plus a stricter schema. The former is acceptable given that active queries rarely see invalidations. If the latter becomes restrictive, our modular design permits loosening the normalization rule and extending the invalidation strategy.
Consistency Under Concurrency
A core LiveGraph contract is that queries stay current. Since updates are discovered by tailing the replication stream—not by polling—no invalidation can be dropped anywhere in the pipeline. That creates a subtle problem in the cache: juggling simultaneous reads and invalidations, which we call the “read-invalidation rendezvous.” If an invalidation lands while a read is in progress, we cannot tell whether the read’s result predates or postdates it. To preserve eventual consistency, LiveGraph re-fetches either way.
Making this work takes several steps. First, edges begin listening for invalidations on a query before requesting the cache. That guarantees upstream invalidations are observed even when a result hasn’t yet arrived. The rendezvous itself happens at a synchronization layer just above the in-memory cache, which enforces three behaviors:
- Same-type operations coalesce. Concurrent reads on the same key join a single cache read, primarily to prevent hot queries from hammering the database. But a read never coalesces into a reader that has already been invalidated.
- An invalidation arriving during an in-flight read marks that read as invalidated and waits for any pending cache sets to finish. This stops new readers spawned by the invalidation from merging into readers holding stale results, which would lose the invalidation entirely.
- A read arriving during an in-flight invalidation is flagged as invalidated and barred from writing to the cache. Racing the invalidation could otherwise pull a stale value from cache and persist it for future readers.
Validating the Rendezvous
Satisfying these constraints made for tricky code. We validate it three ways. First, a chaos test drives many threads that concurrently read and invalidate across a small key set, maximizing interleavings before production changes ship.
Second, online cache verification samples random queries, comparing cache results directly against the primary database. A mismatch reports whether an invalidation was seen or skipped.
Finally, a convergence checker compares query results between the old engine and LiveGraph 100x. This checker enabled safe incremental migration—each case was verified correct on the new engine before traffic switched over. Because the legacy engine is far slower, the checker needed heavy tuning; the old system often lagged seconds behind the new one.
Scaling lessons and the road ahead
The transition from that original single Node.js server to the current LiveGraph architecture has radically changed how Figma handles growth. The platform is now composed of decoupled services — the invalidator, edge, and cache layers — each able to scale horizontally according to its own pressure points. When the database team introduces new vertical or horizontal shards, only the invalidator needs to grow. When user traffic or active query counts increase, the edge and cache services absorb that load. A key win from this split is that scaling one service no longer causes a disproportionate increase in network fan-in or fan-out traffic, a problem that plagued the old "scale the entire monolith" approach.
Moving to this new architecture required careful planning around migration safety and incremental delivery. A strategy for live sharding was also needed to make the transition seamless for production traffic. Braden Walker, a software engineer on the team, details the intricacies of this rollout in his Systems@Scale talk, which covers the step-by-step process of getting from the legacy system to LiveGraph 100x without disrupting service.
Maintaining momentum
LiveGraph 100x is an ongoing milestone rather than the final destination. The team has already sketched out a set of enhancements that target weaker points in the current design:
- Automatic invalidator re-sharding to keep up with shifting data distribution.
- Cache resolution for non-Postgres sources to broaden the range of data LiveGraph can serve at the edge.
- First-class server-side computation support, such as handling complex permissions within the platform itself.
The design and execution of this project was a collective effort by Figma's Web Platform team: Bereket Abraham, Braden Walker, Cynthia Vu, Deepan Saravanan, Elliot Lynde, Jon Emerson, Julian Li, Leslie Tu, Lin Xu, Matthew Chiang, Paul Langton, and Tahmid Haque. For those interested in tackling the kinds of scale challenges that motivated the LiveGraph 100x redesign, Figma is actively hiring for these teams. More details on this project are available in the related engineering entries linked below.



