Linear's sync engine, open-sourced
Strata Sync is an open-source TypeScript implementation of the sync engine behind Linear. This page maps each part of Linear's published design onto the module here that implements it.
Linear’s engineers described their sync engine in talks and posts but never released the code. The reverse-engineering notes wrote it down part by part. This page maps each part onto the module here that implements it. Strata Sync is a clean-room implementation: no Linear code, and Linear is not affiliated with the project.
Why this design
Most local-first engines use CRDTs, which merge without a coordinator but carry metadata on every value and get awkward with partial replication and per-row permissions. Linear went the other way: one server, one counter, one order every client replays. Ordering is decided in one place, permissions fall out of the same mechanism, and a client that was offline for a week catches up by asking for everything after a single integer. Text is the exception, and uses Yjs CRDTs.
The mapping
Models
| Linear | Strata Sync |
|---|---|
| Decorators register models at startup | ModelRegistry, filled by @ClientModel and the property decorators |
| Load strategies | loadStrategy: instant, lazy, partial, explicitlyRequested or local |
| A reference holds an id and a resolved model | @Reference holds the key. Collections hydrate on access |
| A schema hash triggers migration | computeSchemaHash(). A mismatch re-bootstraps |
Registration happens at import time, so a model file has to be imported for its models to exist. See Models and Schema.
The object pool
Linear keeps one object per id in a modelLookup map, so every reference is the same instance. Strata Sync calls this the identity map, bounds it with identityMapMaxSize, and emits modelChange on eviction so hooks re-hydrate instead of reading a stale object.
Bootstrap and the sync id
| Linear | Strata Sync |
|---|---|
lastSyncId orders every change | SyncId, a string so it can grow past 2^53 |
| Full, partial or local bootstrap | bootstrapMode: auto, full or local |
| Streamed as newline-delimited JSON | NDJSON from /sync/bootstrap |
Only instant models load at bootstrap | Same. The rest load on first access |
Partial indexes
Linear names a subset of a model by an index key, records which subsets it has already fetched, and batches requests so nothing is fetched twice. Strata Sync keeps that shape: hasPartialIndex and setPartialIndex on the storage adapter, /sync/batch for the fetch, and de-duplication of in-flight requests. Coverage is recorded only once a fetch succeeds, so a cancelled load never pretends the data is local. See Load strategies.
The transaction queue
| Linear | Strata Sync |
|---|---|
| Create, update, delete, archive, unarchive | The same five, as I, U, D, A, V |
| Updates apply in memory first | Same. The outbox entry is what reaches the server |
| Queue states | queued -> sent -> awaitingSync -> completed |
| Persisted so a restart replays them | A durable outbox |
| Idempotency left to you | clientId + clientTxId. A retry cannot apply twice |
One deliberate difference: Strata Sync writes optimistic state to memory but not to storage, so a reload before confirmation replays the outbox rather than reading an unconfirmed row.
Deltas and rebase
Sync actions arrive as I, U, D, A, V, plus C for coverage and G for a group change. Applying a packet means: handle group changes, write the rows, cancel any in-flight create whose model just arrived, rebase the rest of the outbox, then advance lastSyncId.
Rebase is field-level by default, so two clients editing different fields of one row never conflict. rebaseStrategy picks what happens when they edit the same one: server-wins (default), client-wins or merge. See Conflict resolution.
Sync groups
Groups are the permission boundary. A client receives deltas only for the groups it subscribes to, and auth.resolveGroups decides those per request. Membership changes ship as durable G actions, so a user added to a team while offline still learns about it on the next catch-up, and a group action forces a re-bootstrap that the server filters on current membership.
Undo
Every mutation records its inverse. client.undo() sends it as an ordinary transaction, so other clients see a normal update. runAsUndoGroup() collapses several mutations into one step, and a server rejection drops the entry from both stacks.
What Strata Sync adds
- Collaborative text. Yjs documents and presence in
@stratasync/y-doc. See Collaborative editing. - Swappable adapters. Storage, transport and reactivity are separate packages, so the core runs in Node with no browser.
- A server you own.
@stratasync/serverregisters the routes on your Fastify app and stores the log in your Postgres. Redis is optional.
For the wire protocol, read the sync protocol. Still deciding whether you need any of this? What a sync engine is covers when the answer is no.
Common questions
Get started
One command. It scaffolds a working app with the sync server wired up.