Linear’s Speed Isn’t Magic, It’s a Radical Bet Against the Network

Linear’s Speed Isn’t Magic, It’s a Radical Bet Against the Network

A deep technical breakdown of how Linear achieves sub-10ms UI updates by inverting the traditional client-server architecture, and why this approach is both brilliant and controversial.

Linear’s Speed Isn’t Magic, It’s a Radical Bet Against the Network

A deep technical breakdown of how Linear achieves sub-10ms UI updates by inverting the traditional client-server architecture, and why this approach is both brilliant and controversial.

Architecture diagram of Linear's local-first sync engine showing the flow from IndexedDB to MobX observables to React UI
Linear’s architecture inverts the traditional client-server relationship: the database the UI reads from lives in the browser, with the server acting as a sync target.

Linear’s co-founder Tuomas Artman dropped a bombshell at a 2024 conference: “Literally the first lines of code that I wrote was the sync engine.” For a startup aiming to disrupt project management, that decision is borderline insane. You don’t build infrastructure first, you ship a barely functional MVP and iterate. Unless your entire thesis is that speed is the product.

A few milliseconds is all it takes to update an issue in Linear. A traditional CRUD app doing the same thing takes about 300ms. The difference isn’t better code or faster servers. It’s a fundamental inversion of the client-server relationship. The database the UI reads from lives in the browser. The server became a sync target, not a source of truth.

This isn’t just a clever optimization, it’s a radical bet against physics. And it comes with trade-offs that the engineering community is still debating.

The Core Inversion: Database in the Browser

Most web apps live inside an agonizing loop. The user clicks. The browser fires an HTTP request. A server queries a database and sends the result back. The browser repaints. The end result is a spinner, a skeleton, or a frozen UI for a few hundred milliseconds.

Linear flips this entirely. The actual database the UI reads from is in IndexedDB. Mutations apply locally first, then asynchronously push to the server, which broadcasts deltas back to other clients via WebSocket.

The difference in code is stark:

// A traditional web app updating the server
async function updateIssue({ issue }) {
  showSpinner();
  const response = await fetch(`/api/issues/${issue.id}`, {
    method: "PATCH",
    body: JSON.stringify({ title: issue.title }),
  });
  const updated = await response.json();
  setIssue(updated)
  hideSpinner();
}

// vs Linear
issue.title = "Faster app launch";
issue.save();

The first line, issue.title = "Faster app launch", updates an in-memory MobX observable. The second line queues a transaction that the sync engine batches and flushes to the server asynchronously. The key insight: the UI re-renders synchronously off the local, in-memory update. No spinners. No waiting. The network is the bottleneck, and Linear eliminated the need to wait for it.

This isn’t a new idea. Meteor.js pioneered analogous patterns with Minimongo years ago. But Linear executed it at a level of polish that made the technique invisible, users don’t think about syncing because it just works.

The Stack: Surprisingly Conservative

Despite the architectural novelty, Linear’s stack is refreshingly boring:

Layer Technology
UI Runtime React + react-dom
State Management MobX (observable graph, granular re-renders)
Language TypeScript (single language end-to-end)
Bundler Rolldown-Vite + plugin-react-oxc
Backend Node.js + TypeScript
Database PostgreSQL on Cloud SQL (issues table partitioned 300 ways)
Cache Memorystore Redis
Edge Cloudflare Workers
Desktop Electron

No edge database. No React Server Components. No fancy framework. They chose client-side rendering despite its reputation for slow initial loads. The difference is they engineered their way around CSR’s weaknesses rather than adopting server-side rendering’s complexity.

This approach keeps a clean mental model, you never have to think about whether you’re on the server or client, whether window is accessible, or whether cache headers are correct. There’s beauty in those constraints.

Making the First Load Feel Instant

A client-side rendered app has a fundamental problem: it must download JavaScript before showing anything useful. Linear’s solution is a masterclass in progressive enhancement and aggressive preloading.

The Bundler Evolution: Parcel → Rollup → Vite → Rolldown

Linear has rewritten their build pipeline four times. Each migration was driven by the same goal: ship less code. The results are concrete and measurable:

  • 50% less code shipped
  • 30% smaller after compression
  • Cold-cache page loads 10, 30% faster
  • Time-to-first-paint of the active-issues view dropped 59% (on Safari)
  • Memory usage dropped 70, 80%

Most of that came from dropping legacy browser support (no polyfills, no ES5 transpilation, no nomodule fallback) combined with better dead-code elimination and aggressive code splitting.

Even with these optimizations, Linear still ships roughly 21 MB of minified JavaScript. The difference is it’s aggressively code split into hundreds of route-level chunks fetched on demand. Every npm package above ~3 KB gets its own chunk, cached independently. Bumping a single dependency invalidates only one chunk, the rest stay cached.

