Local-First Persistence
Client writes commit to the local store and return; a background drainer delivers them to the server.
Install in BlintOpens the Blint desktop app and adds this pattern to your project. Don't have Blint?
The local store is the source of truth for a user's own data. A save completes when it is committed locally; delivery to the server is a separate, later, best-effort concern. This keeps the interface responsive regardless of network latency, cold starts, or outages — and it is why the client, not the server, owns the identity of the records it creates.
Rules
The client mints the identity. Generate the record's id at creation, on the device. The record must be addressable — storable, editable, referenced by its children — before the server has ever seen it. The server honours that id on upsert rather than assigning its own; see the Client ID Upsert pattern for the server half.
A save path never awaits the network. Between the user's gesture and the committed local write there must be no network call. The write is synchronous from the caller's point of view and the UI updates from the local store. Any
asyncin that path should be incidental, not a round trip.Mark the record, don't send it. A save flags the record as needing delivery. A second flag records whether the record has ever reached the server, which is what selects create-vs-update at delivery time. Nothing else about the save should change because the network happens to be available.
Every dirty flag has exactly one drainer. A flag that no uploader reads is silent data loss: the interface reports success, the data never leaves the device, and the defect only surfaces on reinstall. This is the pattern's most common failure — adding the flag feels like adopting the pattern. When you add the flag, register the drainer in the same change.
Clear the flag only on confirmed success. Clear on an acknowledged response, never optimistically at send time — otherwise a failed delivery is indistinguishable from a successful one and the write is lost.
Failed deliveries stay dirty and back off. Retry on a widening schedule with jitter. The queue query must honour the stored next-retry time; a backoff that is computed but not read by the queue is not a backoff, it is a tight retry loop wearing a disguise.
Round-trip or it isn't persisted. Anything the client uploads must also be ingested when the server sends it back. Upload-only data survives nothing — not a reinstall, not a second device — which is precisely the durability the upload was supposed to buy. Adding a field to the upload payload is half a change; ingesting it is the other half.
Delivery stays off the UI thread. Serialising the payload and transmitting it must not run on the UI actor. A background uploader that blocks the main thread reintroduces exactly the stall the pattern exists to remove — and it does so on a timer, so it presents as periodic jank with no obvious cause.
Never log whole payloads in the delivery path. Stringifying a request or response body and writing it to the console is frequently more expensive than the request itself, and it runs on every flush. Log identifiers, sizes and outcomes.
Device-local records carry no sync fields. If a record is local by design, it must not have a dirty flag. An undrained flag on local-only data is indistinguishable, on inspection, from rule 4's data-loss bug.
Consequences
- The uploader is a queue drainer, not a request builder tied to a user action. There should be no "save now" affordance in the interface; the user's action flags, and the drainer delivers.
- Delivery failures are invisible to the user by design. That makes an observable signal — a sync-state indicator, or at minimum a log that is actually read — a requirement rather than a nicety.
- Because the client owns the id, a create and a subsequent update address the same row. Server-side upsert logic must never reassign the primary key.