Architecture map of Linear's local-first sync engine, offline-capable data model, and multi-region infrastructure — compiled from public technical talks and blog posts.
Linear is a project management tool built on a local-first sync engine. The client stores a near-complete copy of workspace data in IndexedDB. Mutations happen locally first, then sync asynchronously to the server. Network latency is eliminated from the user interaction path entirely.
graph TD
subgraph Client["Client (Browser / Electron)"]
UI["React + MobX
Observer Components"]
OG["Object Graph
(in-memory)"]
OP["Object Pool
(normalized store)"]
TQ["Transaction Queue"]
IDB["IndexedDB
(local database)"]
end
subgraph Server["Server (GCP)"]
API["GraphQL API
(Node.js)"]
PG["PostgreSQL
(primary)"]
MDB["MongoDB
(sync cache)"]
WS["WebSocket
Server"]
end
UI --> OG
OG --> OP
OP --> TQ
TQ --> IDB
TQ --> API
API --> PG
API --> MDB
WS --> OP
style UI fill:#181818,stroke:#5588ff,color:#f0f0f0
style OG fill:#181818,stroke:#0055ff,color:#f0f0f0
style OP fill:#181818,stroke:#0055ff,color:#f0f0f0
style TQ fill:#181818,stroke:#0055ff,color:#f0f0f0
style IDB fill:#181818,stroke:#9966cc,color:#f0f0f0
style API fill:#111111,stroke:#00bbdd,color:#f0f0f0
style PG fill:#111111,stroke:#9966cc,color:#f0f0f0
style MDB fill:#111111,stroke:#cc8844,color:#f0f0f0
style WS fill:#111111,stroke:#22cc44,color:#f0f0f0
Linear's stack is TypeScript end-to-end, confirmed by CTO Tuomas Artman. A single model definition with decorators generates the database schema, GraphQL types, and client models simultaneously.
Frontend framework with observable reactivity. Components wrapped with observer auto-re-render on changes.
Backend services sharing the same type definitions as the frontend.
Primary relational database for authoritative data storage.
Caching layer for serialized model objects and delta sync packets. 3-4x faster than BigTable for this use case.
Browser-side persistence. Treated as a real database, not a cache.
Desktop shell wrapping the same React web app. No separate codebase.
Added for collaborative rich-text editing of issue descriptions. Linear's only CRDT usage.
Google Cloud Platform infrastructure managed as code. Cloudflare Workers for multi-region routing.
block-beta
columns 1
A["Presentation — React + MobX observers, keyboard-first UX"]
B["State — Object Graph + Object Pool (MobX observables)"]
C["Persistence — IndexedDB (client) + Transaction Queue"]
D["Transport — WebSockets (real-time) + GraphQL (mutations)"]
E["Server — Node.js + TypeScript, shared model definitions"]
F["Storage — PostgreSQL (primary) + MongoDB (sync cache)"]
G["Infrastructure — GCP, Terraform, Cloudflare Workers"]
style A fill:#181818,stroke:#5588ff,color:#f0f0f0
style B fill:#181818,stroke:#0055ff,color:#f0f0f0
style C fill:#181818,stroke:#9966cc,color:#f0f0f0
style D fill:#111111,stroke:#22cc44,color:#f0f0f0
style E fill:#111111,stroke:#00bbdd,color:#f0f0f0
style F fill:#111111,stroke:#cc8844,color:#f0f0f0
style G fill:#0a0a0a,stroke:#cc8844,color:#f0f0f0
The sync engine is Linear's defining technical achievement. CTO Tuomas Artman built four sync engines before Linear (gaming portal, Groupon POS, Uber mobile). The engine has two jobs: get the user up to date with the current state, and keep them up to date in real time.
graph TD
subgraph ObjectGraph["Object Graph (MobX)"]
OBS["Observable Properties
via Object.defineProperty"]
REACT["React observer()
auto-re-render"]
end
subgraph ObjectPool["Object Pool"]
ML["modelLookup Map
(ID → Model)"]
HYD["updateFromData
hydration"]
REF["attachToReferenced
Properties"]
end
subgraph TxQueue["Transaction Queue"]
TX["Transactions
(reversible ops)"]
SID["syncId
(monotonic counter)"]
PERSIST["IndexedDB
persistence"]
end
OBS --> REACT
ML --> OBS
HYD --> ML
REF --> ML
TX --> PERSIST
TX -->|"async"| SERVER["Server"]
ML --> TX
style OBS fill:#181818,stroke:#0055ff,color:#f0f0f0
style REACT fill:#181818,stroke:#5588ff,color:#f0f0f0
style ML fill:#181818,stroke:#0055ff,color:#f0f0f0
style TX fill:#181818,stroke:#9966cc,color:#f0f0f0
style PERSIST fill:#181818,stroke:#9966cc,color:#f0f0f0
style SID fill:#181818,stroke:#cc8844,color:#f0f0f0
style SERVER fill:#111111,stroke:#00bbdd,color:#f0f0f0
graph LR
SAVE["model.save()"] --> OG["Object Graph"]
OG --> OP["Object Pool"]
OP --> TQ["Transaction
Queue"]
TQ --> IDB["IndexedDB"]
TQ -->|"async"| SRV["Server"]
style SAVE fill:#0055ff,stroke:#5588ff,color:#fff
style OG fill:#181818,stroke:#0055ff,color:#f0f0f0
style OP fill:#181818,stroke:#0055ff,color:#f0f0f0
style TQ fill:#181818,stroke:#9966cc,color:#f0f0f0
style IDB fill:#181818,stroke:#9966cc,color:#f0f0f0
style SRV fill:#111111,stroke:#00bbdd,color:#f0f0f0
graph LR
SRV["Server"] --> WS["WebSocket"]
WS --> OP["Object Pool
+ IndexedDB"]
OP --> MOB["MobX
Reactivity"]
MOB --> OG["Object Graph"]
OG --> RE["React
Re-render"]
style SRV fill:#111111,stroke:#00bbdd,color:#f0f0f0
style WS fill:#111111,stroke:#22cc44,color:#f0f0f0
style OP fill:#181818,stroke:#0055ff,color:#f0f0f0
style MOB fill:#181818,stroke:#0055ff,color:#f0f0f0
style OG fill:#181818,stroke:#0055ff,color:#f0f0f0
style RE fill:#181818,stroke:#5588ff,color:#f0f0f0
The network layer is not required for the app to function. The save() method on any model object triggers the entire sync cycle. Changes are applied instantly to the local data, then synced when connectivity is available. "The entire process is just one method call away."
Models extend a base Model class using TypeScript decorators. A single definition serves the database schema, GraphQL schema, and client model simultaneously, eliminating separate schema maintenance.
| Strategy | Behavior | Examples |
|---|---|---|
| instant | Loaded during bootstrapping | Teams, users, projects, labels |
| lazy | Loaded all at once when first needed | Secondary configuration data |
| partial | On-demand loading of subsets | Issues, attachments |
| explicitlyRequested | Loaded only when explicitly requested | Comments, history |
| local | Stored only in client-side IndexedDB | UI preferences |
Persisted directly to the database. Mapped to both GraphQL fields and IndexedDB columns.
E.g., Issue.assignee resolves to a User object, but only assigneeId is persisted.
E.g., User.assignedIssues is a computed one-to-many collection derived from the reverse side.
graph TD
subgraph Registry["ModelRegistry"]
MR["Model Metadata
(load strategy, props)"]
end
subgraph Stores["Store Types"]
FS["FullStore
(complete collections)"]
PS["PartialStore
(on-demand subsets)"]
end
subgraph Models["Model Hierarchy"]
BASE["Base Model Class"]
ISSUE["Issue"]
USER["User"]
PROJ["Project"]
COMMENT["Comment"]
end
MR --> FS
MR --> PS
BASE --> ISSUE
BASE --> USER
BASE --> PROJ
BASE --> COMMENT
FS --> USER
FS --> PROJ
PS --> ISSUE
PS --> COMMENT
style MR fill:#181818,stroke:#0055ff,color:#f0f0f0
style FS fill:#181818,stroke:#9966cc,color:#f0f0f0
style PS fill:#181818,stroke:#9966cc,color:#f0f0f0
style BASE fill:#111111,stroke:#00bbdd,color:#f0f0f0
style ISSUE fill:#111111,stroke:#5588ff,color:#f0f0f0
style USER fill:#111111,stroke:#5588ff,color:#f0f0f0
The bootstrapping process loads workspace data in stages. Returning users skip full bootstrap entirely, needing only a delta sync to catch up on changes since their last visit.
graph TD
START["App Start"] --> CHECK{"Schema hash
match?"}
CHECK -->|"Yes"| DELTA["Delta Sync
/sync/delta"]
CHECK -->|"No"| FULL["Full Bootstrap
/sync/bootstrap?type=full"]
FULL --> STORES["StoreManager creates
FullStore / PartialStore"]
STORES --> PARSE["Parse newline-delimited
ModelName=JSON"]
PARSE --> IDB["Persist to
IndexedDB"]
IDB --> HYD["Hydrate instant
models to memory"]
HYD --> WS["Establish
WebSocket"]
DELTA --> REPLAY["Replay SyncActions
since lastSyncId"]
REPLAY --> WS
WS --> PARTIAL["Partial Bootstrap
(comments, history)"]
style START fill:#0055ff,stroke:#5588ff,color:#fff
style CHECK fill:#181818,stroke:#cc8844,color:#f0f0f0
style FULL fill:#181818,stroke:#9966cc,color:#f0f0f0
style DELTA fill:#181818,stroke:#22cc44,color:#f0f0f0
style WS fill:#111111,stroke:#22cc44,color:#f0f0f0
style IDB fill:#181818,stroke:#9966cc,color:#f0f0f0
Immutable records of data changes broadcast to all clients via delta packets. The server may produce additional side effects, so delta packets can differ from original transactions.
| Field | Description |
|---|---|
| ID | Unique integer, monotonically increasing |
| Model Name | Which model type was affected |
| Model ID | The specific instance identifier |
| Action Type | I (Insert), U (Update), D (Delete), A (Archive) |
| Data | Optional payload with changed fields |
A __schemaHash combining model names, versions, and property names detects mismatches between the app code and locally cached data. If the hash differs, a full bootstrap replaces the local database. This enables fast startup for returning users while ensuring correctness after deployments.
Linear treats the client's IndexedDB as a real database, not merely a cache. The server is "just another client to sync with" rather than the exclusive source of truth.
Changes happen locally first, then sync asynchronously. The UI never waits for server confirmation.
Each client maintains a nearly-complete local database copy of the workspace data.
Network latency is eliminated from the user interaction path entirely. Filtering, searching, and navigating are instant.
Database module manages connectivity, table creation, and schema migrations via the hash-based validation system.
graph TD
subgraph IDB["IndexedDB (Browser)"]
SCHEMA["__schemaHash
(version check)"]
FSTORE["FullStore tables
(teams, users, projects)"]
PSTORE["PartialStore tables
(issues, attachments)"]
TXLOG["Transaction log
(pending mutations)"]
end
subgraph Runtime["Runtime (Memory)"]
POOL["Object Pool
(modelLookup)"]
GRAPH["Object Graph
(MobX observables)"]
end
FSTORE --> POOL
PSTORE -->|"on demand"| POOL
POOL --> GRAPH
GRAPH -->|"propertyChanged"| TXLOG
TXLOG -->|"async"| NET["Network Layer"]
style SCHEMA fill:#181818,stroke:#cc8844,color:#f0f0f0
style FSTORE fill:#181818,stroke:#9966cc,color:#f0f0f0
style PSTORE fill:#181818,stroke:#9966cc,color:#f0f0f0
style TXLOG fill:#181818,stroke:#0055ff,color:#f0f0f0
style POOL fill:#181818,stroke:#0055ff,color:#f0f0f0
style GRAPH fill:#181818,stroke:#5588ff,color:#f0f0f0
style NET fill:#111111,stroke:#00bbdd,color:#f0f0f0
Linear uses two distinct strategies for collaboration: last-writer-wins for structured data, and CRDTs (Y.js) for rich-text documents. The server establishes authoritative ordering for all transactions.
graph TD
CHANGE["User Change"] --> TYPE{"Change type?"}
TYPE -->|"Structured data
(status, assignee, etc.)"| LWW["Last-Writer-Wins"]
TYPE -->|"Rich text
(descriptions)"| YJS["Y.js CRDT"]
LWW --> SERVER["Server orders
all transactions"]
SERVER --> BROADCAST["Broadcast delta
to all clients"]
YJS --> MERGE["Automatic merge
(no conflicts)"]
YJS --> CURSOR["Real-time cursor
visibility"]
YJS --> SNAP["Version snapshots
+ undo/redo"]
BROADCAST --> OPT["Optimistic update
or rollback"]
style CHANGE fill:#0055ff,stroke:#5588ff,color:#fff
style TYPE fill:#181818,stroke:#cc8844,color:#f0f0f0
style LWW fill:#181818,stroke:#0055ff,color:#f0f0f0
style YJS fill:#181818,stroke:#22cc44,color:#f0f0f0
style SERVER fill:#111111,stroke:#00bbdd,color:#f0f0f0
style MERGE fill:#181818,stroke:#22cc44,color:#f0f0f0
Linear did not use CRDTs until recently. The collaboration model for structured data aligns more closely with Operational Transformation (OT) than CRDTs, relying on a centralized server for ordering. Y.js was added specifically for collaborative editing of issue descriptions.
Changes apply instantly to the local Object Graph and IndexedDB. If a transaction fails server-side, it is reversed on the client. The UI never shows a loading spinner for write operations.
Linear's public API is the same GraphQL API used internally. A notable architectural decision: mutations return only a lastSyncId, with actual data updates arriving through the WebSocket sync channel.
graph TD
subgraph GraphQL["GraphQL (Queries & Mutations)"]
Q["Queries
(read data)"]
M["Mutations
(write data)"]
INTRO["Full introspection
+ schema discovery"]
end
subgraph Sync["Streaming REST (Sync Protocol)"]
BOOT["/sync/bootstrap
(full + partial)"]
DELTAE["/sync/delta
(incremental)"]
end
subgraph Realtime["WebSockets (Real-Time)"]
PUSH["Delta packet
push to clients"]
end
M -->|"returns lastSyncId
only"| PUSH
BOOT --> IDB["IndexedDB"]
DELTAE --> IDB
PUSH --> IDB
subgraph External["External Integrations"]
WH["Webhooks
(server-to-server)"]
end
style Q fill:#181818,stroke:#0055ff,color:#f0f0f0
style M fill:#181818,stroke:#0055ff,color:#f0f0f0
style BOOT fill:#181818,stroke:#9966cc,color:#f0f0f0
style DELTAE fill:#181818,stroke:#22cc44,color:#f0f0f0
style PUSH fill:#181818,stroke:#22cc44,color:#f0f0f0
style WH fill:#111111,stroke:#cc8844,color:#f0f0f0
style IDB fill:#181818,stroke:#9966cc,color:#f0f0f0
| Aspect | Detail |
|---|---|
| Rate Limiting | Complexity-based: 250K points/hr (API key) or 200K points/hr (OAuth) |
| Mutation Response | Returns only lastSyncId; data flows via WebSocket |
| Bulk Data | Streaming REST endpoints, not GraphQL (performance) |
| Bootstrap Format | Newline-delimited plain text: ModelName=<JSON> |
| Webhooks | Programmatic registration for server-to-server integrations |
| Error Format | Standard GraphQL errors with extensions for context |
Linear expanded from single-region to multi-region to address GDPR and data residency requirements. Rather than sharding databases, they replicate the entire production deployment per region.
graph LR
CLIENT["Client"] --> CF["Cloudflare
Workers Proxy"]
CF -->|"extract auth,
get JWT + region"| AUTH["Global Auth
Service"]
CF -->|"forward with
signed headers"| REG["Regional
Backend"]
REG --> PG["Regional
PostgreSQL"]
REG --> SRVS["Full Backend
Stack (isolated)"]
AUTH -->|"Pub/Sub"| REG
style CLIENT fill:#0055ff,stroke:#5588ff,color:#fff
style CF fill:#181818,stroke:#cc8844,color:#f0f0f0
style AUTH fill:#181818,stroke:#0055ff,color:#f0f0f0
style REG fill:#111111,stroke:#00bbdd,color:#f0f0f0
style PG fill:#111111,stroke:#9966cc,color:#f0f0f0
style SRVS fill:#111111,stroke:#00bbdd,color:#f0f0f0
Three patterns for shared tables (users, workspaces) across regions:
| Operation | Flow |
|---|---|
| Creating | Auth service first (enforces global constraints), then regional database |
| Deleting | Same order as creating, with Postgres triggers creating audit logs |
| Updating | Regional service updates propagate asynchronously via internal API calls |
Multi-region was rolled out with feature flags, initially only for Linear engineers. System timezone determines default region selection. Background tasks periodically validate all synced records for consistency between services.
Proxy layer for multi-region routing. Caches authentication signatures to avoid repeated round-trips.
Inter-service messaging for async task scheduling and one-way data flow from auth to regional services.
Infrastructure-as-code. The entire setup is managed by approximately three people.
Linear's core philosophy: "the tool should never be slow." Speed is treated as a feature, not a metric. Architectural decisions are made with perceived latency as the primary constraint.
All user interactions operate against local data. Network is never in the critical path.
Avoids loading hundreds of thousands of objects upfront. Issues and attachments load on demand.
Centralized loader coalesces multiple concurrent UI requests for the same data into a single fetch.
Only observed properties trigger React re-renders. No unnecessary component updates.
Returning users skip full bootstrap. IndexedDB persistence means only recent changes need syncing.
Serialized model objects and delta packets cached in MongoDB for 3-4x faster sync compared to alternatives.
Frontend engineers interact only with local data structures. No manual network calls, no loading states for cached data, no manual UI updates. MobX handles reactivity automatically. The result: developers write code as if the app were entirely local.
Linear's interface is designed keyboard-first. The sync engine enables this: because all data is local, filtering, searching, and navigating are instant operations against IndexedDB rather than server round-trips.
| Shortcut | Action | Design Principle |
|---|---|---|
| Cmd/Ctrl+K | Global command palette | Fuzzy search across all actions |
| C | Create issue | Single-key for common actions |
| E | Edit issue | No modifier keys for frequent ops |
| J / K | Navigate list | Vim-inspired traversal |
| / | Filter | Instant local filtering |
Shortcut hints appear on hover after a brief delay, teaching users faster workflows progressively. Every action in the interface is accessible via keyboard, from creating and moving issues to filtering, assigning, and changing status.