// vite.config.ts (reconstruction)
export default defineConfig({
  plugins: [react()],
  build: {
    target: "esnext",            // no legacy syntax, no polyfills
    cssMinify: "lightningcss",
    modulePreload: { polyfill: false },
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes("node_modules")) {
            const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
            if (pkg) return `vendor-${pkg}`;
          }
        },
      },
    },
  },
});

Modulepreload: Collapsing the Waterfall

Once you’ve split the bundle into hundreds of chunks, you create a new problem: each chunk imports other chunks, and the browser doesn’t know about them until it parses the entry script. Without help, the load timeline becomes a waterfall, fetch the entry, parse it, fetch its imports, parse those, fetch their imports.

Linear solves this with modulepreload tags in the <head /> of their index.html:

<script type=module crossorigin
  src="https://static.linear.app/client/assets/html.2_JBQs3Q.js"></script>
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/vendor-mobx.Crhy2qQc.js">
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/SyncWebSocket.Djw6l_Op.js">
<link rel=modulepreload crossorigin
  href="https://static.linear.app/client/assets/DatabaseManager.DKssGAN8.js">
<!-- ...around many more -->

The crossorigin attribute on each preload matches the crossorigin on the entry script, so the browser reuses the cached fetch instead of treating preload and import as separate resources. The cold-load timeline collapses from a sequential waterfall into a single parallel batch.

The Service Worker: Prefetching Everything

Linear goes further with a service worker that precaches the rest of the route-level chunks. The worker has a precache manifest of around 1,200 hashed assets, route chunks, icons, fonts, and pulls them down lazily after the first page load. Within a few seconds of hitting the login screen, the full app is sitting in cache.

This buys two things:

  1. Subsequent navigations skip the network entirely, the service worker answers directly from its cache.
  2. The app works offline. Combined with the local-first sync engine, you can read issues, create new ones, edit titles and descriptions, and change statuses. Everything queues in the local transaction store and flushes the next time the connection comes back.

The Inlined App Shell: Render Before You Authenticate

One of Linear’s most interesting architectural decisions is their approach to authentication. The conventional flow is: fetch HTML → load bundle → validate session → fetch user → fetch workspace → render. One to three seconds before the user sees anything.

Linear assumes the happy path and verifies in the background. The inline boot script checks whether localStorage.ApplicationStore exists:

if (localStorage.getItem("ApplicationStore") === null) {
  document.documentElement.classList.add("logged-out");
}

If it’s there, the user has used Linear before, which means their workspace is already in IndexedDB. If it’s missing, the shell flips to its logged-out layout and the login flow takes over. The actual session token sits in a cookie, the bundle never tries to be smart about it. It just renders what it has and lets the next request (a WebSocket handshake or sync delta) trigger a 401 if the session is stale.

Before any JavaScript bundle has parsed, the inlined script reads localStorage.splashScreenConfig and applies the user’s remembered shell tokens directly to document.documentElement.style: sidebar background, base color, border color, sidebar width. It detects color-scheme preference and Electron context.

The entire pattern is consistent: trust what’s local, let the server be the arbiter of correctness, reconcile asynchronously.

The Sync Engine: Three Pillars of Speed

Most of what makes Linear fast lives downstream of one decision: the server is a sync target, not a source of truth for the UI. The sync engine operates on three pillars that work together to produce the feeling of instant responsiveness.

1. The data is already there

When the app boots, it doesn’t fetch the workspace from the server. It hydrates from IndexedDB into an in-memory MobX object pool. Every query from the UI goes to the pool first. There’s no “loading issues” state because the issues are already on the user’s machine.

Linear has applied this data-level code splitting just like their JavaScript bundles. The two heaviest tables, Issue and Comment, lazy-hydrate on demand. Startup cost tracks the workspace structure, not the workspace size. A 10,000-issue workspace boots about as fast as a 100-issue one.

2. Mutations don’t wait for the network

When you change an issue’s status, three things happen almost simultaneously:

  1. The MobX observable updates, so the UI reflects the change
  2. The mutation is written to a durable transaction queue in IndexedDB
  3. The mutation is queued for the server

The network hasn’t been touched yet. The user never waits to see their own change. Retry, rollback, durability across reloads, all handled in the background. If the server rejects, the observable reverts with a brief flicker. In practice, that almost never happens because most invalid mutations are caught before the transaction is created.

This is a good place to discuss architectural trade-offs between integrity and performance that every system must navigate.

3. One delta, one cell

When the server confirms a mutation (yours or someone else’s), the change comes back as a small JSON envelope describing what moved. The client applies it by writing to the corresponding MobX observable.

Because every property on every model in Linear is its own observable, and every component that reads one is wrapped in observer(), MobX knows exactly which components depend on which fields. A change that updates one field of one issue re-renders exactly the components that read that field. Not the parent list, not the sidebar, one cell. A 50-issue update is 50 cell re-renders, not a list re-render.

