Writing · July 8, 2026 · 8 min read
The INSERT that ran thirteen times: why a write inside a Server Component render is a race
React Server Components · Next.js · PostgreSQL · correctness
TL;DR: One sign-in on my job board side project produced thirteen copies of the same row in an application table. No errors, no failed requests, nothing suspicious in analytics. The cause was a "create the row if it doesn't exist" helper called from a Server Component layout. In the Next.js App Router there is no such thing as one render per visit: prefetching, navigations, server actions, and parallel tabs mean the same layout renders many times, sometimes milliseconds apart, and a check-then-insert in that path races with itself. The multiplier I watched burn CPU in an earlier post about the prefetch storm this time corrupted data. I weighed four ways out, and the one that actually guarantees correctness is a UNIQUE constraint in the database. Everything in application code is, at best, noise reduction.
Thirteen rows for one signup
The symptom surfaced in the most mundane way possible: I was scrolling through an admin view and saw the same email address thirteen times. Same person, same signup, thirteen rows. The auth provider's own user table had exactly one entry for that email, as it should, because the provider enforces email uniqueness. Only my application table had multiplied.
What made it unsettling was the silence. Nothing had failed. Every one of those thirteen inserts had completed successfully, returned a 200, and logged no error. Product analytics showed a single sign-in event and zero client-side exceptions in the incident window. The bug was invisible from every vantage point except the data itself.
The code that looked correct
The helper existed for a classic reason: "when a user enters the authenticated part of the app for the first time, make sure their profile row exists." The shape of the code will look familiar:
async function ensureProfileRow(email: string) {
const existing = await findProfileByEmail(email); // SELECT
if (existing) return;
await insertProfile({ email }); // INSERT
}Check, then insert. It reads like a guard clause, and in a single threaded script it would be one. I called it from the layout of the authenticated route group, reasoning that the layout is the one place every logged-in page passes through, so the row would exist before anything needed it. That reasoning holds two assumptions, both false in a Server Component.
There is no first render
The mental model I was carrying said: the user logs in, the app shell renders once, my helper runs once, done. The App Router does not work like that. A layout renders server-side on every RSC request that touches its segment, and RSC requests come from far more places than clicks:
- Link prefetching. Every
<Link>that enters the viewport can fire a prefetch request, and each prefetch renders the target route, layouts included, on the server. A dashboard with a sidebar of internal links fires a burst of these the moment it paints. - Navigations. Every client-side navigation within the group re-requests the RSC payload.
- Server actions. A form submission POSTs to the same route and re-renders it.
- Multiple tabs and devices. The same user, the same session, entirely independent request streams.
One person logging in and landing on a dashboard is not one render. It is a burst of renders, and crucially, some of them are concurrent. In my logs the incident window showed about ten renders of the authenticated layout within ten seconds, all returning 200, with two of them starting 26 milliseconds apart. "Provision on first render" is a lie in this architecture, because there is no single first render to provision on.
The race, and the amplifier
With concurrency in the picture, check-then-insert is a textbook time-of-check to time-of-use race. Two renders start within milliseconds of each other. Both run the SELECT. The row does not exist yet, so both get an empty result. Both proceed to INSERT. Two rows. Nothing in application code can close the window between the check and the write, because the two renders are separate invocations that share nothing but the database.
That explains two duplicates. It does not explain thirteen. The rest came from an amplifier: the function I used to fetch the existing row throws when the query matches more than one row. My code read only the data field of the result and ignored the error field. So the moment the initial race created two rows, every subsequent check errored out, the error was swallowed, the "existing" value came back empty, and the helper cheerfully inserted another row on every render. Thirteen rows was not thirteen races. It was one race plus a feedback loop.
Diagnosis by triangulation
The claim to prove: one user action produced N server-side writes, not N user actions producing N writes. No single data source could establish that, so I lined up three:
- The database against the auth provider. Auth said one user. My table said thirteen. Whatever multiplied, multiplied inside my application, not in the sign-in flow.
- Product analytics. One sign-in event in the window, no client-side exceptions, no repeated auth attempts. This also explained the invisibility: Server Components do not emit client analytics events, so a server-side write loop leaves no trace in any client-based tool.
- Server logs. The hosting provider's function logs for the window showed the burst: roughly ten renders of the authenticated layout in ten seconds, all successful, some overlapping. That was the smoking gun for concurrency.
One operational note: runtime logs on my platform expire after a few hours without an external log drain. The decisive evidence was perishable; a day later I would have been guessing.
The options on the table
With the mechanism understood, the question became where the write should live so that it cannot race with itself. Four options were on the table, and they are not interchangeable, so it is worth spelling out the trade-offs.
Option 1: move the write into the auth callback. The route handler that completes the login flow runs once per sign-in, not once per render, so provisioning there shrinks the number of writers from dozens to roughly one. The pros are real: correct semantics (a profile is a consequence of logging in, not of painting a layout), far less wasted work, and no database round trip on every render. The con is the word "roughly." Two tabs completing login flows, a retried callback, or the same user signing in on two devices still produce concurrent writers. Fewer writers is not the same as one writer.
Option 2: a server action triggered from the client after mount. This keeps the render path pure and ties the write to an explicit request. But it adds a client round trip before the row exists, depends on the client actually running JavaScript before anything server-side needs the data, and actions can be retried or fired from two tabs at once. It trades an invisible race for a visible latency cost, and keeps the race.
Option 3: a queue or event. Emit a "user signed up" event and let a consumer provision the row. This is the right shape for heavy or failure-prone provisioning: retries, backpressure, dead-letter visibility. For one INSERT of one small row it is infrastructure to operate forever, it adds a delay during which the row does not yet exist, and queues deliver at least once, so the consumer needs idempotency anyway. The queue relocates the requirement that got me here; it does not remove it.
Option 4: enforce idempotency in the database. A UNIQUE constraint on the natural key, with the write expressed as an insert that ignores conflicts. The database admits the first insert and atomically rejects every concurrent and future duplicate, no matter how many renders, callbacks, tabs, or queue redeliveries race. The cons are operational rather than architectural: existing duplicates must be cleaned up before the constraint can be created, the constraint must exist before any conflict-ignoring code deploys (the upsert references it and errors otherwise), and it presumes a stable natural key to constrain on. None of those is a reason not to do it. They are ordering requirements.
There was also a fifth pseudo-option I dismissed quickly: deduplicating in application code with a module-level memo or a lock. Concurrent serverless renders are separate instances sharing no memory, so a per-instance guard cannot see the other writer. A distributed lock could, but that is hand-building a slower, buggier version of the atomicity the database offers for free.
What I chose, and why
I chose option 4 as the guarantee and option 1 as the hygiene, in that order of importance. The reasoning: only the database sees every writer. Renders, auth callbacks, server actions, and any future queue consumer share nothing but the data layer, so that is the only place where "at most one row per email" can be decided atomically. The constraint turns the invariant from a convention the code tries to follow into a fact the system enforces. Moving the write into the auth callback then makes the common path clean and cheap, but it rides on top of the constraint rather than substituting for it.
The queue stayed on the shelf, not because it is wrong but because it is more machine than the job needs, and the server action lost because it added latency while keeping the race. If provisioning ever grows independent side effects (a welcome email, records in a third-party system), the event shape becomes worth its cost, with the constraint still underneath doing the actual guaranteeing.
The rollout order mattered as much as the choices. First, a code change capping the existence check at one row, which stopped the feedback loop immediately without touching the schema. Then a cleanup migration: deduplicate, keeping the oldest row per email, and add the constraint in the same transaction, so that if the constraint failed on a remaining duplicate the whole thing rolled back with no half-migrated state. Only then the conflict-ignoring upsert, which needs the constraint to exist. Deploying those in the wrong order turns a fix into an outage.
Render must be pure
The deeper principle is one React has been stating all along: render must be pure. That rule is usually taught as a client-side concern about strict mode. On the server it is a concurrency contract. The framework reserves the right to render a component zero, one, or N times, in parallel, and to discard any of those renders. Reads are safe under that contract. Writes are not. The render path starts from "as many times as the router feels like" semantics; route handlers, server actions, and queue consumers start from "roughly once." Put writes there, and let the database turn roughly into exactly.
What I'd tell past me
- Render is plural. In the App Router a layout renders on every RSC request: prefetches, navigations, actions, tabs. Any "on first render" logic is built on a fiction.
- Check-then-insert cannot be made safe in app code. Concurrent serverless invocations share nothing but the database, so the database is the only place uniqueness is decided atomically. A
UNIQUEconstraint is the guarantee; every code change is noise reduction. - Relocating a write shrinks a race, it does not end one. Auth callbacks, server actions, and queues all still admit concurrent or retried writers. Pick them for their semantics, not as a substitute for idempotency.
- Read the error field. A swallowed error turned a two-row race into a thirteen-row feedback loop. Helpers that throw on unexpected result shapes are telling you something.
- Server-side bugs are invisible to client tooling. No client exceptions, no analytics events, all requests 200. Triangulate the database, analytics, and server logs, and pull the logs quickly, because they expire.
- Fixes have a deploy order. Stop the bleeding in code, dedup before constraint, constraint before conflict-ignoring upserts, cleanup in one transaction.