Search IOCombats

Search challenges, guides, questions and articles

How Linear’s Sync Engine Works: The Architecture Behind Instant, Offline-First UI
LinearSync EngineLocal-FirstSystem DesignOffline-First AppsFrontend Architecture

How Linear’s Sync Engine Works: The Architecture Behind Instant, Offline-First UI

By Ghazi Khan | Aug 7, 2026 - 13 min read

Open Linear, create an issue, drag it across a board, and the UI updates before you'd expect any network round trip to have completed. Turn off your Wi-Fi and keep working: issues still open, edits still save, drag-and-drop still works. Reconnect, and everything reconciles without you noticing a thing.

Most web apps can't do this because most web apps are built around a request-response model: click a button, fire a request, wait, render the response. Linear is built differently. It treats the browser as a real database for both reads and writes, not a cache, and treats the network as something that happens in the background instead of something the UI waits on. That doesn't make the browser the source of truth, though. The server still decides what's correct; the local copy is a synchronized subset of the server's data, not an independent authority, and the two reconcile asynchronously. This is the local-first architecture pattern, and Linear is widely cited as its clearest production implementation. Engineers have reverse-engineered it closely enough (with sign-off from Linear's own CTO) that the mechanics are well documented, not guesswork.

This post walks through how it actually works, and why the specific choices Linear made trade off against the CRDT-based approach you'll read about elsewhere.

The Core Problem: Network Latency in the Interaction Path

In a typical CRUD app, every write goes: user action, HTTP request, server processes it, database commits, response comes back, UI updates. Even on a fast connection, that's 100 to 300 milliseconds where the interface either shows a loading spinner or, worse, does nothing until the response lands. On a flaky connection, or offline entirely, the interaction just fails.

Linear's answer is to remove the network from that path entirely. Every model an app needs (issues, teams, comments, projects) lives in the browser's IndexedDB, structured as a real local database. When you interact with the UI, you're reading and writing that local database directly. The network's job shrinks to one thing: keeping that local database in sync with everyone else's local database and with the server's source of truth.

Diagram
100%
flowchart TB subgraph crud["Traditional CRUD app"] direction LR A1[User action] --> A2[HTTP request] --> A3[Server processing] --> A4[Database commit] --> A5[Response] --> A6[UI updates] end subgraph linear["Linear's local-first model"] direction LR B1[User action] --> B2[Local DB write] --> B3[UI updates] B2 -.-> B4[Background sync] -.-> B5[Server] end crud ~~~ linear
visualized byIOCombats

The top flow has a 100 to 300 millisecond gap between the user's action and the UI reflecting it, because the network sits directly in that path. The bottom flow closes that gap to near-zero: the local write and the UI update happen back to back, while syncing to the server runs in parallel on a separate branch that never blocks rendering.

Traditional CRUDLinear's local-first model
Write pathAction → network round trip → server commit → UI updatesAction → local write → UI updates instantly, server sync happens after
Perceived latency100–300ms per interactionNear-zero
Server's roleIn the critical path for every interactionAuthoritative source of truth, reconciled in the background
Offline behaviorInteraction failsReads, creates, and edits still work; changes queue locally and sync on reconnect
Conflict handlingServer serializes requests as they arriveLast-writer-wins, ordered by a server-assigned sync id

The Object Model: Models, the Object Pool, and MobX

Linear's sync engine (often referred to by the reverse-engineering community as LSE) organizes data as models: Issue, Team, Organization, Comment, and so on. Each model has properties and references to other models, and those properties are observable through MobX. When a property changes, any component reading it re-renders automatically, the same reactive pattern MobX has always offered, just applied to data that happens to be synced across a network instead of only living in memory.

Every loaded model gets stored in an Object Pool, essentially a large in-memory map keyed by UUID. When code anywhere in the app needs an issue by ID, it fetches it from the pool rather than making a new request. This is what makes reads instant: there is no request to make.

Not everything loads at once. Each model type has a loadStrategy recorded in a central ModelRegistry:

  • instant: loaded during app bootstrap (most core models)
  • lazy: fetched in full, but only when first needed
  • partial: only a subset of instances loaded on demand (large collections like document content)
  • explicitlyRequested: loaded only when specifically asked for (e.g., historical data)

This tiered loading is what keeps bootstrap fast even for a workspace with tens of thousands of issues. You don't wait for everything; you wait for what you need first, and the rest hydrates lazily as it's accessed.

Diagram
100%
flowchart LR subgraph tab["Browser tab"] direction LR idb[("IndexedDB<br/>persisted models")] -- hydrates --> pool["Object Pool<br/>in-memory map, keyed by UUID"] pool -- feeds --> mobx["MobX observables /<br/>React components"] pool -.-> instant["instant"] pool -.-> lazy["lazy"] pool -.-> partial["partial"] pool -.-> explicit["explicitlyRequested"] end
visualized byIOCombats

Disk, memory, and the reactive UI are three separate layers with three separate jobs: IndexedDB persists, the Object Pool holds the working set in memory keyed by UUID, and MobX turns property reads into automatic re-renders. The loadStrategy tags determine how eagerly each model type gets pulled into that pool.

Bootstrapping: Building the Local Database

The first time you log into a workspace, Linear performs a full bootstrap: it pulls the models tagged instant from the server and writes them into IndexedDB. Alongside the data, it stores two important numbers on the local database's metadata:

  • lastSyncId: a monotonically increasing integer that represents the current version of the entire workspace's data
  • firstSyncId: the value lastSyncId had at the moment this bootstrap happened

On every subsequent app load, Linear checks IndexedDB first. If a local database already exists, it does an incremental bootstrap: it asks the server "send me everything that changed since firstSyncId" instead of re-downloading the whole workspace. This is the same idea as a database replica catching up on a write-ahead log, just applied to a browser tab.

Diagram
100%
sequenceDiagram participant C as Client participant S as Server rect rgba(56, 100, 220, 0.08) note over C,S: Full bootstrap — first login C->>S: Request instant-strategy models S-->>C: Data + lastSyncId + firstSyncId C->>C: Write to IndexedDB end rect rgba(56, 100, 220, 0.08) note over C,S: Incremental bootstrap — returning user C->>C: Read firstSyncId from local IndexedDB C->>S: Changes since firstSyncId? S-->>C: Delta only C->>C: Merge into existing IndexedDB data end
visualized byIOCombats

The incremental path is cheap precisely because it skips the first three steps of a full bootstrap: no re-fetching of instant models, no re-writing data already on disk, just the delta since the last known sync point.

Either way, once IndexedDB has data, every app load after that hydrates the in-memory Object Pool straight from disk before touching the network. That's what makes opening the app feel instant even before any request completes: the UI has something real to render immediately, and the network call is just there to check whether anything changed while you were away.

Transactions: How a Write Actually Happens

Here's the sequence when you rename an issue:

  1. The property assignment happens immediately in memory. The UI re-renders instantly through MobX. No network call has happened yet.
  2. Calling the model's save method wraps that change into an UpdateTransaction object describing exactly what changed and its previous value (so it can be reversed).
  3. The transaction is pushed onto a request queue and written into an __transactions table in IndexedDB. This second part matters: if the tab closes or the network drops right now, the pending change survives and gets resent on reconnect.
  4. A TransactionQueue batches pending transactions and sends them to the server on a short timer (or immediately, depending on the change).
  5. Once the server confirms the batch, it's cleared from IndexedDB.
Diagram
100%
flowchart LR S1["1. Property mutated in memory<br/>+ UI re-renders"]:::callout --> S2["2. UpdateTransaction<br/>object created"] S2 --> S3{"3. Fork"} S3 --> S3a["Written to IndexedDB<br/>__transactions table"] S3 --> S3b["Pushed to request queue"] S3a --> S4["4. TransactionQueue batches<br/>and sends to server"] S3b --> S4 S4 --> S5["5. Server confirms,<br/>transaction cleared from IndexedDB"] classDef callout fill:#172554,stroke:#facc15,stroke-width:2px,color:#e2e8f0
visualized byIOCombats

Step 1 is marked out because it's the whole point: the UI updates there, before any network call has been made. Everything after it, the transaction object, the IndexedDB write, the queue, the server confirmation, is bookkeeping that happens after the user has already seen their change take effect.

It's worth being precise about what "written to IndexedDB" means in step 3, because it's not the model's own row. The in-memory model updates immediately (that's what the UI reads from), and the pending transaction gets written to a separate queue table, __transactions. The model's actual row in IndexedDB only gets rewritten once the server confirms the change and sends it back as a delta packet, covered next. That distinction is what keeps the local database honest: it never diverges into its own version of the truth, it just temporarily lags behind an in-memory model that already knows about a pending, unconfirmed change.

