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.
Discarded writes are observable
Section titled “Discarded writes are observable”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.
Pending writes are datable
Section titled “Pending writes are datable”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.
Knowing when you can edit
Section titled “Knowing when you can edit”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.
What survives a long offline period
Section titled “What survives a long offline period”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.
Staged sessions offline
Section titled “Staged sessions offline”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.
Several tabs, one store
Section titled “Several tabs, one store”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
discardedWriteslike 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.