Skip to content
Chapter 61Lesson 3

Version columns and the honest 409

Optimistic concurrency in Drizzle and React, where a version column in the UPDATE's WHERE turns two users clobbering each other's edits into a recoverable 409.

Picture two teammates editing the same invoice. Alice and Bob both click “Edit,” and both forms load with amount: 100. Alice bumps the amount to 150 and saves; the database now holds 150. A minute later Bob, whose form still shows the old 100, fixes a typo in the customer note and saves. His save writes the whole form back: the corrected note, plus the stale amount: 100. Alice’s 150 is gone. She finds out three days later, when the invoice is short fifty dollars and nobody can explain why.

This bug never shows up in a demo, where only one tab is open. It surfaces the first week real users edit the same records, and it resists diagnosis: no exception, no stack trace, just a value that quietly reverted. A single extra WHERE condition can detect the clobber, and the right React surface can turn that detection into a moment where the user sees what changed and decides what to do.

You already have most of the machinery. Earlier in this chapter, your lifecycle actions put predicates in the UPDATE’s WHERE: the tenant scope rides in ctx.db, and the lifecycle filters keep you off deleted rows. This lesson adds one more predicate, plus the React surface that makes its failure honest.

The danger is timing: two writes interleave in a way neither tab can see. Each tab does something reasonable on its own; the damage exists only in the gap between them.

%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
  participant A as Tab A
  participant B as Tab B
  participant DB
  Note over DB: amount = 100
  rect rgba(129, 140, 248, 0.18)
  A->>DB: read invoice
  DB-->>A: amount = 100
  B->>DB: read invoice
  DB-->>B: amount = 100
  end
Both tabs open the invoice and read amount = 100. Each form now holds a value that was true at read time.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
  participant A as Tab A
  participant B as Tab B
  participant DB
  Note over DB: amount = 100
  A->>DB: read invoice
  DB-->>A: amount = 100
  B->>DB: read invoice
  DB-->>B: amount = 100
  rect rgba(52, 211, 153, 0.20)
  A->>DB: UPDATE amount = 150
  DB-->>A: ok
  end
  Note over DB: amount = 150
