Programming guides for beginner...
Any comments are welcomed....
I hope it helps!!! Thanks for drop by...
Powered By Blogger

Monday, June 8, 2026

Linear Is Fast Because the Browser Is the Database

Linear Is Fast Because the Browser Is the Database

Disclosure: This post was researched and drafted with AI assistance. Primary source: Dennis Brotzky, "How's Linear so fast? A technical breakdown", performance.dev, 3 May 2026; surfaced on the HN front page the week of 8 June 2026. The sync-engine description, the Parcel → Rollup → Vite → Rolldown bundler arc, the React + TypeScript + MobX + Postgres + Redis + turbopuffer stack, the 50% / 30% / 59% / 70–80% build-pipeline numbers, the modulepreload + service-worker precache technique, the inlined boot script, the "render first, authenticate second" pattern, the per-property MobX observable + observer() granular re-render model, the 0.10s–0.35s transition variables, and the transform / opacity / paint / layout property tiering are all from that post. The author is an outside observer; he has never worked at Linear and has not seen their code. Architectural inferences in the "original take" section are the blog's synthesis. Stack entries and numbers were not independently verified.

A CRUD app takes 300ms to update an issue. Linear does the same update in a few milliseconds. The difference is a single architectural inversion: Linear does not treat the server as the source of truth for the UI. The server is a sync target. The database is in the browser. Almost every other optimization in Dennis Brotzky's reverse-engineering write-up — which hit the HN front page this week — is a downstream consequence of that one decision.

The architectural move worth studying in 2026 is the data layer. Everything else is downstream.

The local-first sync engine, in three parts

Brotzky's write-up is a tour, not a discovery, and the three pieces of the sync engine are the part most worth re-stating clearly.

1. The data is already there. When the app boots, it hydrates from IndexedDB into an in-memory MobX object pool, and every UI query hits that pool. There is no "loading issues" state because the issues are already on the user's machine. Heavy tables like Issue and Comment lazy-hydrate on demand: a 10,000-issue workspace boots about as fast as a 100-issue one because startup cost tracks workspace structure, not workspace size.

2. Mutations do not wait for the network. Changing a status updates the MobX observable, writes the change to a durable transaction queue in IndexedDB, and queues it for the server. The network is touched last. If the server rejects, the observable reverts and there is a brief flicker; in practice, this almost never happens because invalid mutations are caught before the transaction is even created.

3. One delta, one cell. When a server confirmation arrives — yours or a collaborator's — the client receives a small JSON envelope describing what moved and applies it by writing to the corresponding MobX observable. Because every property on every model is its own observable, MobX knows which components depend on which fields. A 50-issue update is 50 cell re-renders, not a list re-render.

Take any one of those three away and the app starts to feel slow. A local database without optimistic writes still spins on save. Optimistic writes without granular observables still jank on every update. Granular observables without a local database still wait on initial load. Linear's speed is a property of the system, not any single layer.

The first-load pipeline is a separate engineering project

If the sync engine is the answer to "feels fast while you work," the loader is the answer to "feels fast when you arrive." Brotzky's account of Linear's build pipeline is a four-migration arc — Parcel → Rollup → Vite → Rolldown — driven by the same goal each time: ship less code, faster. The numbers Linear published from their own migration: 50% less code shipped, 30% smaller after compression, cold-cache page loads 10 to 30% faster, time-to-first-paint of the active-issues view dropped 59% on Safari, memory usage dropped 70 to 80%.

The bulk of the win came from dropping legacy browsers (no polyfills, no ES5 transpilation, no nomodule fallback), tighter dead-code elimination, and aggressive code splitting. Even after all of this, Linear still ships roughly 21 MB of minified JavaScript, but split into hundreds of route-level chunks fetched on demand. The entry script fires modulepreload tags for the whole critical path so the browser parallel-fetches them before the entry script's first import resolves, collapsing the water-fall into a single parallel batch. A service worker with a precache manifest of about 1,200 hashed assets then pulls down the rest of the route chunks lazily after the first page load; within a few seconds of hitting the login screen, the full app is sitting in cache, and the app is offline-capable because the local-first sync engine already has the user's data in IndexedDB.

The boot script is the part most teams will copy first

The cheapest Linear trick to reproduce is also the one most likely to slip past you: the inlined boot logic in <head>. Before any bundle has parsed, the inline JavaScript reads localStorage.splashScreenConfig, restores the user's remembered shell tokens (sidebar background, base color, border color, sidebar width, dark mode), and applies them to document.documentElement.style. It checks whether localStorage.ApplicationStore exists. If it does, the user has used Linear in this browser before, which means their workspace is already in IndexedDB. If it does not, the shell flips to the logged-out layout and the login flow takes over.

The bundle never tries to be smart about authentication. The actual session token lives in a cookie. The next request — the WebSocket handshake, a sync delta, any HTTP call — is the thing that fails with a 401 if the session has gone stale, and the client redirects to login. Render first, authenticate second. The pattern is consistent with the rest of the architecture: trust the local, the server is the source of truth for correctness, the two reconcile asynchronously.

