← All posts

One store, several writers - replacing a cache of REST answers with a pool of rows

The board stopped being a function of the last query the server ran. How Rezee's client keeps canonical rows in memory, mirrors them to IndexedDB, and why a complete slice is smaller on the wire than the single page it replaced.

Sep 22, 2026 · 13 min read · Kash Gohil · LinkedIn · X

Rezee's client used to cache responses. A board was GET /issues?filter=&page=&project=&team=, the answer was stored under that query key, and a change notice on the workspace stream invalidated the key and refetched. Changing a tab was a new server query. Changing a filter was a new server query. The lot of it was 1,340 lines holding 115 query factories.

That design has one flaw, and it is not performance. The applier can patch a row it already holds and cannot tell whether the row still belongs on the board, because the board is not a function of the row. It is a function of the last SQL the server ran for that key. Move an issue into a project and the project's board is wrong until something refetches it, and nothing on the client knows that.

The replacement is Linear's read model: a database of rows on the client, and views computed over it. This post is that store - what is in it, what is deliberately not, the rule that keeps several writers from fighting, and the measurement that decided the size of the first sync.

The pool is the read model, the disk is its mirror

The store is an in-memory pool keyed by workspace, type and id. IndexedDB sits under it and does two things: it is loaded into the pool at launch, and it is written in batches behind it. Screens never read the disk.

interface Space {
  rows: Map<PoolType, Map<string, Held>>;
  indexes: Map<StoreIndex, Map<string, string>>;
  /** Bumped per type, so a board of issues does not recompute on a message. */
  revisions: Map<PoolType, number>;
}

const spaces = new Map<string, Space>();

Per workspace, because an id is unique inside one and nowhere else - issue 3 exists in every workspace - and because opening a second workspace must not empty the first.

Nothing in the pool is async. A screen reads it through useSyncExternalStore with a selector, during render. A read that could suspend would put a network's worth of uncertainty back into a board that exists precisely so that changing a filter does not wait on one.

The revision counter per type is the whole of the render-cost story. A chat message bumps message, not issue, so a board of two hundred issues does not recompute because somebody said hello.

Public indexes - issue number to id, doc number to id, pull (repo, number) to id, channel handle to id - are derived as rows enter the pool rather than stored. Frames and URLs speak numbers; the store keys on ids; a copy of that map on disk could only ever agree or be wrong.

Four rules, each of which was a bug first

One type per id. A run used to be a PipelineRunSummary in a list and a PipelineRun on its own page, stored under two names so they would not overwrite each other. That split is a bug report about caching response shapes. Now an Issue is an Issue on the board and on the page; fields the board does not need, like the body, are filled when the page opens. A missing field is a missing field, not a second type.

A field has one writer. Plan's log owns Issue.status. Chat's live adapter owns a new Message. Yjs owns a document's live body and live title. The store's Doc.title is titleProjected - the last snapshot, for the rail and the breadcrumb, allowed to lag the heading by a keystroke. Those are two fields with two writers. Calling them both title is how you get a rail that flickers between two truths.

Per-reader fields are not on the shared row. A channel's unread count, whether a reaction is yours, whether you may edit an issue, a document's capability. Every one of those is keyed by (object, user) or computed at read time. Putting any of them on a broadcast packet makes the packet lie to every recipient but one. writable is the subtle case: it is derived from this reader's team memberships intersected with the issue's project teams, so it cannot be stamped on the stored row by the server that sent it.

Absence is not emptiness. A missing Issue is a miss - fill it, or wait. An Issue whose title is "" is an issue with no title. Screens that conflate the two paint an empty state over data that is one frame away, which is the single most common way a local-first client looks broken.

The version gate is written once

Every write into the pool carries a version, and a write older than the row it lands on is dropped. That rule lives in one function, in the pool, rather than in the pool and again in SQL.

Two spellings of one rule are two answers the moment somebody edits one of them. The same applies across tabs: every tab opens IndexedDB and every tab writes, so the disk re-checks the stored version inside its own transaction. A tab holding a stale row cannot overwrite what a newer tab already wrote.

Optimistic edits use the same versions through a journal. A mutation opens one, writes rows, and either forgets it or puts back exactly what it found:

interface Before {
  ws: string;
  type: PoolType;
  id: string;
  held: Held | null;
  /** The version this journal first wrote, for a row it created. */
  mine: number;
  index: { name: StoreIndex; key: string | null } | null;
}

