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

LinearStrata Sync
Decorators register models at startupModelRegistry, filled by @ClientModel and the property decorators
Load strategiesloadStrategy: 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 migrationcomputeSchemaHash(). 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

LinearStrata Sync
lastSyncId orders every changeSyncId, a string so it can grow past 2^53
Full, partial or local bootstrapbootstrapMode: auto, full or local
Streamed as newline-delimited JSONNDJSON from /sync/bootstrap
Only instant models load at bootstrapSame. 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

LinearStrata Sync
Create, update, delete, archive, unarchiveThe same five, as I, U, D, A, V
Updates apply in memory firstSame. The outbox entry is what reaches the server
Queue statesqueued -> sent -> awaitingSync -> completed
Persisted so a restart replays themA durable outbox
Idempotency left to youclientId + 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/server registers 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.

npx stratasync init my-app
Read the docs

Keep reading