cd ../writing
// teardown · performance

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.

5 techniques local-first architecture ~50ms target latency © apply anywhere

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.

// perceptual latency thresholds
instant< 50ms
fast< 100ms
noticeable100–200ms
sluggish200–500ms
broken> 500ms

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.

conceptual flow
// 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.

sync protocol — simplified
// 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.

Don't apply optimistic UI to financial transactions. "Move $5000 from checking to savings" — that needs to wait for server confirmation. The bar for optimism is: if the worst case is "user sees an action undo," it's safe. If the worst case is data loss or financial impact, it isn't.

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:

request collapsing
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.