What makes Linear feel instant
Open Linear, type a few keys, and the app responds before your finger leaves the key. Filter ten thousand issues by assignee, the list re-renders in the same frame. Hit cmd-K, and the menu is already there. The thing feels like a native app pretending to be a website — instead of the other way around.
That feeling isn't one optimization. It's a stack of decisions, each of which by itself sounds like overkill, that compound into something a normal SPA can't reach. This post walks through every layer of that stack — from the data model, through the network, into the render pipeline, and down to the bundle — using what Linear's team has said publicly, what's been confirmed by their CTO in a community reverse-engineering project, and where I'm inferring, saying so out loud.
The thesis is simple. Linear doesn't fetch data. The data is already on the device. Everything else is in service of making that work without falling apart.
The data lives on the device
A normal SPA, when you click a link, does roughly this: render a loading state, fire a request, wait for the round trip, parse the response, render the page. The minimum latency is one round trip — typically 50-300ms — and the user sees a spinner.
Linear inverts the model. The browser holds a full local replica of your workspace's data in IndexedDB. Reads are local; the network is for sync, not fetch. When you navigate, there's nothing to wait for — the data was there before you clicked.
Tuomas Artman, Linear's CTO, has framed it succinctly: "you're effectively just building the front end. You have data in memory, you've got data objects, which you render on screen. Then you modify those and that's it. Your feature is done." The round-trip-disguised-as-a-loading-state goes away because the round trip goes away.
Two databases per client: a small registry called linear_databases tracking which workspaces exist, plus one full database per workspace named linear_<hash> . Each model gets its own object store keyed by UUID. There's a _meta store carrying the persistence state — the most important field is a single integer called lastSyncId that we'll come back to. There's a _transactions store for pending writes that haven't been acked yet, so a refresh doesn't lose them.
Schema migrations are tracked via a schemaHashcomputed from all the model and property metadata. Mismatches trigger a migration on open. None of this is novel for someone who's built a database before — but most web apps treat IndexedDB as a glorified key-value cache, and Linear treats it as a real database. That distinction is what unlocks everything else.
The sync engine, simplified
The cost of putting the data on the client is that the client now needs to be kept in sync. Linear built their own sync engine — they call it the Linear Sync Engine, or LSE. It is not built on Replicache, not on a CRDT framework, not on Operational Transform. Artman, on a Hacker News thread: "Server-controlled global sync IDs with last-write-wins conflict resolution (no OT or CRDT)."
The unit of replication is called a SyncAction. It's a tuple of { id, modelName, modelId, action, data }where action is one letter — I insert, U update, D delete, Aarchive, V unarchive, C covering, plus sync-group ops. A delta is just an array of these, ordered by ascending id. There's no separate snapshot/operation distinction. It's brutally simple.
The id is the linchpin. Linear assigns a globally monotonic integer to every change in the entire database — not per-workspace, but database-wide. The reverse-engineering writeup notes that lastSyncId "often increments significantly, indicating that it is tracking changes across all workspaces in the system." If your client's lastSyncId is behind the server's, you know exactly how stale you are. To catch up, you ask the server for every SyncAction with an id greater than yours. The server replies with the array. You apply them in order. Done.
Conflict resolution is last-writer-wins, with rebase. When you change a field locally, an in-flight transaction holds your snapshot. If a delta arrives that touches the same field while your transaction is still queued, the transaction's rebase() method updates its "original" value to the delta's value, and the local model resets to your value. Your write still wins, eventually — but the engine keeps the bookkeeping clean.
For rich text in issue descriptions specifically, Linear later retrofitted CRDTs because last-writer-wins doesn't make sense for a text document being typed in two places. But for everything else — status changes, assignee swaps, label reorders — last-writer-wins is enough. They tested the boundary and didn't generalize past it.
Optimistic mutations, in four stages
When you change something in Linear, the UI updates before the server has heard about it. There's no spinner, no "saving" state, no flash when the response comes back. That works because every mutation flows through a four-stage in-memory queue, plus a fifth IndexedDB-persisted layer.
Stage 1 is created — the transaction was instantiated this microtask, but hasn't been committed yet. Stage 2 is queued — committed at the next microtask, also written to the _transactionsIndexedDB store so a page refresh doesn't lose your write. Stage 3 is executing — sent to the server, awaiting the GraphQL response. Stage 4 is completed but unsynced — the server acked, but we haven't yet seen the matching delta packet that advances lastSyncId .
Independent transactions that share a microtask batch get merged into a single GraphQL request, with aliases. So if you rapid-fire-update three issues, what hits the wire is one request with three aliased mutations: o1: issueUpdate(...) , o2: issueUpdate(...) , and so on. One round trip for three writes.
Rollback is just snapshot reversion. Each transaction carries the pre-mutation state of the model. If the server rejects, the engine reverts the in-memory model and the UI reacts. The invariant is stated bluntly in the reverse-engineering write-up: the local DB "cannot contain changes that have not been approved by the server." Optimism is a UI affordance, not a data-truth compromise.
The same machinery powers undo/redo. Each transaction implements an undoTransaction that returns a new transaction for redo. An UndoQueue manages the stack. Editor components subscribe to a transactionQueuedSignalwhile the user is editing, then unsubscribe on save — transactions queued during that subscription are grouped as one undo entry. The undo operation itself doesn't create a new entry on the redo stack. None of this needs special undo logic per feature — it falls out of the queue.
The cold start
Putting all your data on the client means cold-loading all your data once, which would be fatal if you tried to do it in one shot for a workspace with 50,000 issues. Linear handles this with a streaming bootstrap.
On first load, the client requests /sync/bootstrap?type=full&onlyModels=... . The server responds with a text/plain stream of newline- delimited JSON, one model row per line. The final line is a _metadata_ record carrying lastSyncId , subscribedSyncGroups, databaseVersion , and the count of returned models.
Two things make this work. First, line-delimited JSON streams, which means the parser starts iterating before the response has fully arrived. The browser writes rows into IndexedDB progressively rather than holding the whole blob in memory. Second, the onlyModels parameter — Linear declares a load strategy per model:
instant— fetched at bootstrap; default for things you'd see immediately on the home view.lazy— fetched all-at-once on first demand.partial— fetched on demand, subset per query (used for things like comments scoped to a specific issue).explicitlyRequested— never fetched implicitly.local— IndexedDB-only, never synced to the server (used for purely client-side state).
Comments and issue history specifically are not in the instant bootstrap. They're heavy, dense, and mostly irrelevant to the first paint. Defer them, and the cold load is dramatically smaller.
Subsequent loads aren't even a bootstrap — they're a delta. The client persisted its lastSyncId to the _meta store on the previous session. On the next session it sends that integer to /sync/delta; the server responds with the array of SyncActions that happened since. For an active user, this is usually a tiny response. The DB is essentially never re-fetched.
The render layer
Local data only matters if you can render it fast. Linear uses React, but bypasses most of React's reconciliation through MobX. The combination is unusual enough to be worth understanding.
Every model in memory lives in what Linear calls the Object Pool — a single map keyed by UUID. There is exactly one Issue instance per issue, one Userper user. If an issue references its assignee, it stores a reference to the user object, not a copy. Memory stays flat relative to the unique entity count, regardless of how deep the relational graph runs.
Each model property is wired into MobX via decorators. When you assign issue.title = "new title", MobX's setter fires, the property's observers are notified, and only the React components observing that specific property of that specific issue re-render. The parent list, the surrounding chrome, the navigation — none of it touches React's reconciler.
This is what people are pointing at when they say "Linear's UI doesn't feel like React." They are still using React. They've just made the boundary of a React update small enough that the cost of reconciliation, for any given mutation, is negligible. It's not magic — it's that re-rendering one <span> is fast, and Linear arranges for any given change to re-render exactly one <span>.
The frame budget
At 60fps, you have 16.6ms per frame to do everything — read DOM, execute JavaScript, recalculate styles, lay out, paint, composite. The classic killer is layout thrashing: JavaScript reads a layout property (offsetHeight, getBoundingClientRect ), then writes a style, then reads again. Each read forces the browser to flush pending style recalculations and run a synchronous layout. In a tight loop, that's how an interaction goes from buttery to jittery.
Linear hasn't published the specifics of how they handle this, but the patterns the application demonstrates — smooth list virtualization, transform-based animations that hold 60fps even during heavy sync — are consistent with the standard playbook: batch DOM reads and writes via requestAnimationFrame so all reads happen against a stable layout and all writes happen at the same point in the frame; prefer transform and opacity for animation because both bypass layout and paint entirely and run on the compositor; never animate width , top, or left directly.
Worth flagging: this section is inference, not direct citation. The Linear team hasn't published a "here's how we hit 60fps" post. But the application visibly does it, and there's a finite set of techniques that get you there.
The bundle
Linear's most-cited public performance write-up is from March 2021. They migrated their build from Parcel to Rollup, dropped support for legacy browsers, and got these numbers:
- 50% less code shipped (30% smaller after compression).
- 10–30% faster page loads from a cold cache, depending on network speed.
- 59% faster to active issues in Safari, 82% faster to large backlogs, when pre-warmed.
- 40–50% faster in other browsers, 60–65% faster to backlogs.
- 70–80% more memory-efficient across all browsers and the desktop app.
The single biggest lever was dropping legacy browser support. That removed thousands of lines of polyfills and let the bundler ship native ES2020 instead of transpiling to ES5. Coupled with Rollup's tree-shaking — which is more aggressive than Parcel's was at the time — the V8 engine spends measurably less time parsing and compiling the bundle on every page load.
Code splitting and lazy-loaded routes do the rest. The initial bundle contains the app shell, auth context, and the bootstrap kickoff. Heavy modal flows, settings screens, and integration configuration only load when the user navigates to them.
Linear hasn't published the specifics, but they almost certainly prefetch lazy chunks on hover or focus — the pattern where you listen for mouseover on an internal link and inject a <link rel="prefetch">, so by the time the user clicks, the chunk is already in the browser's HTTP cache. This is the same idea as instant.page, and it's nearly free.
Beyond a single tab
Most users have Linear open in two or three tabs. If each tab ran its own sync engine, that would mean three WebSockets, three IndexedDB writers contending for the same workspace database, and three full copies of the in-memory object pool. The bandwidth and memory cost compounds badly.
The right answer here is a Shared Worker — a singleton background thread that all tabs of the same origin can connect to. The sync engine runs once, in the worker, owning the single WebSocket and the single IndexedDB writer. Tabs become lightweight clients communicating with the worker over postMessage ; an update applied in tab A is broadcast to tab B by the worker.
I should be upfront here: Linear hasn't publicly confirmed they use a Shared Worker. The reverse-engineering write-up doesn't document one either. But the cross-tab sync behavior the application exhibits — change something in one tab, see it appear in another within milliseconds, with no duplicate WebSocket traffic — is hard to explain without a Shared Worker or something architecturally equivalent. So this section is well-grounded inference rather than direct citation.
The PWA layer
Linear ships a Service Worker. The exact toolkit they use isn't publicly documented, but the patterns are standard:
- Pre-cache the app shell on the worker's install event — the HTML, the JS bundles, the CSS, the fonts. On subsequent visits, the network is bypassed for the shell entirely; it's served from local cache, painting in milliseconds.
- Cache-first for immutable assets — fonts, images, hashed JS chunks. The cache is the answer; the network is the fallback.
- Stale-while-revalidate for things that change but don't have to be perfectly fresh — serve the cached response immediately, fetch the network response in the background, update the cache for next time.
- Network-first for the things that must be authoritative— auth, the sync delta endpoint. Hit the network; fall back to cache only on failure.
On version updates, the well-behaved pattern is to install the new worker in the background but not activate it automatically. Activating mid-session can break in-flight state. Instead, the new worker waits; the page sends a "do you want to refresh?" prompt; on confirm, the page posts SKIP_WAITING to the worker and reloads. Linear does something like this — the exact UI is subtle but it's there.
The auth path, optimized away
Authentication is the silent killer of cold-start performance. The naive flow is: redirect to auth provider, validate token, wait for response, then start loading the app. That blocks every pixel of the UI behind a network round-trip to a different service.
Linear's approach is to render the cached UI before validating the session. The local IndexedDB and the cached app shell are enough to paint the interface. A locally-stored JWT or session cookie is enough for the first frame to be authoritative from the user's perspective. Real validation runs in parallel — if it fails (revoked session, IP restriction, etc.), the app gracefully redirects to login. If it succeeds, nothing visibly happens. Either way, the user saw their data immediately.
This is a small architectural decision with a big perceived impact: it decouples visual readiness from cryptographic validation. The two things are happening on the same thread, but only one of them blocks paint.
The backend reality check
It's worth saying out loud: the backend is not what's making Linear feel fast. Linear runs on Google Cloud — Postgres for the primary store, Memorystore (Redis) as the event bus, GKE for the workloads, NodeJS and TypeScript end-to-end. They're at "tens of terabytes of data," with the issues table partitioned 300 ways to make pgvector indexing tractable. Tom Moor (Head of US Engineering) summarized this on the Google Cloud blog. None of it is edge-deployed. The API doesn't run at a Cloudflare POP.
The "feels like edge" experience comes entirely from the client. The local data store, the optimistic mutations, the granular rendering — those are what compress the perceived latency. The backend is a normal-looking horizontally-scaled GKE cluster. That's instructive: you don't need an exotic backend to feel fast. You need a thoughtful client.
What you can steal
Not every app needs to be local-first. Most don't — the complexity of a sync engine is real, and only earns its keep when the workload is read-heavy, latency-sensitive, and concurrently edited. But several of Linear's individual moves are useful well outside that domain.
- Per-model load strategy. Even without local-first, declaring "this data is needed at first paint; this is needed on demand; this is never auto-fetched" at the schema level keeps cold loads honest as the app grows.
- Optimistic mutations as the default. If your backend is reliable enough that 99% of writes succeed, treat rollback as the exception. Most apps are needlessly pessimistic.
- Granular reactivity. Whether via MobX, Solid's signals, or Vue's reactive primitives — bypassing tree-wide reconciliation for hot paths buys you orders of magnitude on update-heavy UIs. React is now learning this with React Compiler; the principle predates it.
- Modern-only bundles. Drop legacy targets if your audience can take it. The 50% reduction is real and one-time; you do it once and never pay again.
- The four-stage mutation queue. Even in a stateless app, separating "intent → in-flight → committed" gives you free retry logic, free undo, and free offline-tolerance.
- Data on the device. The biggest idea, with the biggest cost. Don't reach for it for fun. But when your read latency is the user-facing problem, and the dataset fits in the browser, it's the only architecture that gets you to instant.
Linear didn't invent any of these techniques in isolation. What they did is treat each one as essential rather than optional, and compound them. That's the whole story. Speed isn't a feature you add at the end. It's a property of the architecture you chose at the beginning.