This is what allows a busy workspace to stay smooth when ten people are editing things simultaneously. The cost of receiving updates scales with what changed, not with what’s on screen.

Why the three fit together

Take any one 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 isn’t a property of any single layer. It’s a property of the system.

Designed for Speed: Input Model Matters

Speed isn’t just an engineering problem, it’s a design problem. 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 underlying engine runs.

Linear integrates the keyboard as a primary tool. Every common action has a shortcut. The command palette is one keystroke away. The right-click menu is custom-built. None of these are accidents, they’re thoughtful design decisions that compound over thousands of daily interactions.

The command palette searches the local MobX object pool, not a server. The entire app is accessible from a single pane. Navigation is search. Issue creation is search. Status changes are search scoped to statuses. One primitive, used everywhere, running on data that’s already in memory.

Engineering speed makes a single interaction fast. Design speed makes the path to each interaction short.

Animations: What You Don’t Animate Matters

All the performance work can be undone by bad animations. Linear restricts its animations to a handful of composited properties (transform, opacity) that hand the work to the GPU, avoiding layout-triggering properties (width, height, top, left, margin, padding) that force the browser to recompute the position of every subsequent element.

/* What Linear does */
.row:hover {
  background-color: var(--color-bg-hover);
  transition: background-color 0.12s;
}
.icon-arrow {
  transform: translateX(0);
  transition: transform 0.15s;
}

They also keep durations short, most transitions are below the 100ms cause-and-effect threshold, with asymmetric timing on enter and exit. Hover highlights, popovers, and panels appear instantly when summoned, then fade out over 150ms when dismissed.

The Controversy: Is This Worth It?

The HN discussion around this architecture is sharply divided. The criticisms are substantive:

“My Linear client gets stuck ‘syncing’ all the time, completely blocking all interaction. I’d rather have loading spinners than getting locked out of the whole app for 10 minutes.”

“The whole ‘optimistic updates’ pattern without plan for the ‘sad path’ is setting users up for data loss. Whatever can go wrong in an online-first world can also go wrong in an offline-first world, but you might get informed of that all at once at a later time, or not at all.”

Even Linear’s co-founder, Tuomas Artman, acknowledges: “Conflicts almost never happen.” That’s true for their use case, but it’s not generalizable to applications with higher write contention or more complex business logic.

This tension between performance and correctness mirrors challenges in balancing rich domain models against performance at scale, you can’t have both maximum speed and perfect consistency without significant engineering investment.

The Physics Argument

One commenter raised a critical point: “You can absolutely create a traditional CRUD app where doing the same thing takes more like 30ms, not 300ms.” The argument is that a well-optimized backend within 10ms RTT of users can render responses within 10ms. Orchestrating multiple edge regions can cover most of the world’s population with sub-100ms latency.

Aaron Boodman (creator of Replicache and Zero, the closest open-source analogs to Linear’s approach) pushed back: “The only AWS region < 10ms away from us-east-1 is us-east-2. us-west-1 is 60ms away. eu-central-1 is 100ms away. Asia is 200ms away. And this is datacenter-to-datacenter traffic. Actual latency over the public internet to residential providers is far worse.”

The physics is brutal. The speed of light imposes a minimum ~200ms round trip for users in Asia hitting US servers. No amount of backend optimization can erase that. You either accept quarter-second commits for most users or embrace eventual consistency.

What This Means for Your Architecture

Most applications don’t need a custom sync engine. Libraries like Tanstack Query and SWR can get surprisingly close with optimistic updates for most use cases:

// optimistic mutation with SWR
mutate(
  `/api/issues/${issue.id}`,
  { ...issue, title: "Faster app launch" },
  false
);

The key idea is simple: UI responsiveness should not depend on network latency. Users perceive speed based on how quickly the interface reacts, not how quickly the server responds.

The patterns that produce Linear’s speed are:

  • Eliminate unnecessary network requests through local state everywhere possible
  • Optimistic updates with proper rollback handling
  • Granular observable re-rendering so changes affect only what changed
  • Lazy hydration of data, just like code splitting
  • Aggressive preloading of everything the user will need next
  • Drop legacy support, the compatibility tax is enormous

The hard part isn’t the implementation. It’s the dedication to the craft over years as the codebase matures, expands, and pushes up against new constraints.

If you’re building high-performance systems, consider whether your application genuinely needs strong consistency for every interaction. For project management tools, collaborative documents, and many CRUD applications, the answer is increasingly “no.” Embracing performance-first engineering vs. architectural crutches might mean rethinking your fundamental assumptions about where data lives.

The lesson isn’t that you should build a custom sync engine. The lesson is that the network is the enemy, and the best way to win against it is to engineer around it entirely. The user shouldn’t know you made a request. They should just see the result.

Share:

Related Articles