Tab A saves 150. The database now holds 150, and from Tab A's point of view everything is correct.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
  participant A as Tab A
  participant B as Tab B
  participant DB
  Note over DB: amount = 100
  A->>DB: read invoice
  DB-->>A: amount = 100
  B->>DB: read invoice
  DB-->>B: amount = 100
  A->>DB: UPDATE amount = 150
  DB-->>A: ok
  Note over DB: amount = 150
  rect rgba(248, 113, 113, 0.22)
  B->>DB: UPDATE amount = 100
  DB-->>B: ok
  end
  Note over DB: amount = 100 (A's edit gone)
Tab B saves. Its form still holds the stale 100 from step 1, so its UPDATE writes amount = 100 over Tab A's 150. The database is back to 100.
%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
  participant A as Tab A
  participant B as Tab B
  participant DB
  Note over DB: amount = 100
  A->>DB: read invoice
  DB-->>A: amount = 100
  B->>DB: read invoice
  DB-->>B: amount = 100
  A->>DB: UPDATE amount = 150
  DB-->>A: ok
  Note over DB: amount = 150
  B->>DB: UPDATE amount = 100
  DB-->>B: ok
  Note over DB: amount = 100 (A's edit gone)
  rect rgba(248, 113, 113, 0.22)
  Note over A,DB: silent data loss
  end
Tab A's edit is gone. No error, nothing logged, both writes 'succeeded'. This is last-write-wins, the default behavior of every naive UPDATE.

That default has a name: last-write-wins . The database did exactly what each UPDATE ... WHERE id = ? told it. The problem is that Bob’s UPDATE had no way to know his form was built from a value that had since moved on.

Both saves returned ok; the loss lives in the gap between Bob’s read and his write, which is exactly where a second writer slips in. So we need three things, and the rest of this lesson takes them in order:

  1. A precondition that detects when the second writer is working from a stale read.
  2. A response that lets that writer recover by seeing the current value and deciding what to do, instead of losing their work or someone else’s.
  3. The judgment to know when this is overkill, because adding it to every write is its own kind of mistake.

Optimistic concurrency and the version column

Section titled “Optimistic concurrency and the version column”

Two strategies stop two writers from clobbering each other, and choosing between them is the real decision here.

The first is pessimistic locking. When Bob reads the row to edit it, the database locks that row (SELECT ... FOR UPDATE) and holds the lock until he saves; Alice has to wait. The trouble is what the lock spans: human think-time. Bob opens the form, gets coffee, takes a call, and the row stays locked the whole time, every other editor blocked behind him. If the request that should release the lock dies, the lock can linger. That makes it wrong for web traffic, where “a user slowly typing into a form” is the normal case.

The second is optimistic concurrency . There is no lock. Bob reads the row and its version and takes as long as he likes. His UPDATE then says, in effect, “write this, but only if the version is still what I read.” If someone wrote in the meantime the version moved, the condition fails, and Bob’s write is rejected so he can deal with it. You’re betting collisions are rare, and in ordinary editing they are: two people rarely edit the same invoice within the same minute. You pay nothing on the common path and a small price only on the rare miss.

The mechanism is a single integer that counts how many times the row has been written. You add one column:

export const invoices = pgTable('invoices', {
id: uuid().primaryKey().$defaultFn(() => uuidv7()),
orgId: uuid().notNull(),
amount: numeric({ precision: 12, scale: 2 }).notNull(),
version: integer().notNull().default(1),
...lifecycleColumns,
});

The protocol around that column is four steps:

  1. The client reads the row and its version, say version: 7.
  2. The client holds that 7 and sends it back when the user saves.
  3. The UPDATE does two things in one atomic statement: it checks WHERE version = 7, and in its SET it does version = version + 1.
  4. You read how many rows the UPDATE touched. One row means the write landed: nobody raced you, and the version was still 7. Zero rows means the version moved: someone wrote between your read and your write, bumping it past 7, so your WHERE matched nothing. Zero rows is the conflict.

The rows-affected count is the signal, so the UPDATE answers “did this conflict?” by itself, with no separate query.

Use an integer for the version, not a UUID or timestamp: it’s small, ordered, and version + 1 is an atomic increment the database does in place. One tempting alternative is to reuse the updatedAt timestamp you already have instead of adding a column. That’s a different question, taken up next.

Reusing updatedAt when you can’t add a column

Section titled “Reusing updatedAt when you can’t add a column”

You already added updatedAt in this chapter’s first lesson, and Drizzle’s $onUpdate stamps it on every write, so you already have a value that changes on every UPDATE. Why add a second one? Couldn’t the precondition just be WHERE updatedAt = :clientUpdatedAt?

It can, and sometimes it should. The two approaches trade off differently, worth understanding so you choose deliberately.

.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)

The course default for structured editing. A dedicated counter: one extra column, but immune to timestamp-precision problems and unambiguous, since version = 7 means exactly one thing. Reach for this on any multi-field edit form.

Prefer version for structured editing: multi-field forms, drafts, anything a user spends real time in. It’s explicit and can’t be defeated by timestamp precision. Reach for updatedAt only when you genuinely can’t add a column, such as a frozen schema or a legacy table you don’t own, and only when its precision is high enough to trust. The rest of this lesson uses version.

The action’s UPDATE already filters by tenancy and lifecycle from this chapter’s earlier lessons. Add a third predicate and a conflict branch and it becomes one statement with four distinct parts.

export const updateInvoice = authedAction(
'member',
updateInvoiceSchema,
async (input, ctx) => {
const updated = await ctx.db
.update(invoices)
.set({
amount: input.amount,
version: sql`${invoices.version} + 1`,
})
.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)
.returning();
if (updated.length === 0) {
return conflict(await currentInvoice(ctx, input.id));
}
revalidatePath('/invoices');
return ok(updated[0]);
},
);

The same five-seam shape as every action in this chapter. authedAction has parsed the input and checked the role, and ctx.db is already tenant-scoped. We’re at the mutate seam.

export const updateInvoice = authedAction(
'member',
updateInvoiceSchema,
async (input, ctx) => {
const updated = await ctx.db
.update(invoices)
.set({
amount: input.amount,
version: sql`${invoices.version} + 1`,
})
.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)
.returning();
if (updated.length === 0) {
return conflict(await currentInvoice(ctx, input.id));
}
revalidatePath('/invoices');
return ok(updated[0]);
},
);

The SET bumps the version by one, arming the next writer’s check. The check in the WHERE and this increment are a pair: forget the bump and every save succeeds, the counter never moves, and no conflict is ever detected. (updatedAt isn’t set here; $onUpdate from Soft delete & archive stamps it on every UPDATE.)