Only the first touch per row is recorded, so a mutation that writes the same row twice still rolls back to where it started. And a rollback stops at a row whose version has moved past the one recorded: two edits to one row can overlap, and if the first is refused after the second was confirmed, restoring the first's "before" would throw away an answer the server has already given.

This is the part that replaced refetch-on-error. The old rollback was an invalidation: the mutation failed, the board refetched, the truth arrived. That refetch is exactly what the store exists to remove.

Who may query, and when

A screen may treat the store as its source only when its slice is defined as complete for that screen. Until then the adapter is write-only: REST still answers, and a successful GET upserts rows into the pool on the way past.

Complete has a definition per surface. Plan after bootstrap is every issue, label, project, cycle and member the caller may read. The chat rail is every channel the caller is in; the chat pane is a window and never a complete corpus. Code lists are the pull cards and run summaries the working-set fill defined, not the git graph.

"The store has some rows" is not completeness, and the failure this rule exists to prevent is concrete: with only the Active tab's first page on disk, switching to Backlog and running a local query gives you an empty column rather than a loading state. Being confidently wrong is worse than being slow.

Bootstrap, delta, and the measurement that sized them

The server keeps one action row per mutation, written inside the mutation's transaction, so seq order is commit order and a mutation cannot land without its action. A client with a cursor inside retention asks for the delta. A client without one, or past retention, takes a dump.

GET /api/workspaces/:ws/sync/bootstrap?models=issue,label,project,cycle,member,team
GET /api/workspaces/:ws/sync/delta?since=<lastSyncId>&models=issue,label,...

Retention is thirty days, sized by how long a laptop stays shut rather than how long a reconnect takes. A fortnight is the case it is built for, and a window equal to the case it is sized for is a window that fails it. Past the window, deltaVerdict answers pruned and the client dumps, which is the same answer it gets on its first sight of a workspace: slower, and correct.

The delta has a ceiling of 5,000 actions. Past it the client bootstraps instead, because replaying a fortnight of a busy workspace is more bytes than the dump those actions add up to, and the dump is one snapshot rather than a queue to walk.

The ordering around the dump is the part people get wrong. The client opens the stream first and buffers, then bootstraps, then applies the buffered frames whose seq is above the dump's lastSyncId. The dump is read in one snapshot, so no action falls between its rows and its cursor. And the write into the store is a diff by id in one transaction - upsert what the dump has, delete what it does not - so a board painted from disk never empties mid-refill.

Whether the dump was affordable at all was a measurement, not an argument:

Bootstrap dump size against one page of the board it replaces on the wire, gzipped complete slice 18,957 B board, page 1 31,468 B raw JSON complete slice 131,006 B for 182 issues board, page 1 102,676 B for 50 issues, with bodies measured against the largest real workspace: 182 issues, 9 projects, 1 label, 2 members
Every issue the reader may see, without bodies, against the first page of the board that used to be fetched instead. About 700 bytes an issue, 104 on the wire. Including bodies would have added 242,141 bytes and nearly tripled the dump, which is the omission earning its keep.

The complete slice is smaller gzipped than one page of the board it replaces. That result is entirely about the body field: fifty issue bodies outweigh a hundred and eighty issue rows. Leaving Issue.body off bootstrap and filling it when the page opens is what makes "fetch everything" cheaper than "fetch a page".

Extrapolated, ten thousand issues is around 7 MB raw and 1 MB gzipped. Partial bootstrap by "updated in the last ninety days" is Linear's path and it is a different slice, not a hedge inside this one - it belongs to a workspace a hundred times the size of the one measured here.

Issues arrive in slices rather than all at once. An issue is in exactly one of open, closed or archived, decided by its own row. The first sync asks for open only; closed and archived load in the background a few seconds later, or immediately if a screen needs one. Which slices are loaded is kept beside the cursor, and a screen whose slice is not loaded reads as syncing rather than as empty. Loading a slice deletes only inside that slice and does not move the cursor.

The stream stays an invalidation bus

Rezee's workspace stream is a 24-hour buffer of notices: issue 12 changed. It carries no bodies. We chose SSE over polling partly for this: a dropped frame is stale, never wrong, which is what keeps the sync engine an optimization rather than a correctness dependency.

The store did not change that. The moment a packet carries a body, authorization has to live on the log rather than on the subsequent GET, and the per-reader fields above become lies on the wire. So the log carries rows for models whose audience is a workspace, the stream carries handles for everything else, and the two are allowed to disagree because the head check heals it within about thirty seconds.

