Back to Blogs

How Linear Builds a Blazing-Fast UI: Local-First Architecture Explained

Today

If you have used Linear, you have noticed something unusual: it feels faster than your filesystem. Switching issues, searching across projects, updating priorities — everything is instant. No spinners, no skeleton loaders, no waiting. For a web app handling thousands of records per team, this should not be possible. But it is, and the engineering behind it is worth understanding in depth.

The Root Problem with Traditional Web Apps

Most web apps follow a request-response loop:

User action → HTTP request → Server processes → Database query → Response → Render

Every interaction is bottlenecked by the network. Even at 50ms round-trip latency, multiply that across every click, keystroke, and navigation and the app feels sluggish. The best optimization traditional apps offer is caching and prefetching — but you are still fundamentally waiting for a server.

Linear took a different architectural bet: what if the database lived in the browser?

Local-First: The Core Idea

Local-first software keeps a complete, queryable copy of your data on the client. The server becomes a sync target — not the source of truth for reads.

Traditional:  User → Network → Server DB → Response → Render
Linear:       User → Local DB → Render (sync happens in background)

Every read is a local query. Every write is applied locally first, then synced to the server asynchronously. The UI never waits for the network.

This is not new as a concept — it is how native desktop apps have always worked. Linear brought it to the web.

SQLite in the Browser

The local database powering Linear is SQLite, running in the browser via WebAssembly. SQLite compiled to WASM gives you a fully functional relational database with:

Here is what bootstrapping a SQLite WASM database looks like:

import initSqlJs from "sql.js";
 
async function initLocalDB() {
  const SQL = await initSqlJs({
    locateFile: (file) => `/wasm/${file}`,
  });
 
  const db = new SQL.Database();
 
  db.run(`
    CREATE TABLE IF NOT EXISTS issues (
      id TEXT PRIMARY KEY,
      title TEXT NOT NULL,
      status TEXT NOT NULL,
      priority INTEGER,
      assignee_id TEXT,
      team_id TEXT NOT NULL,
      updated_at INTEGER NOT NULL
    );
 
    CREATE INDEX IF NOT EXISTS idx_issues_team ON issues(team_id);
    CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
    CREATE INDEX IF NOT EXISTS idx_issues_updated ON issues(updated_at DESC);
  `);
 
  return db;
}

Queries against this local database are synchronous and sub-millisecond — indistinguishable from reading from memory.

function getIssuesByStatus(db: Database, teamId: string, status: string) {
  const stmt = db.prepare(`
    SELECT id, title, priority, assignee_id
    FROM issues
    WHERE team_id = :teamId AND status = :status
    ORDER BY priority ASC, updated_at DESC
  `);
 
  return stmt.all({ teamId, status });
}

No async, no await, no loading state. The result is available before the next frame renders.

The Sync Engine

A local database is only useful if it stays in sync with the server. Linear built a custom sync engine that handles this bidirectionally.

Delta Sync: Only Transfer What Changed

On initial load, Linear bootstraps the client database with your team's data. After that, the server only sends deltas — records that changed since your last sync timestamp.

interface SyncDelta {
  type: "create" | "update" | "delete";
  entity: string;
  id: string;
  data?: Record<string, unknown>;
  serverTimestamp: number;
}
 
async function applyDeltas(db: Database, deltas: SyncDelta[]) {
  db.run("BEGIN TRANSACTION");
 
  try {
    for (const delta of deltas) {
      if (delta.type === "delete") {
        db.run(`DELETE FROM ${delta.entity} WHERE id = ?`, [delta.id]);
      } else {
        const cols = Object.keys(delta.data!);
        const placeholders = cols.map(() => "?").join(", ");
        const updates = cols.map((c) => `${c} = excluded.${c}`).join(", ");
 
        db.run(
          `INSERT INTO ${delta.entity} (id, ${cols.join(", ")})
           VALUES (?, ${placeholders})
           ON CONFLICT(id) DO UPDATE SET ${updates}`,
          [delta.id, ...Object.values(delta.data!)]
        );
      }
    }
 
    db.run("COMMIT");
  } catch (err) {
    db.run("ROLLBACK");
    throw err;
  }
}

The server pushes deltas via a persistent WebSocket connection. When any team member updates an issue, the delta propagates to all connected clients within milliseconds.

Conflict Resolution

What happens when two users edit the same issue simultaneously? Linear uses Last-Write-Wins (LWW) with logical timestamps for most fields — simple and predictable. For text content (descriptions, comments), they use a CRDT (Conflict-free Replicated Data Type) approach that merges concurrent edits without conflicts.

interface VectorClock {
  [clientId: string]: number;
}
 
function resolveConflict<T>(
  local: { value: T; clock: VectorClock; ts: number },
  remote: { value: T; clock: VectorClock; ts: number }
): T {
  // If one clock dominates the other, use that value
  if (dominates(remote.clock, local.clock)) return remote.value;
  if (dominates(local.clock, remote.clock)) return local.value;
 
  // Concurrent writes: fall back to wall-clock timestamp
  return remote.ts > local.ts ? remote.value : local.value;
}
 
function dominates(a: VectorClock, b: VectorClock): boolean {
  return Object.keys(b).every((k) => (a[k] ?? 0) >= b[k]);
}

Optimistic Updates: Writing Locally First

