How Linear built the fastest web app you've ever used.
Open Linear, click an issue, change its priority, navigate to a different project. Every interaction completes in under 50 milliseconds. No spinners. No "saving…" toast. No loading states. It feels native because it almost is — and the architecture that makes it possible is genuinely different from how 99% of web apps are built. Here's the teardown.
01The problem with traditional web apps
Most SaaS products follow the same architecture: UI sends a request → server processes → server responds → UI updates. Every action waits on the network. Even a perfectly optimized backend takes 80–300ms round-trip. That delay is perceptible — your brain registers it as "the app is doing something." That's why traditional web apps feel slower than native apps even when they're objectively fast.
Below 50ms, humans don't perceive a delay — the action feels caused by the click, not following from it. Above 100ms, it starts feeling like a request. Above 200ms, your brain reorients: "wait, did that work?"
Linear's whole architecture is designed around the 50ms ceiling. Every interaction must complete within that budget. The network can't be in the critical path — there isn't time.
02The core idea: local-first
Linear keeps a full copy of your data in your browser. Not a cache, not a recent-pages snapshot — an actual replica of every issue, project, comment, and label you have access to, stored in IndexedDB.
When you click an issue, Linear doesn't fetch it. It already has it. The "navigation" is just rendering data that was already in memory or one IndexedDB read away (sub-millisecond on modern devices). The server isn't involved.
When you edit something — change priority, assign someone, write a comment — Linear updates the local copy first, then sends a message to the server in the background. The UI never waits for the server. The user sees the change happen instantly because, locally, it did.
// User clicks "change priority to urgent"
function updatePriority(issueId, newPriority) {
// 1. Update local store immediately (synchronous, ~0ms)
localStore.issues[issueId].priority = newPriority;
// 2. Trigger UI re-render (next frame, ~16ms)
emit('issue:changed', issueId);
// 3. Send to server in background (network round-trip, ~80ms)
// User never sees this delay
syncQueue.push({ type: 'update', issueId, newPriority });
}
Total perceived latency: one frame (16ms). The server round-trip happens, but invisibly. Compare to the traditional architecture where the same action takes 80–300ms before the UI even acknowledges the click.
03The sync engine — how it stays consistent
Local-first sounds great until you ask: what if two people edit the same issue at the same time? Linear's answer is a custom sync engine built around event sourcing.
Instead of sending state ("set priority to urgent"), the client sends events ("user X changed priority of issue Y to urgent at timestamp Z"). The server is an append-only log of these events. Every connected client subscribes to the log.
When two events conflict, the server uses deterministic resolution rules (usually last-write-wins, sometimes more sophisticated CRDT-style merging). Every client receives the resolved sequence and applies it locally. Eventually, everyone converges.
// Client → Server
{
type: 'mutation',
id: 'mut_abc', // idempotency key
timestamp: 1742000000123,
action: 'updateIssue',
payload: { issueId: 'I-42', priority: 1 }
}
// Server → All Clients
{
type: 'delta',
sequenceNumber: 98742,
appliedMutations: ['mut_abc'],
changes: [
{ entity: 'issue', id: 'I-42', fields: { priority: 1 } }
]
}
The sequenceNumber is the magic. Every client tracks the highest sequence it has seen. On reconnect, it asks: "give me everything after sequence X." The server replays only the missing deltas. This is how Linear stays consistent across disconnects, refreshes, and offline periods.
04Optimistic UI — and what happens when the server says no
"Optimistic UI" is the term for showing the result of an action before the server confirms it. Linear's approach is the most rigorous I've seen:
- Every mutation gets a temporary ID generated client-side (UUID).
- UI updates immediately with the temporary state.
- Server responds — either acceptance (now the mutation is "real") or rejection (rare, usually permission errors).
- On rejection, the client reverses the local change and shows a non-blocking error toast. The user sees the action briefly happen, then undo — like a "wait, no" gesture.
This works because rejections are rare. In a well-designed system, almost every action the user can attempt will succeed. The UI is optimistic because the odds are with you.
05Request collapsing — never ask twice
If a user opens 10 issues in quick succession, Linear doesn't fire 10 requests. It collapses them into one batched query. This is implemented through a tiny middleware that holds requests for 8–16ms before sending:
const pending = new Map();
let flushTimer = null;
function fetchIssue(id) {
// Already requested in this batch? Return the same promise
if (pending.has(id)) return pending.get(id);
const promise = new Promise((resolve) => {
pending.set(id, { resolve });
});
pending.set(id, { resolve: promise.resolve, promise });
// Schedule flush at next microtask boundary
if (!flushTimer) {
flushTimer = setTimeout(flushBatch, 12);
}
return promise;
}
async function flushBatch() {
const batch = [...pending.entries()];
pending.clear();
flushTimer = null;
const ids = batch.map(([id]) => id);
const results = await fetch('/api/issues', {
method: 'POST', body: JSON.stringify({ ids })
}).then(r => r.json());
batch.forEach(([id, { resolve }]) => resolve(results[id]));
}
The 12ms delay is imperceptible to the user but lets the app collapse parallel requests. A page that renders 20 issues fetches them in one request instead of 20. Network usage drops by 90%+, server load drops proportionally, and each fetch is faster because there's no per-request handshake overhead.
06The keyboard-first model — bypassing the mouse
The mouse is slow. Hand → mouse → screen target → click is 500–800ms even for expert users. Keyboard shortcuts are 100–200ms. Linear treats this as a perception problem: fast keyboard interactions make the whole app feel faster, even if the underlying operations take the same time.
Every action in Linear has a keyboard shortcut:
- ⌘ K — command palette (find anything)
- C — create new issue
- A — assign to me
- P — change priority
- L — add label
- G then I — go to inbox
- G then P — go to projects
These follow a Vim-inspired prefix pattern: single-key actions when context is clear, two-key sequences for navigation. The discoverability is solved by the command palette — every shortcut is searchable, and pressing the same action through ⌘K once teaches you the shortcut for next time.
07What to take from this
You probably don't need Linear's full sync engine. Building event sourcing with conflict resolution is months of work and significant operational complexity. But you can steal techniques piecemeal:
Cache aggressively in IndexedDB. Any data the user has seen once should be available instantly the second time. Use @databases/sqlite or just hand-rolled IndexedDB. Don't fetch from the network when you have local data.
Update UI before the server confirms. For non-critical mutations, render the result immediately, then send the request. On the rare rejection, roll back. Your users will feel the speed difference.
Batch network requests. Hold parallel fetches for 10–20ms and combine them into one. Build it once, use it everywhere.
Add keyboard shortcuts. Start with ⌘K, then add single-key actions for the most common operations. Discoverability comes from the palette.
Stop using spinners. If something is fast enough to not need a spinner, don't add one. If it needs one, ask whether you can make it faster — usually by caching, batching, or going optimistic.
∞The mindset shift
The traditional way of building web apps treats the network as a feature: data lives on the server, the client fetches it. Local-first treats the network as a synchronization mechanism: data lives on the client, the server is one of many replicas.
That inversion is the whole shift. Once you make it, latency stops being a thing you optimize and becomes a thing that almost can't exist — because the operation completes locally before there's anything to optimize.
Linear feels fast because there's nothing slow to feel.