export const updateInvoice = authedAction(
'member',
updateInvoiceSchema,
async (input, ctx) => {
const updated = await ctx.db
.update(invoices)
.set({
amount: input.amount,
version: sql`${invoices.version} + 1`,
})
.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)
.returning();
if (updated.length === 0) {
return conflict(await currentInvoice(ctx, input.id));
}
revalidatePath('/invoices');
return ok(updated[0]);
},
);

The WHERE carries every precondition: the row id, the lifecycle filter (isNull(deletedAt), so you don’t edit a deleted row), and the version check. Tenancy rides in ctx.db. Miss one and you write the wrong row, or the wrong version.

export const updateInvoice = authedAction(
'member',
updateInvoiceSchema,
async (input, ctx) => {
const updated = await ctx.db
.update(invoices)
.set({
amount: input.amount,
version: sql`${invoices.version} + 1`,
})
.where(
and(
eq(invoices.id, input.id),
isNull(invoices.deletedAt),
eq(invoices.version, input.version),
),
)
.returning();
if (updated.length === 0) {
return conflict(await currentInvoice(ctx, input.id));
}
revalidatePath('/invoices');
return ok(updated[0]);
},
);

.returning() hands back the rows the UPDATE actually touched. Zero rows is not a no-op success: the version moved between read and write, so we return conflict(...) with the fresh row. One row means we won the race, so we return it as the new state.

1 / 1

That zero-rows branch is the point. Treat it as a quiet success and you are back to silent data loss: the write never landed, but you told the user it did. Zero rows is a 409.

The sql`${invoices.version} + 1` fragment is the same sanctioned sql carve-out you saw with the partial-index predicate. Doing the + 1 in SQL keeps the increment atomic: the database reads and writes the counter in one step, with no chance for a second writer to slip in. And .returning() is what makes the zero-rows check possible:

const updated = await ctx.db.update(invoices).set({ /* … */ }).where(/* … */).returning();
// updated.length === 0 → the version moved; this is a conflict, not a success

Don’t enforce the increment with a database trigger. It works, but it hides the most important line of the action behind invisible database behavior. Keep version = version + 1 in the SET at the call site, where the next reader can see this write taking part in the optimistic-concurrency protocol.

Detecting the conflict is half the job; the other half is what you hand back. A recoverable conflict is worth far more than a bare error.

Your action already returns the canonical Result: { ok: true, data } or { ok: false, error }, where error carries a code and a userMessage. The conflict case reuses code: 'conflict', the existing discriminant the form branches on, and adds one field: current, the fresh server row. When you reject Bob’s write, you also hand him the row as it stands after Alice’s edit, so his UI shows what changed without a second round-trip.

Ship current in the rejection rather than let the client re-fetch. You already read a fresh row to build the error, so send it along; a re-fetch is a request you didn’t need, and the value could change again before it lands.

The err(code, userMessage, fieldErrors?) helper from the Result lesson has no current; its third argument is fieldErrors, for form-field messages, which is a different thing. So a small dedicated helper extends the shape:

const conflict = <T>(current: T) =>
({
...err('conflict', 'This invoice was changed in another tab. Refresh to see the latest version.'),
current,
});

It spreads the standard failure Result and adds current as a sibling, so the call site stays readable, return conflict(currentRow), and the divergence from the base helper lives in one place.

One meaning, two transports. Everything above is the in-app path: a Server Action returns the Result directly to your React form, and no HTTP status reaches the client. The same conflict can arrive through another door. When an external integrator or mobile client hits a route handler wrapping this logic, you return 409 Conflict with an RFC 9457 Problem Details body instead. Same meaning, “your write lost the race,” in whichever vocabulary the caller speaks. Building that route handler is a separate concern.

A conflict Result only helps if the form turns it into a choice the user can see and act on, instead of silently losing their work.

You already know the two hooks. useActionState wires a form to a Server Action and returns [state, formAction, isPending], where state is the latest Result the action returned; useOptimistic shows a provisional value before the server confirms. The inputs stay uncontrolled with defaultValue. What’s new: version rides through as a hidden input, so it travels in the form’s FormData without being rendered.

The form branches on three outcomes of state:

  • { ok: true }: the write landed. Replace the local view with the returned row, show success, and update the hidden version to the value the server returned, or the next save conflicts against itself.
  • { ok: false, error: { code: 'conflict' } }: the write lost the race. Render the conflict banner with the server’s current values, and offer the two choices below.
  • { ok: false } with any other code: the ordinary error path you already handle.