This gives you three things for free: instant UI feedback (step 1 happens before any network activity), offline durability (step 3 means nothing is lost if you close the laptop mid-edit), and undo/redo (each transaction stores enough information to reverse itself).

// Simplified illustration of the optimistic-write pattern LSE uses.
// Not Linear's actual source, a conceptual model of the same idea.

interface Transaction {
  id: string;
  modelId: string;
  changes: Record<string, { from: unknown; to: unknown }>;
  status: 'pending' | 'synced' | 'failed';
}

function updateIssueTitle(issue: Issue, newTitle: string) {
  const previousTitle = issue.title;

  // 1. Mutate the in-memory model immediately. UI updates now.
  issue.title = newTitle;

  // 2. Build a reversible transaction.
  const tx: Transaction = {
    id: crypto.randomUUID(),
    modelId: issue.id,
    changes: { title: { from: previousTitle, to: newTitle } },
    status: 'pending',
  };

  // 3. Persist to IndexedDB so it survives a refresh or disconnect.
  db.transactions.put(tx);

  // 4. Queue for the server. The UI does not wait on this.
  transactionQueue.enqueue(tx);
}

Sync IDs: Why Linear Chose Total Order Over CRDTs

This is the part that separates Linear's approach from most "local-first" toolkits you'll encounter, which usually reach for CRDTs (Conflict-Free Replicated Data Types). A CRDT is a data structure designed so that merges from multiple sources always converge to the same result, no matter what order the merges happen in. That property, called commutativity, lets CRDT-based systems avoid needing a central authority to decide ordering. Each client can accept updates in whatever order they arrive and still end up consistent. This is powerful for fully peer-to-peer or multi-writer-heavy scenarios like collaborative text editing (Figma's multiplayer canvas and most real-time document editors lean this way).

Diagram
100%
flowchart TB subgraph crdt["CRDT — partial order"] direction LR cA((Client A)) <--> cB((Client B)) cB <--> cC((Client C)) cA <--> cC conv["All converge to the<br/>same state, any merge order"] end subgraph ot["OT with sync id — Linear"] direction LR oA((Client A)) --> srv[["Server"]] oB((Client B)) --> srv oC((Client C)) --> srv srv -- "assigns 1, 2, 3…<br/>broadcasts in order" --> oA srv --> oB srv --> oC end crdt ~~~ ot
visualized byIOCombats

No central node on the left, clients merge with each other directly and order doesn't matter. One central node on the right, every transaction passes through the server, which is the sole source of truth for ordering.

Linear doesn't use CRDTs for its core data model. Instead, it relies on Operational Transformation (OT) logic anchored by a centralized server that assigns a strict, total order to every transaction across the workspace. Every transaction that the server accepts increments a single counter: lastSyncId. Because every client eventually receives every transaction in that exact numeric order, there's no ambiguity about "what happened when," which is a much simpler problem than resolving concurrent edits with no ordering guarantee at all.

The tradeoff is explicit: CRDTs give you order-independence at the cost of more complex data structures and, often, larger payloads (CRDTs frequently carry metadata for every possible merge). Linear's OT-with-total-order approach is simpler to reason about and cheaper on the wire, but it requires a central server in the loop to hand out sync ids. That's a fine tradeoff for an issue tracker, where a server round trip to get the next id is not a bottleneck, but it would be a worse fit for something like a fully offline peer-to-peer editor with no reliable central authority.

ApproachOrderingNeeds central serverTypical use case
CRDTPartial order, merges commuteNo (works peer-to-peer)Collaborative text/canvas editing
OT with sync id (Linear)Total order, server-assignedYesStructured app data (issues, projects)

Delta Packets: Propagating Changes to Every Client

Once the server accepts a transaction, it doesn't just confirm it to the sender. It broadcasts a delta packet over WebSocket to every connected client subscribed to that data, including the client that made the change. Each delta packet contains one or more sync actions, each tagged with its own sync id.

Diagram
100%
sequenceDiagram participant A as Client A (writer) participant S as Server participant B as Client B (observer) A->>S: Send transaction S->>S: Assign sync id, store par Broadcast delta packet S-->>A: Delta packet S-->>B: Delta packet end note over A: Confirmation only —<br/>no visible UI change<br/>(already applied optimistically) note over B: New data —<br/>triggers UI update
visualized byIOCombats

Client A already rendered this change back in step 1 of the write flow, so its copy of the delta packet just resolves the pending transaction; nothing on screen moves. Client B never had this data locally, so the same broadcast is genuinely new information and triggers a re-render.

When a client receives a delta packet, it works through a fixed sequence: check whether it gained or lost access to any sync groups (Linear's permission boundary, tied to workspace and team membership), write the new data into IndexedDB, apply the changes to in-memory models so MobX triggers re-renders, advance its local lastSyncId, and finally resolve any pending transactions that were waiting on that id to confirm.

That last step is why the writer's own UI doesn't "flicker" when its own change comes back over the wire: the transaction was already applied optimistically in step 1 of the write flow, so the returning delta packet is just confirmation, not new information.

Conflicts, when they happen (two people editing the same field near-simultaneously), are resolved with a straightforward last-writer-wins rule based on sync id order. No merge logic, no operational transform of the conflicting edits themselves. That simplicity is only possible because of the total ordering guarantee described above.

Practical Takeaway

You don't need to build anything as elaborate as Linear's full sync engine to apply the pattern. The reusable idea is: separate "the change happened" from "the server confirmed the change." Mutate local state first, queue the network call second, and persist that queued call somewhere durable (IndexedDB, not just memory) so a refresh or disconnect doesn't lose it.

Diagram
100%
flowchart TB L1["1. Local mutation<br/><i>instant</i>"] --> L2["2. Durable queue<br/><i>survives refresh / disconnect</i>"] --> L3["3. Server reconciliation<br/><i>on reconnect</i>"]
visualized byIOCombats

This is the shape to reach for, generically, whenever you're designing something that needs to feel instant and survive going offline: mutate locally, queue durably, reconcile with the server when you can.

For interviews, this is a strong system design answer whenever you're asked to design something like an issue tracker, a collaborative to-do app, or "make this feel instant." Naming the specific tradeoff (OT with a central order-giver versus CRDTs for peer-to-peer merging) signals you understand why the choice matters, not just that both options exist. And if you're building a real feature that needs offline resilience, the transaction queue plus local persistence plus reconciliation-on-reconnect pattern above is the actual shape to reach for, not a vague "cache it and sync later."

Conclusion

Linear's sync engine feels instant because almost nothing in the interaction path waits on the network. Reads come from an in-memory object pool backed by IndexedDB. Writes apply locally first and queue for the server after. A single server-assigned sync id gives every client the same total order to reconcile against, which is what makes last-writer-wins conflict resolution sufficient without needing CRDT-level merge logic. It's a deliberate, well-reasoned tradeoff, not a shortcut, and it's a pattern worth having fully internalized before your next system design interview.

Sources

  • Scaling the Linear Sync Engine, Linear's official engineering blog, on the design goals and background of the sync engine.
  • reverse-linear-sync-engine, a reverse-engineering study of Linear's sync engine endorsed by Linear's CTO, the primary source for the model, transaction, sync id, and delta packet mechanics described in this post.
  • Local-first software, Ink & Switch's reference paper on the local-first software pattern this architecture belongs to.
  • How's Linear so fast? A technical breakdown, an independent technical breakdown corroborating the IndexedDB storage, startup hydration, and server-authority details covered in this post.

Advertisement

Ready to practice?

Test your skills with our interactive UI challenges and build your portfolio.

Start Coding Challenge