Search selfstore
v1.8.21

Designing an interface when the network is optional

Offline-first deletes most of the UI states a server app needs - spinners, retry toasts, optimistic rollback - and adds three smaller obligations in their place.

Most of the states in a web interface are not about the user’s data. They are about the network: the skeleton while the fetch lands, the spinner on the save button, the toast that says “could not reach the server, retry?”, the optimistic row that has to be un-drawn when the request fails. We write these so often that they feel like part of what an application is.

They are not. They are the cost of putting a round-trip between the user and their own data. Take the round-trip out and most of that machinery has nothing left to do. What is interesting is not that it disappears - it is what has to appear in its place, because local-first invents obligations of its own.

The states that stop existing

When the working copy lives on the device, reading it is a local call and writing to it is a local call. Four familiar UI patterns lose their reason to exist:

That last one deserves emphasis. Offline support in a server app is a feature with a budget, a scope and a set of things it does not cover. Offline in a local-first app is not a feature; it is the absence of a dependency.

What replaces the fetch

The pattern that takes over is smaller than the one it replaces: read local state, and re-render when it changes. In selfstore that is one subscription, and the important detail is that the same signal fires for the user’s own writes and for state folded in from another device:

store.onChange(() => render(store.all('todos')));

One render path, one source of truth in the interface. Nothing in the loop distinguishes “my keystroke” from “my phone’s edit an hour ago”, so there is no second code path to keep in sync with the first. The quick start is the whole loop in a handful of lines.

The three things you still owe the user

Deleting the network states does not mean the interface gets to say nothing about persistence. Data that lives on a device the user can lose deserves three honest signals, and they are the design work local-first actually adds.

An indicator of where the data stands

The user should be able to tell, at a glance and without asking, whether their work is only in this browser or also in the durable home they connected. This is a one-line surface, not a dashboard: selfstore exposes a headless descriptor (store.status, carrying a state, a severity, an optional required action and an i18n labelKey) so you map it through your own tokens and your own copy. The frameworks guide wires it into React, Svelte and Vue with the same three lines each.

Silence about the transient

The costliest bug in this genre is the alarming dialog that fires while everything is fine, because a host cold-started or a train went into a tunnel. The rule worth designing around: a failed upload is not the user’s problem until it is proven to be. The edit is safe in the working copy either way, so the interface should retry quietly and stay quiet.

That means being strict about which failures may interrupt. selfstore draws the line in its contract: only a genuine loss of access (AUTH_EXPIRED) sets an actionable status, and only to one of two gestures, unlock or reconnect. Everything else a destination throws is treated as transient and retried with no gate and no dialog. The error reference lists which is which; the practical effect is that “you are offline” never becomes a modal.

The truth about a merge

Any last-writer-wins system drops the losing side of a genuinely concurrent edit. The interface obligation that follows is not to prevent it - you cannot, in the general case - but to never let it happen invisibly. Every converge that changed something is journaled, and same-record conflicts carry both values, so the UI can say “your phone’s version of this note was replaced, here it is” and offer a restore. A conflict the user can see and undo is an inconvenience; the same conflict resolved silently is data loss with better manners. The sync guide documents the journal and the per-collection strategies that decide what conflicts at all.

The one screen local-first adds

There is a question a server app never has to ask, because it answered it for the user by default: where should this data live? Local-first has to ask it, since the durable home is storage the user owns - a file on disk, their Google Drive, a WebDAV server, an S3 bucket.

Two things keep that from becoming a wall in front of your app. It can be deferred: the app is fully usable on the working copy alone, so the prompt waits until there is something worth protecting. And the journey is identical in every app that has it, so selfstore ships it as a themable web component, with the same flow available headlessly when it belongs inside your own components.

Where the honesty line sits

Two limits, stated plainly, because an interface that promises more than the architecture delivers is the actual failure mode.

Convergence between devices is not instantaneous presence. Sync runs on concrete moments - opening the app, focusing the tab, the network coming back, a slow interval, a flush when the tab hides - plus whenever you call it on a gesture. That is right for one person’s devices minutes apart and wrong as a foundation for two cursors in one paragraph. Live collaborative editing wants a CRDT and usually a relay; you can embed one and let it ride along, but do not draw a presence indicator that the sync model cannot honour.

And whole-state sync is proportional to the state. At the megabyte scale of one person’s app data it is invisible; on a metered connection with a multi-gigabyte dataset it is the wrong design, and no amount of UI polish fixes that.

Inside those limits, the trade is very good. You delete a category of interface work that never served the user - the waiting, the retrying, the guessing - and you spend a fraction of it on three signals that do: where my data stands, that you will not nag me about a tunnel, and that nothing was lost quietly. Skipping the backend is the architectural half of that story; this is what it looks like on the screen.


Try selfstore in five minutes, or read the other notes.