The two recovery choices are a real product decision:

  • “Use latest and edit again” replaces the form’s values and hidden version with the server’s current, so the user re-applies their change on fresh data. This is the safe default, and the one you make obvious.
  • “Overwrite anyway” re-fires the action with a force flag that bypasses the precondition, stamping the user’s values over whatever’s there. It deliberately discards someone else’s write, so gate it behind a role or hide it; never present it as a casual convenience.

How useOptimistic behaves on a returned conflict

Section titled “How useOptimistic behaves on a returned conflict”

The form above wires only useActionState. Layer useOptimistic on the same flow and you may expect the phrase “useOptimistic rolls back on error” to apply. It doesn’t, not the way this course writes actions.

The optimistic value is visible only while the transition is pending. When the action resolves, the transition ends and React re-renders against the real state.

A conflict returns { ok: false }; it doesn’t throw. So two things happen. You only advance the real state on ok: true, so the success update never runs and the real state is unchanged. And the transition ends normally, so the optimistic value expires and the UI falls back to that unchanged value. There is no separate rollback step.

Automatic rollback fires only when the action throws. Because this course returns Results, the revert you see is the optimistic value expiring over a state that never moved, not React catching an exception. Same outcome on screen, different mental model, and the wrong one sends you hunting for a rollback that isn’t happening.

Once the optimism expires, the form renders the conflict banner against the user’s still-present uncontrolled input plus the server’s current. The user never lost what they typed; they learned the row moved underneath them, and now they choose.

This is the optimistic-mutation state machine from earlier, idle → submitting → {success | conflict | error}, with one new arrival: conflict is the named state this lesson adds.

idle
submit
submitting
success
conflict
error

conflict is the state this lesson adds: the second tab’s write was rejected, and the user sees the current value to decide.

Now the form. Step through the hidden version input, the useActionState hookup, and the three-way branch on state.

export function EditInvoiceForm({ invoice }: { invoice: Invoice }) {
const [state, formAction] = useActionState(updateInvoice, null);
const row = state?.ok ? state.data : invoice;
return (
<form action={formAction}>
<input type="hidden" name="id" defaultValue={row.id} />
<input type="hidden" name="version" defaultValue={row.version} />
<input name="amount" defaultValue={row.amount} />
{state?.ok === false && state.error.code === 'conflict' && (
<ConflictBanner current={state.current} />
)}
<SubmitButton>Save</SubmitButton>
</form>
);
}

useActionState wires the form to the action and returns the latest Result as state. null is the initial state, before any submit.

export function EditInvoiceForm({ invoice }: { invoice: Invoice }) {
const [state, formAction] = useActionState(updateInvoice, null);
const row = state?.ok ? state.data : invoice;
return (
<form action={formAction}>
<input type="hidden" name="id" defaultValue={row.id} />
<input type="hidden" name="version" defaultValue={row.version} />
<input name="amount" defaultValue={row.amount} />
{state?.ok === false && state.error.code === 'conflict' && (
<ConflictBanner current={state.current} />
)}
<SubmitButton>Save</SubmitButton>
</form>
);
}

The version travels as a hidden input in FormData, never rendered; defaultValue keeps it uncontrolled. This carries the client’s read-time version to the action’s precondition. On a successful save, row becomes the returned row, so this picks up the new version automatically.

export function EditInvoiceForm({ invoice }: { invoice: Invoice }) {
const [state, formAction] = useActionState(updateInvoice, null);
const row = state?.ok ? state.data : invoice;
return (
<form action={formAction}>
<input type="hidden" name="id" defaultValue={row.id} />
<input type="hidden" name="version" defaultValue={row.version} />
<input name="amount" defaultValue={row.amount} />
{state?.ok === false && state.error.code === 'conflict' && (
<ConflictBanner current={state.current} />
)}
<SubmitButton>Save</SubmitButton>
</form>
);
}

The conflict branch: on ok: false with code: 'conflict', render the banner against the server’s current, the sibling field the conflict helper added. Every other outcome falls through to paths you already handle. This one branch is the entire new surface.

1 / 1

This three-way branch is the only genuinely new code in the lesson; everything else was a one-line schema change or two extra WHERE clauses.

The starter form’s action is a stub that returns a mocked Result, so you can simulate both outcomes. Wire the branch so a conflict result renders the banner with the server’s current value, and a success result updates the displayed value.

