loceldocumented
GuidesDigging deeper

Offline and durability

What survives a reload, a crash, a full disk and a schema change — and how to see the difference before your users do.

This guide covers what locel promises about data that has not reached the server. You will learn:

  • What is durable, when, and in what order
  • Why visible and durable are one state, and what to render instead
  • What happens when storage fills, and what locel refuses to do about it
  • How long an outbox can get, and what to do about a very long one
  • What survives each kind of failure, in one table

Status: Built. The API below exists and is under test.

Overview

"Offline support" usually means the app still renders when the network drops. That is the easy half, and it is not the half that loses work.

The half that loses work is the gap between a write being on screen and a write being on disk. In most optimistic stacks that gap exists, it is small, and it is invisible — the mutation is applied to an in-memory store, storage is written behind it, and if the write to storage fails the failure is logged to a console nobody is reading. The user sees a saved row. The reload sees nothing.

locel closes the gap by construction rather than by care: the row you are looking at is read from the replica, so if it renders, it is stored.

One store, one transaction

Everything the client knows lives in one store: confirmed rows, their versions, the checkpoint, the outbox, and any unacknowledged conflicts. A write commits all of the parts it touches together, or none of them.

That is a durability claim, but it is mostly a consistency claim, and the consistency claim is the valuable one. It rules out states rather than making anything faster:

  • Rows cleared but the checkpoint kept — a store that reports itself current and is empty.
  • An outbox entry whose base version refers to a row version that was rolled back.
  • A confirmed row without the checkpoint that would let you resume after it.
  • A write visible on screen that never reached storage.

None of those are conditions locel checks for and repairs. They are shapes the store cannot take.

Rendering the three states

function TodoRow({ todo }: { todo: Todo }) {
  if (todo.$conflict) return <Row todo={todo} note="edited elsewhere" />
  if (todo.$pending) return <Row todo={todo} note="not sent yet" />
  return <Row todo={todo} />
}

There is no $durable column to branch on, and its absence is the guarantee rather than an omission. A visible row was read from the store, so a visible row is a stored row; a column for it could only ever be true.

The case it would have been for — the store refusing a write — is not a property of a row. It is a property of the replica, and it is reported there:

replica.onStoreFailure((failure) => {
  // Nothing was written, so nothing became visible. What is at risk is
  // every write from here on: the replica stops accepting them.
  banner.show('Your changes are not being saved on this device.')
})

A store failure is never swallowed and never logged only. If a write cannot be written it is refused, not shown — and you are told, because a UI that keeps accepting edits into a store that is not saving them is exactly how users lose an afternoon.

When storage fills

Browsers evict origin storage, and users fill disks. Both end at the same place: a write that cannot be persisted.

locel's response is to stop accepting writes and say so. It does not:

  • Drop the oldest outbox entries to make room. Those are the writes furthest from being recoverable by any other means.
  • Fall back to memory-only operation. That is the invisible-gap failure, chosen deliberately, which is worse than an error.
  • Prune confirmed rows to free space. In partition mode they are the offline guarantee; in subset mode they are already bounded.
replica.onStoreFailure(({ reason, bytesHeld }) => {
  if (reason === 'quota') {
    // Recoverable: free space, then resume.
  }
})

await replica.resume()

If your partition is large enough that quota is a routine concern rather than an edge case, that is the signal to move to subset mode, not to add eviction.

How long an outbox can get

There is no cap. A user offline for a week accumulates a week of transactions and they all replay, in order, on reconnect.

Three consequences worth designing for.

Reconnect can be slow. Four hundred queued transactions are four hundred round trips, serially, because ordering is load-bearing. replica.status carries the count throughout:

replica.status
// { state: 'syncing', pending: 143, oldest: Date }

locel ships no React package, so subscribing is four lines you own rather than a hook you import:

export function useSyncStatus() {
  return useSyncExternalStore(replica.onStatusChange, () => replica.status)
}

onStatusChange takes the subscriber and returns the unsubscribe function, which is exactly useSyncExternalStore's contract. Everything else about sync is a live query over the magic columns; status is the one thing that is about the replica rather than about a row, so it is the one thing that is not.

Base versions get old, and that is correct. A write made on Tuesday carries Tuesday's base version on Friday. Conflicts are detected against what the user was looking at, and they were looking at Tuesday. Refreshing it to Friday's version would silently convert every collision into an overwrite.

A long offline session produces a long conflict list. The account is real and you must render it. $conflict on a hundred rows after a week away is information the user needs, and a toast is not the way to deliver it — build a review screen.

Multiple tabs

Every tab on the origin shares one replica through the broker. One outbox, one connection, one store.

So a write in one tab is durable for all of them, and closing the tab that made a write does not orphan it. If the tab that happens to be driving the connection closes mid-flight, another takes over and resumes from the first unanswered transaction — the transaction id makes the resend idempotent.

What survives what

FailureConfirmed rowsOutboxConflicts and rejectionsCheckpoint
Tab reload
Browser crash
Machine power loss
Network offline, any duration
Leader tab closes mid-send
Schema changerebuiltreplayed, reporting what no longer appliesreset with the rows
Scope or user changeseparate storeseparate storeseparate storeseparate store
Storage quota exhausted✓, frozen
Origin storage cleared by the user
replica.forget(scope)

"Separate store" is not a loss. Switching workspace or signing in as somebody else opens a different store and leaves the previous one intact, unsent writes included; see signing out for when you should delete it anyway.

The last two rows are the honest ones. If the user clears site data, unsent work is gone, and no client-side library can promise otherwise. If that is unacceptable for your product, the answer is to shorten the window in which writes are unsent — not to keep a second copy somewhere the user did not agree to.

Testing it

import { createReplica, stores } from '@locel/client'

const replica = createReplica({ schema, store: stores.memory(), transport: null })

transport: null is a replica with no server: writes are visible, durable and permanently pending. It is the right harness for the states that are otherwise hard to reach — a long outbox, a reload mid-flight, a conflict list built from a week away.

For the reload, stores.memory() accepts a handle you can carry across instances:

const handle = stores.memory()
const first = createReplica({ schema, store: handle, transport: null })
first.transaction(() => todosCollection.insert({ title: 'Ship it' }))
await first.detach()

const second = createReplica({ schema, store: handle, transport: null })
// second replays the outbox exactly as a reload would

The transaction boundary this rests on is ADR 0007, which records the three unfixed defects in a comparable library that it makes unrepresentable.

Where to go next

On this page