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 → RenderEvery 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:
- SQL queries with indexes
- Transactions (ACID guarantees)
- Full-text search
- Sub-millisecond query times on in-memory data
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
- Linear runs SQLite in the browser via WebAssembly for sub-millisecond local reads
- A custom sync engine over WebSockets delivers deltas to keep all clients consistent
- Optimistic updates make every write feel instant by applying it locally before hitting the server
- Reactive queries automatically re-render components when underlying data changes
- The pattern trades bootstrap complexity for near-native performance after first load
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!