The save action returns a Result, and the two buttons below let you drive it to either outcome (no server needed). Wire the two branches in the marked spot: (1) when state is a conflict — state?.ok === false && state.error.code === 'conflict' — render a banner with role='alert' showing the server's current amount, state.current.amount; (2) when state is a success — state?.ok — show the saved amount, state.data.amount, in the element with data-testid='saved'. The hidden version input is already wired; leave it.

Preview
    Reference solution
    import { useState } from 'react';
    const conflictResult = {
    ok: false,
    error: {
    code: 'conflict',
    userMessage: 'This invoice was changed in another tab. Refresh to see the latest version.',
    },
    current: { amount: 150 },
    };
    const successResult = (amount) => ({ ok: true, data: { amount } });
    export function App() {
    const [state, setState] = useState(null);
    const [amount, setAmount] = useState(100);
    return (
    <form className="space-y-3" onSubmit={(e) => e.preventDefault()}>
    <input type="hidden" name="version" defaultValue={7} />
    <label className="block">
    Amount
    <input
    name="amount"
    type="number"
    value={amount}
    onChange={(e) => setAmount(Number(e.target.value))}
    className="block border px-2 py-1"
    />
    </label>
    {state?.ok === false && state.error.code === 'conflict' && (
    <p role="alert" className="text-red-600">
    Changed elsewhere — it's now {state.current.amount}.
    </p>
    )}
    <p data-testid="saved">{state?.ok ? `Saved: ${state.data.amount}` : ''}</p>
    <div className="flex gap-2">
    <button type="button" onClick={() => setState(successResult(amount))} className="border px-3 py-1">
    Save
    </button>
    <button type="button" onClick={() => setState(conflictResult)} className="border px-3 py-1">
    Simulate a conflicting save
    </button>
    </div>
    </form>
    );
    }

    The conflict branch is gated on state?.ok === false && state.error.code === 'conflict' and reads state.current.amount, the fresh server row the conflict helper shipped beside the error, so the user sees what they’d overwrite. The success branch is gated on state?.ok and reads state.data.amount. Every other outcome falls through to the paths a real form already handles.

    A version column on every table, with a 409 possible on every save, is friction, not safety. Most writes don’t need it.

    The question to ask: did a client read a value, sit on it, then write based on it? That read-modify-write loop through a human is the only way a second writer can overwrite a fresh value. Without it, last-write-wins is correct and a version column is dead weight. Three common cases have no loop:

    • Single-user toggles. A personal preference, or a per-user “show archived” flag like the tri-state filter from this chapter’s first lesson. One owner, no second writer to lose a write to.
    • Append-only writes. A comment, an audit note, a new status entry. Each write creates a new row, so nothing existing is overwritten and two writers both succeed.
    • SQL-side increments. SET count = count + 1 reads and writes inside the database in one atomic statement, so no client holds a stale value. Two concurrent increments both land.

    The senior call: add the version column when a read-modify-write loop runs through a client, skip it otherwise. Walk the decision below when you’re unsure.

    Should this write carry a version column?

    Keep one distinction straight, because you’ll meet its neighbor soon. A version precondition is not an idempotency key; they catch different bugs:

    • An idempotency key stops the same write from landing twice, like a double-click or a network retry resending an identical request. Same intent, fired twice, should happen once.
    • A version precondition stops two different writes from overwriting each other: two tabs, two distinct edits, racing. Different intents, both acknowledged, but the loser must be told it lost.

    They’re orthogonal, and one action can want both: an idempotency key dedupes Bob’s accidental double-submit, while the version check catches that Bob and Alice were editing the same row. You’ll build idempotency keys later, with webhook ingestion.

    One boundary: this is not collaborative real-time editing. Tools like Google Docs, where two cursors edit the same paragraph and both sets of keystrokes survive and merge, use a different family of techniques (CRDTs, operational transforms ) and are out of scope. Optimistic concurrency answers a narrower, far more common question: did this whole-form save lose a race?

    The reference shape every later edit surface in the course mirrors.

    export const invoices = pgTable('invoices', {
    id: uuid().primaryKey().$defaultFn(() => uuidv7()),
    orgId: uuid().notNull(),
    amount: numeric({ precision: 12, scale: 2 }).notNull(),
    version: integer().notNull().default(1),
    ...lifecycleColumns,
    });

    The one schema change. A version column; DEFAULT 1 starts new rows at 1 and backfills existing ones.

    A user-editable row two tabs can open gets a version column at design time; no client read in the write loop, no version column.

    The primary sources behind this lesson, covering the concept, the React hooks, and the HTTP error standard, are worth keeping close: