Transactions, Optimistic UI, and Concurrency
Transactions, optimistic UI, and concurrency control solve one practical problem: users want an interface that responds immediately, while the database must still remain correct when requests overlap. In a Next.js application this usually means a Client Component predicts a mutation result, a Server Action or Route Handler performs the real write, and the database enforces the invariant that the UI is only guessing about.
The outcome of this chapter is a concrete mutation model for database-backed Next.js features: place the authoritative write on the server, wrap related writes in one database transaction, use optimistic rendering only for reversible visual feedback, and detect stale or conflicting updates before they overwrite newer data.
What Actually Happens
A transaction is a database unit of work that commits completely or rolls back completely. If a checkout creates an order, decrements inventory, and writes a payment attempt, those changes should not be half-visible. In application code, the transaction is the boundary around the statements that must share one outcome.
Optimistic UI is different. It is a rendering technique, not a durability guarantee. React lets the browser show an expected state before the server confirms it. The user sees a pending message, reordered list item, or adjusted counter immediately. If the server succeeds, the optimistic state becomes ordinary state after a refresh or cache revalidation. If the server rejects the mutation, the UI must remove or replace the prediction.
Concurrency is the part that makes this interesting. Two users can edit the same record. One user can double-click a submit button. A slow request can finish after a newer request. A serverless deployment can run many instances of the same action at once. Next.js does not serialize those requests for you. The database and mutation design must decide which writes can coexist, which writes conflict, and which writes are idempotent retries.
Next.js Mutation Anatomy
The common App Router shape has four pieces. A Client Component owns interactive pending state. A Server Action receives a narrow command rather than a trusted object graph. The data layer starts a transaction close to the SQL or ORM call. Finally, the action calls revalidation, returns the committed result, or throws a typed error that the client can use to undo the optimistic prediction.
Use Server Actions for form-like mutations invoked from React. Use Route Handlers when the mutation is called by non-React clients, webhooks, or external services. In both cases, validate input on the server, derive the user identity from the session on the server, and treat client-supplied identifiers as selectors to authorize, not proof of ownership.
Example 1: Atomic Account Transfer
This first example is framework-free so the transaction idea is visible. The transfer must subtract from one account and add to another, or do neither. The expected output shows that a failed withdrawal leaves both balances unchanged.
function transfer(accounts, from, to, cents) {
const snapshot = accounts.map((account) => ({ ...account }));
try {
const source = snapshot.find((account) => account.id === from);
const target = snapshot.find((account) => account.id === to);
if (!source || !target) throw new Error("account not found");
if (cents <= 0) throw new Error("amount must be positive");
if (source.balanceCents < cents) throw new Error("insufficient funds");
source.balanceCents -= cents;
target.balanceCents += cents;
return snapshot;
} catch (error) {
return accounts;
}
}
const accounts = [
{ id: "a", balanceCents: 5000 },
{ id: "b", balanceCents: 1000 }
];
console.log(transfer(accounts, "a", "b", 1500));
console.log(transfer(accounts, "b", "a", 9999));
A real database transaction gives this all-or-nothing behavior even when the statements touch persistent rows. The application should not implement rollback by manually compensating after every failed statement; it should ask the database to commit only after every required statement succeeds.
Example 2: Optimistic Comments
The next example models a comment form. The client inserts a temporary comment immediately. The server later returns the committed row, or the temporary row is removed. The important design choice is that optimistic rows have client-only identifiers and a visible pending status.
function applyOptimisticComment(comments, text) {
const trimmed = text.trim();
if (trimmed.length === 0) return comments;
return [
...comments,
{ id: `tmp-${comments.length + 1}`, text: trimmed, status: "pending" }
];
}
function confirmComment(comments, temporaryId, committed) {
return comments.map((comment) =>
comment.id === temporaryId ? { ...committed, status: "sent" } : comment
);
}
function rejectComment(comments, temporaryId) {
return comments.filter((comment) => comment.id !== temporaryId);
}
let comments = [{ id: "c1", text: "First", status: "sent" }];
comments = applyOptimisticComment(comments, " Ship it ");
comments = confirmComment(comments, "tmp-2", { id: "c2", text: "Ship it" });
console.log(comments);
In a React Client Component, this maps naturally to useOptimistic or local state plus a pending form status. The optimistic item should not pretend to be authoritative. It should be styled or ordered so that a rejected mutation can disappear without corrupting pagination, unread counts, or cache-derived totals.
Example 3: Versioned Updates
Optimistic UI also needs a stale-write defense. If Alice opens a task at version 7 and Bob saves version 8 first, Alice’s old form should not overwrite Bob’s newer change silently. A simple pattern is optimistic concurrency control: include the version the user edited, and update only when the current database version still matches it.
function updateTitle(row, command) {
if (row.version !== command.expectedVersion) {
return { ok: false, reason: "version_conflict", current: row };
}
return {
ok: true,
row: { ...row, title: command.title.trim(), version: row.version + 1 }
};
}
const task = { id: "t1", title: "Draft", version: 7 };
const first = updateTitle(task, { title: "Review", expectedVersion: 7 });
const second = updateTitle(first.row, { title: "Publish", expectedVersion: 7 });
console.log(first);
console.log(second.reason);
With SQL, this becomes an UPDATE with WHERE id = ? AND version = ?. If the affected row count is zero, the action returns a conflict instead of success. With an ORM, look for either an explicit transaction API plus a conditional update, or a documented versioning feature. Do not rely on the client comparing timestamps after the save; by then the overwrite may already have happened.
Server Action Fragment
This fragment shows where the pieces sit in a Next.js App Router project. It is intentionally compact: validate input, authorize from the server session, run related writes inside the transaction callback, revalidate the route that reads the data, and return the committed shape.
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireUser } from "@/lib/session";
const RenameTask = z.object({
taskId: z.string().min(1),
title: z.string().trim().min(1).max(120),
expectedVersion: z.number().int().nonnegative()
});
export async function renameTask(input) {
const user = await requireUser();
const command = RenameTask.parse(input);
const task = await db.$transaction(async (tx) => {
const existing = await tx.task.findFirst({
where: { id: command.taskId, ownerId: user.id },
select: { id: true, version: true }
});
if (!existing) throw new Error("not_found");
if (existing.version !== command.expectedVersion) throw new Error("version_conflict");
return tx.task.update({
where: { id: existing.id },
data: { title: command.title, version: { increment: 1 } },
select: { id: true, title: true, version: true }
});
});
revalidatePath("/tasks");
return task;
}
The transaction callback should contain only the database work that must be atomic. Avoid placing slow email delivery, external payment confirmation, or analytics calls inside it. External calls extend lock time and can leave you uncertain about whether the outside system acted when the database rolls back.
Design Choices and Trade-offs
Pessimistic locking prevents others from changing selected rows while your transaction runs. It is useful for scarce resources, but it can reduce throughput and create waiting chains. Optimistic concurrency allows overlapping work and detects conflicts at commit time. It is better for common edit forms where conflicts are rare and users can merge or retry.
Optimistic UI improves perceived latency, but it increases client complexity. Use it for reversible interface changes such as comments, likes, task titles, and list ordering. Be cautious with money movement, destructive actions, or scarce inventory. Those flows can still show immediate pending feedback, but they should avoid displaying irreversible success before the server confirms the transaction.
Idempotency is separate from transactions. If a user submits the same checkout twice, each request may be internally transactional and still create two orders. For retryable commands, include an idempotency key generated for the user’s intent and store it with the committed result. A later request with the same key should return the first result rather than perform a second write.
Failure Modes and Troubleshooting
Symptom: users occasionally see duplicate rows after double-clicking submit. Cause: the action has no idempotency key or unique database constraint. Diagnose: inspect request logs for repeated commands with the same form values and close timestamps. Correct: disable while pending for usability, and enforce uniqueness or idempotency on the server for correctness.
Symptom: a user’s edit overwrites another user’s newer edit. Cause: last-write-wins updates with no version check. Diagnose: compare the version or updated timestamp displayed when the form loaded with the value before the update. Correct: use conditional updates and return a conflict response that reloads the current row.
Symptom: optimistic items flash, vanish, then return. Cause: cache revalidation and local optimistic state are racing, or the temporary item cannot be matched to the committed item. Diagnose: log the temporary id, returned id, and refresh timing. Correct: replace the temporary item with the committed row and let the next server render converge on the same data.
Symptom: transaction timeouts appear under load. Cause: the transaction reads too much, waits on external work, or updates rows in inconsistent order. Diagnose: check slow query logs and lock wait information. Correct: narrow indexes and predicates, move external effects after commit, and update shared resources in a consistent order.
Security, Performance, and Reliability
Authorization belongs inside the mutation, not only in the page that renders the button. A Server Action can be called directly by a capable client, so it must derive the user server-side and query only rows that user may change. Transactions protect consistency, but they do not protect authorization mistakes.
Performance depends on transaction length and contention. Keep transaction scopes short, select only columns needed for the decision, and index the predicates used for ownership and version checks. Reliability depends on retries being deliberate. Retry transient serialization or deadlock errors when the operation is idempotent; return a conflict for genuine stale data.
Hands-on Lab
Prerequisites: a Next.js App Router project, a database or local ORM setup, and one table such as Task with id, ownerId, title, and version. Use a throwaway branch or local database.
- Create a server mutation that accepts
taskId,title, andexpectedVersion. - Validate the command on the server and derive the authenticated user there.
- Inside a database transaction, load the task by both
idandownerId, compare the version, update the title, and increment the version. - In a Client Component, show the submitted title immediately with a pending marker while the action runs.
- On success, replace the pending title with the returned committed task and revalidate the route that reads the task list.
- On conflict, remove the optimistic change, show the current server value, and ask the user to reapply the edit.
Verification: open the same task in two browser windows. Save a change in the first window, then save a different change from the second window using the old version. The second save should not overwrite the first; it should report a conflict or refresh to the committed value. Double-submit the same form and confirm that pending UI does not create duplicate durable rows.
Cleanup: remove test rows, clear any idempotency records created for the lab, and roll back the throwaway branch if the project was only used for practice.
Assessment Exercises
- A like button uses optimistic UI and sometimes ends one count too high. Identify which part should be fixed in the client and which invariant must be enforced in the database.
- Design a versioned update for a shared document title. What does the command include, and what should the server return when the version is stale?
- Decide whether pessimistic locking or optimistic concurrency is better for reserving the last ticket to an event, and explain the trade-off.
- A Server Action sends an email from inside a transaction. Explain why this is risky and where the email should move.
- Write one verification step that proves authorization still holds when a user submits another user’s record id directly.
Summary
In Next.js, optimistic UI is the fast visual guess, the Server Action or Route Handler is the controlled mutation entry point, and the database transaction is the authority for atomic state. Correct concurrent behavior comes from explicit choices: short transactions for related writes, idempotency for retries, version checks for stale edits, and client rollback for failed predictions.