When you drag an issue from "In Progress" to "Done" in Linear, the UI updates in the same frame as your mouse release. The server write happens afterward. This is optimistic updates — assume success, revert on failure.

class IssueStore {
  private db: Database;
  private pendingOps = new Map<string, PendingOperation>();
 
  async updateStatus(issueId: string, newStatus: string) {
    const opId = crypto.randomUUID();
    const previousStatus = this.getIssue(issueId)?.status;
 
    // 1. Apply locally — this triggers reactive re-render immediately
    this.db.run(
      "UPDATE issues SET status = ?, updated_at = ? WHERE id = ?",
      [newStatus, Date.now(), issueId]
    );
    this.notify(issueId); // triggers UI re-render
 
    // 2. Track the pending op for rollback if needed
    this.pendingOps.set(opId, {
      type: "updateStatus",
      issueId,
      previousStatus,
    });
 
    try {
      // 3. Sync to server in background
      await api.updateIssue(issueId, { status: newStatus });
      this.pendingOps.delete(opId);
    } catch (err) {
      // 4. Server rejected — roll back local state
      this.db.run(
        "UPDATE issues SET status = ? WHERE id = ?",
        [previousStatus, issueId]
      );
      this.notify(issueId);
      this.pendingOps.delete(opId);
      toast.error("Failed to update issue. Change reverted.");
    }
  }
 
  private getIssue(id: string) {
    return this.db.prepare("SELECT * FROM issues WHERE id = ?").getAsObject([id]);
  }
}

The user never sees a loading state. On the rare occasion the server rejects (conflict, permissions), the UI silently reverts and shows an error toast.

Reactive Queries: Auto-Updating UI

The final piece is making the UI automatically re-render when local data changes. Linear implements a reactive query layer that subscribes components to specific SQL queries.

class ReactiveQuery<T> {
  private subscribers = new Set<() => void>();
  private cachedResult: T[] = [];
 
  constructor(
    private db: Database,
    private sql: string,
    private params: unknown[] = []
  ) {
    this.execute();
  }
 
  private execute() {
    this.cachedResult = this.db.prepare(this.sql).all(...this.params) as T[];
  }
 
  // Called whenever a write touches relevant tables
  invalidate() {
    const prev = this.cachedResult;
    this.execute();
    if (!shallowEqual(prev, this.cachedResult)) {
      this.subscribers.forEach((fn) => fn());
    }
  }
 
  subscribe(fn: () => void) {
    this.subscribers.add(fn);
    return () => this.subscribers.delete(fn);
  }
 
  get result() {
    return this.cachedResult;
  }
}

In React, this integrates cleanly with useSyncExternalStore:

function useQuery<T>(sql: string, params: unknown[] = []): T[] {
  const query = useMemo(
    () => new ReactiveQuery<T>(localDB, sql, params),
    [sql, JSON.stringify(params)]
  );
 
  return useSyncExternalStore(
    (notify) => query.subscribe(notify),
    () => query.result
  );
}
 
// Usage in a component — re-renders automatically when data changes
function IssueList({ teamId }: { teamId: string }) {
  const issues = useQuery<Issue>(
    "SELECT * FROM issues WHERE team_id = ? ORDER BY priority ASC",
    [teamId]
  );
 
  return issues.map((issue) => <IssueRow key={issue.id} issue={issue} />);
}

When any write touches the issues table — local or from a sync delta — every subscribed component re-renders with fresh data. No polling, no prop drilling, no manual cache invalidation.

How It All Fits Together

┌─────────────────────────────────────────────────────┐
│                    React UI Layer                    │
│         useQuery() → renders from local DB          │
└────────────────────┬────────────────────────────────┘
                     │ reads (sync, <1ms)
┌────────────────────▼────────────────────────────────┐
│              SQLite (WASM) — Local DB                │
│         Full queryable replica of your data         │
└──────┬──────────────────────────────┬───────────────┘
       │ optimistic writes            │ delta sync
       │ (immediate)                  │ (background)
┌──────▼──────────────────────────────▼───────────────┐
│                   Sync Engine                        │
│     Conflict resolution · Retry · Queue             │
└─────────────────────────┬───────────────────────────┘
                          │ WebSocket
┌─────────────────────────▼───────────────────────────┐
│                  Linear API Server                   │
│          Source of truth for persistence            │
└─────────────────────────────────────────────────────┘

Performance Numbers

Metric Traditional SPA Linear Local-First
Issue list render 200–800ms <16ms
Search results 300–500ms <5ms
Status update 100–400ms 0ms (optimistic)
Initial load 1–3s 1–3s (one-time bootstrap)
Offline support None Full

After the initial bootstrap, Linear is effectively an offline-capable native app running in your browser.

Can You Build This?

Yes — and several open source projects make it easier today:

Library What it provides
ElectricSQL Postgres → SQLite sync engine
PowerSync Managed local-first sync service
RxDB Reactive database for browsers
TinyBase Lightweight reactive store
Triplit Full local-first framework

The tradeoff worth understanding: local-first adds bootstrap complexity, conflict resolution logic, and sync infrastructure. It pays off at scale when snappiness is a product differentiator — not for every CRUD app.

Key Takeaways

Local-first is not a trend — it is the architecture that separates apps that feel fast from apps that are fast. Linear proved it works at production scale.


Have thoughts on local-first architecture? Reach out on Twitter/X!