Skip to content

Offline persistence

The client takes an opt-in persistence adapter. With one configured, every pending write the client tracks — create, update, delete, rename, restore — is journaled write-through to local storage, and the next client on the same namespace (a reloaded page) rehydrates the queue and replays it. A reload becomes a reconnect.

Without an adapter the client is memory-only: disconnected writes replay on reconnect, but a reload loses anything still unsent.

A reload is a reconnect — the same replay rules

Section titled “A reload is a reconnect — the same replay rules”

There are no separate cold-start semantics. Rehydrated writes replay through the exact reconnect contract: writes that never reached the server replay however long you were away; a write that was sent and sat unconfirmed past the replay horizon (~30 minutes) surfaces as unconfirmed rather than risking a double-apply; sequence-guarded writes keep compare-and-set semantics at any age. The journal stores each write verbatim — the exact wire event under its original event id — so the server’s de-duplication cannot tell a replay-after-reload from a replay-after-reconnect.

One write kind gets extra care: a sent create (creates aren’t de-duplicated). When the hydrated document cache already proves the document exists — say the reload raced the create’s already-exists rejection — the merge drops the stale create on the spot instead of rehydrating it, so it can never mask or clobber the newer cached state, offline included. Otherwise the re-subscribe’s authoritative answer reconciles it as usual.

One thing deliberately does not survive a reload: promises. An awaited write can’t resolve on a page that no longer exists, so rehydrated writes are fire-and-forget, and their outcomes report through the client’s write-error callback and the pending counts on sys:client-docs-status.

The two paths that discard queued writes — the retention bound evicting a stale record at load, and the replay horizon dropping a sent-but-unconfirmed write — never do so silently. Each lands on sys:client-docs-status as a per-document discardedWrites entry: how many writes were dropped, why (retentionExpired or replayHorizon), when the oldest of them was made (oldestStagedAt — every queued write carries its authoring time, so the loss is datable even when nothing was ever sent), and the oldest first-send time when any of them ever reached the transport. An awaited write still gets its rejection, but a rehydrated fire-and-forget write has no promise left to reject — the status document is its witness, and the report is what lets an app tell the user “some offline changes from last week couldn’t be recovered” instead of saying nothing. Entries accumulate for the session and the document is reactive, so a badge or toast hangs off the ordinary subscription machinery.

The pending counts on sys:client-docs-status say that unsent work exists; each document’s entry also says since when. oldestStagedAt is the authoring time of the document’s oldest pending write — stable across restamps, reloads (it persists with the journal) and adoption, and spanning the namespace like the count itself, so a sibling tab’s queued write dates it too. It is what lets a UI say “unsent changes from Tuesday” rather than merely “3 unsent changes”, and it clears with the count as the writes ack.

Rich text dates too, its own way. Yjs edits don’t live in the write queue — they ride the document cache’s snapshot — so the client stamps the moment a document’s Yjs lane first goes ahead of the server (a dirty episode) and feeds that stamp into oldestStagedAt. The stamp persists on the cache record (localYjsAheadSince), so after a reload a document whose only unsent work is rich text still counts as pending, still carries its date, and the sync status truthfully reports disconnected-saving (“changes pending”) rather than offline-cached (“showing saved data”). The claim clears only when the lane verifiably settles — a flush’s acknowledgment, or the reconnect state-vector exchange confirming the server holds everything — and briefly over-claiming (a sibling tab may have pushed the same content already) errs toward caution by design. The cache is shared, so the marker merges across tabs: two claims keep the earliest stamp, and a settled tab’s write clears another tab’s claim only when its snapshot verifiably contains that tab’s content.

Offline reads: the persisted document cache

Section titled “Offline reads: the persisted document cache”

The same adapter caches server truth write-through: every answer the server gives about a document — initial load, resume, patches, and the negative outcomes, so a deleted or never-existing document renders truthfully. Each document’s Yjs state is re-encoded from the live documents at cache time, which is what carries local unsent edits across a reload: server state plus local ahead-content in one snapshot, self-healed both ways by the reconnect state-vector exchange — and stamped with the dirty-episode marker described above whenever the snapshot carries such unsent content, so the reload knows it is rendering more than saved data.

On boot the cache hydrates before anything reaches the wire, so an offline reload renders: cached documents appear immediately, pending writes re-project on top, and the sync status reports offline-cached — “showing saved data” — until the server re-confirms each one. Online, the re-subscribe carries what the cache knows (each document’s sequence number and Yjs state vector), so the server answers with a resume or deltas instead of re-shipping documents the client already has.