Stack composition: a deliberate refusal of the modern default

The stack list in the write-up is interesting mostly because of what is not in it. React, TypeScript, MobX, Postgres, a CDN, a service worker, IndexedDB. No Next.js, no React Server Components, no TanStack Query, no edge database, no fancy framework. Brotzky calls out the simplicity as a feature, not an oversight: keeping the app entirely client-side removes the constant question of "am I on the server or the client" and gives a single mental model for the entire app.

Backend is Node.js + TypeScript, PostgreSQL on Cloud SQL with the issues table partitioned 300 ways, Memorystore Redis as event bus + cache + sync cursors, turbopuffer for similar-issue vector search, Kubernetes on GCP with one workload per concern, and Cloudflare Workers as a multi-region edge proxy. The two big concessions to the modern web are Rolldown-Vite (with plugin-react-oxc, not @vitejs/plugin-react) and the inline app shell in the head. Everything else is straight 2018-React-with-MobX, and that is a deliberate choice: the technology that ships the data fastest is the technology that ships the data.

The original take: the design is also the bottleneck

Most write-ups of Linear's performance end on the bundler or the sync engine. The post's most underrated observation is in the "Designed for speed" section: a perfectly built sync engine still loses to a slow input model. If the fastest path to an action requires a mouse, three menus, and a click, the user pays for those steps regardless of how fast the engine runs.

Single letters edit the focused issue. Two-letter combos navigate. ⌘ K opens a command palette that searches the local MobX object pool, not a server. Every common action has a shortcut, and every action can be done with a mouse. Engineering speed makes a single interaction fast. Design speed makes the path to each interaction short. For a tool used all day, the difference between a shortcut and a two-second mouse path compounds over every action.

The animation rules complete the same thesis. Browsers have three tiers of property changes — composited (transform, opacity), paint (color, background-color, border-color, fill), and layout (width, height, top, left, margin, padding) — and Linear only animates the first two. The margin-left: 2px; transition: all 0.2s example in the post is a perfect villain: a small visual change that recomputes the layout of every row beneath the hovered one, on every frame, for the full 200ms of the transition. Durations sit at 0.10s–0.35s, well below the 100ms cause-and-effect threshold, and Linear defaults to asymmetric timing — instant on enter, 150ms fade on exit.

The synthesis most people will miss: the fast app is one where every layer is in the same conversation. The data is local, the mutations are optimistic, the observables are granular, the input is keyboard-first, the animations stay on the GPU, the loader ships less code, and the service worker fills in the gaps. None of those are the trick. The trick is the discipline of refusing to let any one layer leak latency into the next.

What this means for you

  • If your team treats the server as the source of truth for the UI: the cheapest single change is the optimistic update. SWR and TanStack Query both support it; the mutate(key, optimistic, false) pattern gets you surprisingly close to Linear's feel without rewriting the data layer.
  • If you maintain a Vite or Rollup config: the manualChunks pattern in the post — one chunk per npm package above ~3 KB, cached independently — is the move. Bump a single dependency, invalidate one chunk, not the whole vendor graph.
  • If you animate anything in a tool used all day: audit your CSS for transition: all. Replace margin and padding animations with transform. Default new transitions to 0.1s–0.25s, not 0.3s. The 100ms cause-and-effect threshold is real.
  • If you build for slow networks or emerging markets: the service-worker precache + modulepreload pair is the single highest-leverage combination in the post. It collapses a multi-second cold load into a single parallel batch and makes the rest of the app offline-capable for free.

What to do this week

# 1. If your app makes a /me or /api/user call before rendering:
#    - Add the inlined localStorage boot check to your <head>.
#    - If localStorage.<your-app-store> exists, render the shell
#      immediately and let the next request do the 401 detection.
#    - One inline script removes one round-trip from every cold load.

# 2. If you maintain a Vite config:
#    - Switch to per-package manualChunks above ~3 KB.
#    - Add <link rel=modulepreload> tags for the critical-path
#      vendor chunks in your index.html template.
#    - Add a service worker with a precache manifest of route chunks.
#      Warm the cache in the background after first paint.

# 3. If you build for slow networks or emerging markets:
#    - The service-worker precache + modulepreload pair is the
#      single highest-leverage combination. It collapses a
#      multi-second cold load into a single parallel batch and
#      makes the rest of the app offline-capable for free.

The bottom line

Linear feels fast because of a single architectural decision: the data the user came to edit is already on their machine. Rolldown-Vite, modulepreload, the service worker, MobX, the IndexedDB hydration, the boot script, the keyboard-first input model, the animation tiers — all downstream of it. If you want a fast web app, the question is "why is my CRUD waiting on the network at all," and the answer in 2026 is "it does not have to."

Related reads from this blog

Sources

No comments:

Post a Comment