01 / The prompt
“It has to keep working with no signal.”
Ana inspects railway bridges. At bridge B-14 she is in a cutting with no signal: she notes spalling on pier 2 and drops the condition rating from 3 to 2. Ben, an engineer in the office, has the same inspection open; he notes that the access permit was renewed and, from the last report, sets the rating to 4. Ask an agent for “an inspection app that works offline” and you get an app where Ana can type in the cutting. The question is what happens when her tablet finds signal again.
The prompt never said who wins when two people changed the same thing, whether a change sent twice counts twice, or what the tablet does with a change the server refuses. Local-first work puts those questions on the table on purpose. Its best-known statement puts the device first: “local-first applications store the primary copy of their data in each device’s local filesystem, the user can read and write this data anytime, even while offline. It is then synchronized with other devices sometime later, when a network connection is available” (Kleppmann, Wiggins, van Hardenberg, and McGranaghan, “Local-first software”).
02 / Name the shape
Write here first. Send changes, not copies.
In a local-first app the device holds its own copy, a replica, and every edit lands there first, so the app works with or without a network. Sync is how replicas catch up. It works when what travels is each change, with an id the server remembers, rather than the device’s whole copy.
The device may decide when an edit shows. It does not decide what is committed: two changes to the same field from the same starting point are a conflict with a name, and a person settles it.
| What | Owner | Why |
|---|---|---|
| What Ana sees right now | Ana’s tablet | Her edits show at once, signal or not. |
| Changes not yet sent | The tablet’s outbox | Each has an id; it leaves the outbox only when the server has answered. |
| The committed inspection | Server | It is what Ben, the next inspector, and the report read. |
| Whether a change was already applied | Server | Only it saw every attempt; it remembers ids. |
| Notes from different people | Merged | Two notes do not compete. Both are kept. |
| A rating two people changed | A person | The server marks it for review; neither device silently wins. |
| Whether the inspection can close | Server | Not while anything is under review. |
Words to put in a prompt or a review
- Replica
- A device’s own copy of the data, which it reads and writes without the network.
- Outbox
- Changes made on the device and not yet acknowledged by the server.
- Mutation id
- An id a change gets when it is made, so the server can recognize it if it arrives again.
- Base version
- The version of a field the device saw when it changed it.
- Conflict
- Two changes to the same thing from the same starting point.
- Last write wins
- Whichever copy arrives last replaces the other, whatever it contained.
Further along: copies that merge themselvesCRDTs, and why this lesson keeps a server
The Ink & Switch essay goes further than this lesson: its ideal replicas merge with each other through data types built to merge (CRDTs), without a server deciding. That suits a document someone owns. An inspection has rules that are not a merge: it cannot close while a rating is disputed. So this lesson keeps a server that owns those rules, and puts everything else on the device. The outbox, the ids, and the named conflict are what you need either way.
03 / Follow one day in the field
Watch three copies of one inspection come back together.
Ana’s tablet, the server, and Ben’s laptop, side by side. First the edits, then Ana’s tablet finding signal three different ways: sending its whole copy, sending its changes without ids, and sending them with ids. The first reply is lost every time. Open Try it to run the day with your own steps.
Whose change survives the sync?
Queue changes with ids
Ana’s tablet
Rating 3 · open
- No notes
0 changes waiting to sync
Server
Rating 3 · open
- No notes
Ben’s laptop
Rating 3 · open
- No notes
0 changes waiting to sync
Both copies match the server.
Both copies match the server.
Reduced motion: choose a scene to see its completed state.
Read this scene
Both copies match the server.
Both copies match the server. Server: rating 3, open, notes: none. Ana’s tablet: 0 waiting.
Watch restarts the story when you come back. Step through keeps your step. Try it replays the day from the start each time you change it.
04 / Read the shape
An outbox on the device, a memory on the server.
Basic form is the server: apply each change once, and name a conflict instead of choosing a winner. In the wild is the device’s replica, where an edit lands first and waits. At the call site is the day itself.
Notice what makes a lost reply harmless: the replica keeps its outbox until it hears back, and the server remembers the ids it has already applied. Take away either half and the resend counts twice.
The server that owns the committed record. It applies a change once per id, answers a resend from memory, marks a rating two people changed from the same starting point for review, and refuses to close while anything is under review.
// The server owns the committed record. It applies each change once, however
// many times it arrives, and it will not let one person's rating silently
// replace another's.
export class InspectionServer {
record = start();
#seen = new Map<string, Result>();
apply(m: Mutation): Result {
const earlier = this.#seen.get(m.id);
if (earlier) return { ...earlier, replayed: true };
const result = this.#decide(m);
this.#seen.set(m.id, result);
return result;
}
#decide(m: Mutation): Result {
const r = this.record;
if (r.status === 'closed')
return { id: m.id, status: 'rejected', reason: 'the inspection is closed' };
if (m.op === 'add-note') {
r.notes.push({ id: m.id, by: m.by, text: m.text });
return { id: m.id, status: 'applied' };
}
if (m.op === 'set-rating') {
if (m.base !== r.ratingVersion) {
if (!r.review.includes('rating')) r.review.push('rating');
return { id: m.id, status: 'conflict', reason: `rating is already ${r.rating}` };
}
r.rating = m.value;
r.ratingVersion++;
r.review = r.review.filter((f) => f !== 'rating');
return { id: m.id, status: 'applied' };
}
if (r.review.length)
return { id: m.id, status: 'rejected', reason: `resolve ${r.review.join(', ')} first` };
r.status = 'closed';
return { id: m.id, status: 'applied' };
}
/** The whole-record strategy's door: whatever arrives replaces what is there. */
replace(record: Inspection) {
this.record = clone(record);
}
snapshot(): Inspection {
return clone(this.record);
}
} // InspectionServer owns the committed record. It applies each change once,
// however many times it arrives, and it will not let one person's rating
// silently replace another's.
type InspectionServer struct {
Record Inspection
seen map[string]Result
}
func NewServer() *InspectionServer {
return &InspectionServer{Record: Start(), seen: map[string]Result{}}
}
func (s *InspectionServer) Apply(m Mutation) Result {
if earlier, ok := s.seen[m.ID]; ok {
earlier.Replayed = true
return earlier
}
result := s.decide(m)
s.seen[m.ID] = result
return result
}
func (s *InspectionServer) decide(m Mutation) Result {
r := &s.Record
if r.Status == "closed" {
return Result{ID: m.ID, Status: "rejected", Reason: "the inspection is closed"}
}
switch m.Op {
case "add-note":
r.Notes = append(r.Notes, Note{m.ID, m.By, m.Text})
return Result{ID: m.ID, Status: "applied"}
case "set-rating":
if m.Base != r.RatingVersion {
if !slices.Contains(r.Review, "rating") {
r.Review = append(r.Review, "rating")
}
return Result{ID: m.ID, Status: "conflict", Reason: fmt.Sprintf("rating is already %d", r.Rating)}
}
r.Rating = m.Value
r.RatingVersion++
r.Review = slices.DeleteFunc(r.Review, func(f string) bool { return f == "rating" })
return Result{ID: m.ID, Status: "applied"}
}
if len(r.Review) > 0 {
return Result{ID: m.ID, Status: "rejected", Reason: "resolve " + strings.Join(r.Review, ", ") + " first"}
}
r.Status = "closed"
return Result{ID: m.ID, Status: "applied"}
}
// Replace is the whole-record strategy's door: whatever arrives replaces what is there.
func (s *InspectionServer) Replace(record Inspection) { s.Record = record.Clone() }
func (s *InspectionServer) Snapshot() Inspection { return s.Record.Clone() } The behavior these examples promiseChecked by 15 shared scenarios
- An edit is on the device at once and joins its outbox with an id such as
ana-1. - The server applies a change once per id; a resend gets the first result back, marked replayed.
- A rating set from the current version is applied. From an older version it is a conflict: the server keeps its value and marks the rating for review.
- Close is refused while anything is under review, and nothing changes after close.
- A lost reply leaves the device as it was. Otherwise the device takes the server’s copy and a notice for every conflict or refusal.
Every expectation in the shared cases was produced by a separate model written from these rules and kept beside the examples, not copied from either implementation.
Reading the TypeScriptA union of changes, and a private memory
Change is a union on op, so each branch of the server knows
which fields it has. The server’s memory of ids is a private #seen map, so
nothing outside can make it forget. clone hands out copies, so a device holding
the server’s record cannot edit the server by accident.
Reading the GoEmbedding, and values that copy
Mutation embeds Change, so m.Op and m.Text read directly. Inspection is a value, but its slices
share backing arrays, so Clone copies the notes and the review list before the
record leaves the server.
Run it yourselfNo dependencies
Copy the complete TypeScript file and run node --experimental-strip-types inspection.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:
module heyrian.dev/lessons/local-first-sync
go 1.23
whole-record: rating 2, closed, notes [Spalling on], 0 notices for Ana queue-without-ids: rating 2, closed, notes [Access permit, Spalling on, Spalling on], 2 notices for Ana queue-with-ids: rating 2, closed, notes [Access permit, Spalling on], 2 notices for Ana ana: Rating 2 not applied: rating is already 4 ana: Close rolled back: resolve rating first
05 / Review the agent’s diff
“One request instead of one per change.”
A weak signal makes every round trip expensive, so fewer requests is a real improvement. Read what the one request carries before you decide.
06 / How it fails
Offline is easy. Coming back is where it fails.
Every row is one of the shared scenarios unless it is marked as authored.
| What goes wrong | Whole record | Changes without ids | Changes with ids |
|---|---|---|---|
| Unreachable, then back: a note, first reply lost | One note | The note twice | One note; the resend is a replay |
| Conflicting: both add a note | Ben’s note lost | Both kept | Both kept |
| Conflicting: both change the rating | Ana’s silently replaces Ben’s | Marked for review; Ana told | Marked for review; Ana told |
| Wrong: closing while the rating is disputed | Closed | Refused; the tablet rolls back | Refused; the tablet rolls back |
| Stale: Ben adds a note after Ana closed | Ben’s copy reopens the inspection | Refused; Ben told | Refused; Ben told |
| Slow: the device is offline for days (authored) | The outbox grows. The page must say how many changes are waiting, and keep them through a reload. | ||
The ids are the idempotency key from Idempotency and at-least-once, and the base version is the check in Optimistic concurrency.
07 / Is it worth it?
An outbox and a memory cost code. Here is what they buy.
Sending the whole record is a few lines and needs nothing on the server. Hold it up against the changes this app will get.
| Change | Whole record | Changes with ids |
|---|---|---|
| A second client: a supervisor’s phone | Three copies can now overwrite each other | A third outbox; the server’s rules do not change |
| Replace a dependency: IndexedDB instead of localStorage | A device change | A device change. No difference. |
| Change a rule: a rating of 1 needs a note | Every device must enforce it, and old ones will not | One server rule; a refused change comes back as a notice |
| A second team owns reporting | Reports see whichever copy arrived last | Reports see every change, with who made it |
Before you build or change sync, decide what you will measure and the result you would accept:
- Edits lost or applied twice after a lost reply or a reload offline. Zero is the only acceptable number, so it belongs in a test.
- Conflicts per week, by field, and how long each waits for a person.
- Outbox size and age on devices, so a tablet that has not synced for days is visible.
- Time to sync after signal returns.
This page did not run the app with real inspectors, so it gives no production numbers.
08 / Ask for it
Two prompts, two tablets, one lost reply.
We sent two agents the same request at the same time, both running Claude Sonnet. One prompt described the app and its users. The other added an Architecture block: the tablet writes to its own copy first, a queue that survives a reload, an id on every change, notes kept from everyone, a rating two people changed from the same starting value marked for review, no closing while anything is under review, and a count of changes waiting. A script then drove two browsers, took Ana’s offline, and lost her first sync reply on purpose.
| Question | Plain prompt | Architecture prompt |
|---|---|---|
| The field day: notes on the server | 2, once each | 2, once each |
| The field day: the rating | 4 | 4, under review |
| What Ana was told | Your change was overridden by a newer edit from ben. | Your rating change to 2 was not applied: Rating was already changed to 4 by someone else. Your change to 2 was not applied and has been flagged for review. |
| Ana tries to close | Status closed | Refused, button disabled: Blocked: unresolved review items. |
| Ana’s tablet clock runs 10 minutes fast | Rating 2 | Rating 4, under review |
| Ben sets 4, then 3 again; Ana set 2 offline | Rating 3; Ana told: Your change was overridden by a newer edit from ben. | Rating 2; Ana told: Nothing |
| A reload with no signal | An offline notice (HTTP 503) instead of the inspection; the note syncs later | The page does not load; the note syncs later |
The plain prompt produced most of this lesson without being asked: a queue in storage, an id on every change, a server that ignores an id it has seen, and an offline notice of its own when a reload has no signal (an HTTP 503, not the inspection, so the note is not on screen). No note was lost or doubled. What it chose on its own was who wins a disputed rating, and it chose the tablet’s clock: the change with the later timestamp wins. With Ana’s clock ten minutes fast, her offline rating of 2 replaced Ben’s 4, and nothing on either screen said so.
const last = insp.lastRatingChange;
if (last && op.ts <= last.ts) {
result = {
opId: op.opId,
applied: false,
reason: "superseded",
by: last.author,
ts: last.ts,
}; if (insp.rating === baseRating) {
// Nobody else has moved the rating since this client last saw it.
insp.rating = value;
result = { changeId: change.id, status: 'applied' };
} else {
// Someone else's rating change got here first. Keep theirs, flag
// this one for review, and tell the sender their change did not
// land. The architecture build did what its block asked, and the checker saw it: Ana’s rating was
marked for review, she was told, and the close button stayed disabled. But the block said
“from the same starting value,” and the agent compared values. When Ben set 4 and then 3
again, the rating was 3 when Ana’s change arrived, her starting value matched, and her 2
went in as if nothing had happened in between. A counter that goes up on every change, like
the lesson’s ratingVersion, would have caught it. The missing line: recognize the starting point by a version number that only goes up, never by the value or
the device’s clock.
How the runs were made and checkedOne run each, recorded as written
- Both agents received the prompts word for word, in fresh contexts, in the same message. Neither was told about the other, this lesson, or the checker.
- The files each agent wrote are kept byte for byte, with checksums, beside this lesson’s examples. For every question the checker restores a build, starts it fresh, and drives two browser contexts through the build’s own buttons.
- A lost reply is made by letting the request reach the server and then failing it on the page’s side, so the server acts and the tablet hears nothing.
- The checker’s first run read Ana’s page only at the end and missed the plain build’s message, which disappears after a few seconds; it also timed out clicking a close button the architecture build had disabled. It now records every line the page shows, and reports a disabled button. Both runs are kept.
- Each agent wrote a server log to the system’s temporary folder, against the prompt. Both stopped their test servers by process id. Neither touched the other’s folder.
- This is one sample of each prompt, not a measurement of a model.
09 / Hold it there
Test the reconnect, not the happy path.
Online, all three strategies pass every test. The next change that sends a copy instead of changes, or makes a new id on retry, will pass them too. Three checks keep the shape.
The platform’s own behavior
Browsers tell you less than you think. TanStack Query’s default network mode holds work while offline: “In this mode, Queries and Mutations will not fire unless you have network connection” (Network Mode, the current docs, checked 23 September 2026). A paused mutation lives in memory, so it does not survive closing the tab, and it carries no id the server can recognize unless you give it one. An outbox that must survive a reload belongs in storage, with the id made when the change is.
A test that loses a reply
Make the server act and the device hear nothing, then sync again, and assert one note. The lesson’s own specs do this for every strategy, and the same test run against the whole-record strategy is the one that fails. Run tests like it in CI, the way Enforcement layer runs its rules.
A check on what two people actually see
Unit tests have one device. The checker in section 08 drives two browsers, takes one offline, and cuts a reply after the server has acted, then reads what each person sees.
check-runs.mjs /** Make the next sync request reach the server, then tell the page it failed. */ async function loseNextReply(run, page) { let lost = false; await page.route('**/*', async (route) => { const request = route.request(); if (!lost && request.method() === 'POST' && builds[run].syncRequest(request.url())) { lost = true; await route.fetch(); return route.abort('failed'); } return route.continue(); }); return () => lost; }
Your paused mutation is already an outboxOptimistic updates that wait for the network are the first half of local-first. The ids are the second.
Where it already is in your components
An optimistic useMutation that updates the cache before the server answers,
and waits while the device is offline, is an outbox of one, held in memory. So is a draft
saved to localStorage as you type. The device already shows what it has not yet sent.
When you have to own it
The day someone must work for an hour without signal, reload, and not lose anything, and the day two people edit the same record. Then the queue moves to storage, every change gets an id when it is made, a lost reply keeps the change, and the server’s answer can be “not applied”. The samples show the in-memory version as it is usually written, then an outbox that survives a reload and shows what is waiting.
An optimistic note with TanStack Query. It shows at once and waits while offline, in memory: close the tab and it is gone.
// AddNote.tsx. An optimistic note with TanStack Query: it shows at once, and
// with no network the mutation waits (paused) and continues when the network
// returns. The wait lives in memory: close the tab offline and the note is gone.
import { useMutation, useQueryClient } from '@tanstack/react-query';
type Note = { id: string; text: string };
export function AddNote({ bridge }: { bridge: string }) {
const client = useQueryClient();
const add = useMutation({
mutationFn: (note: Note) =>
fetch(`/inspections/${bridge}/notes`, { method: 'POST', body: JSON.stringify(note) }),
onMutate: (note) =>
client.setQueryData<Note[]>(['notes', bridge], (notes = []) => [...notes, note]),
onSettled: () => client.invalidateQueries({ queryKey: ['notes', bridge] })
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
const text = new FormData(event.currentTarget).get('text') as string;
add.mutate({ id: crypto.randomUUID(), text });
}}
>
<input name="text" aria-label="Note" />
<button>Add note</button>
{add.isPaused && <p role="status">Saved here. It will send when you have signal.</p>}
</form>
);
}
10 / Make the call
Queue changes when people work apart. Keep it online when they cannot.
Keep the app online-only, with optimistic updates at most, when losing signal is rare and brief, or when nobody can act on a record without the server anyway, such as buying the last ticket. Then a clear “you are offline” is honest, and there is nothing to reconcile.
Build it local-first, with an outbox and ids, when people must keep working without signal, when two of them edit the same records, or when a lost edit is a lost finding. Decide field by field how conflicts resolve: merge what can merge, like notes, and send the rest to a person. Reconsider when a third kind of device appears, when conflicts pile up faster than people settle them, or when devices stay offline long enough for the outbox to become the record.
Take it with you
Explain it without saying “local-first”: “The tablet saves everything to itself first and keeps a list of what it has not sent. Each item has a number, so the server never counts one twice, and when two people changed the same thing, the server asks a person instead of guessing.” Then find an edit in your app that would be lost if the reply never came back.
Paste into your next prompt, and fill in the blanks
The <device> keeps its own copy of <the record> and writes to it first. Changes wait in an outbox that survives a reload, and the page shows how many. Every change gets an id when it is made. The server applies each id once; a change sent again after a lost reply is not applied twice. <Fields that merge, such as notes> merge. If two people change <field> from the same starting value, the server keeps the first, marks it for review, and tells the other. <Rule, such as close> is refused while anything is under review. Sync sends changes, never the whole record.
Connections to follow nextRelated lessons
- Client–server architecture is where the server’s rules came from: the device asks, the server decides.
- Polling, server-sent events, or WebSockets is how Ben’s laptop would hear about Ana’s changes without syncing by hand.
- Optimistic concurrency is the base version check on the rating.
- Idempotency and at-least-once is the id on every change.
- Offline inspections is the practice project that asks you to build this: one revision per inspection, with every concurrent edit sent to review, a coarser conflict rule than the field-level one here, and its brief says why.
- Server state in the client is the read side: a cached copy with a freshness rule.