“Showing saved data” is answerable per document as well as app-wide. Each hydrated document’s entry on sys:client-docs-status carries cacheHydrated along with the cachedAt of the snapshot being rendered, so a badge can say when the data was saved and not merely that it was. The flag is removed the moment that document’s own authoritative answer lands, which means the badge clears per document on reconnect rather than all at once when the connection returns — a document whose answer is still in flight keeps saying “saved data”, because that is still what is on screen. An offline edit does not clear it either; the document is being edited, but what it renders is still the saved copy.

Cached documents render, but apps still subscribe to everything they read, connection or not — the cache never bypasses that contract. The separate subscribed field answers the narrower question of whether this session’s subscription has been answered: it flips only on a real authoritative answer, never from the cache and never from an optimistic write.

Housekeeping is automatic: the cache is shared across tabs (the newest cursors win; a tie merges the Yjs snapshots), writes coalesce per document, and at boot the oldest records are evicted past the caps (1000 documents / 64 MiB by default), with sys: documents pinned — schemas and the index are what offline validation and rendering hang off, so a warm offline boot resolves a document’s schema without a round-trip. Boot evictions are counted on sys:client-docs-status (evictedCachedDocuments) — benign next to discarded writes, since the data is refetchable once online, but on an offline boot an evicted document simply isn’t there, and the count is the app’s explanation.

Truthful state needs truthful waits, so there are two. A document is settled only once the server has answered for it, a wait that correctly stays pending through an offline boot. Editing needs less: the document only has to be available — readable and backed by a resolved schema, which a cache-hydrated document already is — and that wait resolves as soon as the document materializes, rejecting if it turns out not to exist. Offline-capable flows wait for available before editing, and for settled only where they need server-confirmed truth.

Three kinds of write, three answers:

Write type Long-offline behavior
Yjs (rich text) CRDT deltas merge at any age — no horizon. Offline content editing is the CRDT lane’s home game.
Sequence-guarded JSON Exactly-once at any age: if the server moved, the replay draws a benign rejection and the caller rebases. Right for discrete transactional ops, wrong for long editing sessions.
Unguarded JSON Replays last-writer-wins if never sent; surfaces as “unconfirmed, refetch” if sent and past the horizon. With a persisted queue this is the common case for long offline periods.

That last row is a genuine semantic caveat: a silent last-writer-wins replay of week-old writes is protocol-legal but can surprise the humans involved. For offline work that deserves review before it lands, use a staged session — its changesets live in the host document and sync through the server, so they are already durable, and a reconnect produces a changeset to review instead of silent writes.

Staging and the journal compose with no extra wiring: a changeset is ordinary writes to its host document, so staging journals like any other write. Staged work survives a reload, renders from the cache while offline, and a fresh session over the same host resumes it. Commit awaits the server’s acknowledgment of every stage, so it is inherently an online act — starting one offline leaves it waiting for the reconnect.

Commit is also crash-safe: a page that dies mid-commit leaves journaled writes that replay on the next boot, and a session resumed over the same host recognizes them as its own commit already in flight rather than re-applying the staged work. The commit intent that makes this work is a staged-session mechanism, not an offline one.

Tabs on the same folder and user share the journal and the cache, and all of them stay durable. Each journals its own writes, a dead tab’s unsent writes are adopted by a surviving one, and offline rich-text edits reach sibling tabs through the cache with no server in the loop. Pending JSON writes are visible in siblings but never merge, so a “changes pending” indicator is truthful across tabs — at the cost of two tabs editing the same field rendering their own value until a reconnect settles the order. Offline across tabs is the contract.

Scope it to the principal, and other caveats

Section titled “Scope it to the principal, and other caveats”
  • The namespace must be scoped to both folder and principal. The journal carries document data: key it by folder alone and two accounts on one origin read each other’s pending writes. Clearing the store belongs in logout.
  • Durability is best-effort. Browsers evict IndexedDB under storage pressure; Safari prunes stale origins; private browsing is ephemeral. The guarantee is “survives reload in practice”, never “survives anything” — and an evicted store degrades to exactly the memory-only behavior, never corruption.
  • Only tracked writes are journaled. The client tracks writes to documents it is subscribed to. A fire-and-forget write to a document nothing subscribes to is sent, but never queued, so it does not survive a reload.
  • The queue is capped. At the cap (1000 writes by default), a further write is refused with a distinct queue-full error: an awaited write rejects, a fire-and-forget one reports through the write-error callback. Refusing new work beats silently dropping journaled writes the user already made.
  • Apps can tighten, never loosen. An app-level retention bound evicts stale queue records on boot below the protocol’s horizon; nothing an app configures can make a sent unguarded write replay past it. What the bound evicts reports through discardedWrites like every other discard.
  • The cache outlives authorization, briefly. Cached documents stay readable offline after an access revocation — the client already saw the data — until the next connection replaces them with a not-found. It is the second reason the namespace must be principal-scoped.