There are thirty-one models on the log today, and adding one to the type without adding it to the exported array is a compile error that names the model:

export const syncModelsAreListed: [
  Exclude<SyncModel, (typeof SYNC_MODELS)[number]>,
] extends [never] ? true : ...

satisfies alone only checks the other direction. A missing model is silently dropped from a delta by the models filter while lastSyncId still advances, which is a client stepping over actions it never received. That is a bug nobody would find for weeks, so it is a type error instead.

The outbox is the one table that is not a cache

Everything above can be dropped and refilled. Writes the server has never seen cannot.

export type OutboxKind =
  | "issue.create"
  | "issue.patch"
  | "issue.archive"
  | "issue.comment"
  | "chat.send";

Short, and that is the whole list. Three rules hold it up, each of which is a way of losing work if it is missing.

Order is the order they were made in. seq is an autoincrement and the replay walks it. A patch that follows a create has to land after it; two patches to one issue are last-write-wins in the order the person made them. Sending them in parallel is a race whose loser is whichever edit the person made second.

A refusal stops. 401, 403, 409 and 422 mean the write is dead and will not become alive by being sent again, so the row keeps the sentence that killed it and comes out of the queue. An outbox that retries a refusal forever is a data-loss machine with a spinner on it. Unreachable is the opposite: the row is untouched and the next attempt is the whole answer.

A replay is an upsert. Every queued write carries its own id, generated client-side, and the server answers a retry with the row that id already made. A request that landed and whose answer did not is replayed onto the same row rather than filing a second one.

What is deliberately absent from that list: approving an agent's proposal. It is irreversible and permission-class, and a queue is a promise to try later, which is the wrong promise for an act whose answer may have changed. See the agent approval gate for why that one is never optimistic.

What it cost

  • Completeness is a per-screen commitment, and it is easy to claim early. Every surface that reads locally has to be able to say what "every row I need" means. Getting that sentence wrong does not produce a slow screen, it produces a confidently empty one.
  • Several writers is a discipline, not a mechanism. Nothing in the type system stops the chat adapter writing Issue.status. The rule is enforced by review and by naming the two fields differently when two writers genuinely exist.
  • The disk can be wrong in ways the network cannot. A bad row persists across reloads, which is why the replica has a per-browser kill switch rather than a per-deploy one. Somebody hitting a bad row can turn the store off and keep working while it is fixed.
  • Query did not go away. It still answers for everything the server computes - metrics, mergeability, search, diffs, logs - and for page-only reads. The win was not deleting a library, it was deleting the habit of caching a GET and hoping the next invalidation names the right one.

The bet: the expensive thing in a product like this is not fetching data, it is the client being unable to answer a question it has all the data for. A pool of rows can answer "which issues are in this project" without asking. A cache of pages cannot, no matter how fresh it is.

FAQ

Is this local-first or offline-first?

Offline-capable, with an online source of truth. Reads come from a local store that survives a launch, and a short list of writes queue in an outbox and replay. It is not a CRDT for structured data: the server remains authoritative for issues, projects and the rest, and conflicts are resolved by version rather than by merge. Document bodies are a real CRDT, over a separate protocol.

Why IndexedDB rather than SQLite on OPFS?

We shipped SQLite in a worker over OPFS first and replaced it. The store runs in memory either way, so the disk only has to save rows and load them back - which is the one job IndexedDB does without a worker, a WASM build, or a cross-tab file lock. Losing the OPFS lock used to mean a second tab fell back to the network entirely.

What happens when the client has been offline past the retention window?

It bootstraps. The delta endpoint answers pruned rather than handing back a partial replay, and the client takes a fresh dump, which is exactly the path a first launch takes. Slower than a delta, and correct, which is the right way round for a case measured in weeks.

Does the board still work in a second tab?

Yes. Every tab has its own pool and every tab opens the disk, and writes are guarded by the stored version inside their own transaction, so a stale tab cannot overwrite a newer tab's row. That was the specific failure of the previous design, where a tab without the file lock had no local data at all.

How do you keep two writers from fighting over a field?

By making it two fields when there are genuinely two writers. A document's live title is Yjs; the projected title on the store's row is the last snapshot, and the rail reads the projected one. They are allowed to differ by a keystroke. The bug is not divergence, it is pretending one name covers both.