React StrictMode double fetch in useEffect exposed a duplicate POST bug
Our onboarding page created two draft workspaces per visit in development. StrictMode was blamed and nearly disabled, but the real bug was a non-idempotent POST inside an effect with no cleanup, and it was also happening in production.
A developer on our onboarding team opened a pull request titled "Remove StrictMode, it doubles our
API calls". The evidence looked convincing: on the local Vite dev server, landing on
/welcome created two draft workspaces in the database instead of one. The Network tab showed two
identical POST /api/workspaces/drafts requests, a few milliseconds apart, both returning 201.
POST /api/workspaces/drafts 201 48 ms {"id":"wd_7f3a","status":"draft"}
POST /api/workspaces/drafts 201 51 ms {"id":"wd_7f3b","status":"draft"}
Before merging, we pulled production numbers for the same table. Over the previous 30 days there were 18,420 onboarding sessions and 19,958 draft workspaces. Production does not run StrictMode's development checks, so StrictMode could not explain 1,538 extra drafts. The double request in dev was a symptom; the same bug was leaking into production through other paths.
Reproducing it
In development it happened on every page load:
npm run devwith<StrictMode>inmain.tsx.- Open
/welcomeas a fresh user. - Check the drafts table: two rows for the same user.
In production we reproduced it by opening /welcome, clicking "Back" during the loading spinner,
and clicking "Forward" again. The component unmounted and remounted before the first request
finished, and a second draft appeared.
What we ruled out
- A double submit from the button. There was no button involved; the draft was created on mount, before the user touched anything.
- A retry in our HTTP client. Our fetch wrapper retries only idempotent methods, and the server logs showed two distinct requests with different trace IDs, not one retried request.
- React rendering twice. StrictMode also double-invokes render functions in development, but
rendering alone does not send requests. The POST was in
useEffect, which is the part StrictMode runs as mount, cleanup, mount.
Root cause
WelcomePage created a draft inside useEffect with an empty dependency array, as if an effect
were a "run once on mount" lifecycle hook. The effect had no cleanup function and the server
endpoint was not idempotent. Any extra mount, whether StrictMode's deliberate one in development or a
real remount in production, produced another workspace.
What is happening under the hood
Since React 18, StrictMode in development mounts every component, immediately simulates an unmount (running effect cleanups), and mounts it again. The React team added this to surface effects that do not clean up after themselves, because features like fast refresh, offscreen rendering and back/forward restoration can unmount and remount components with preserved state. A correctly written effect produces the same visible result whether it runs once or runs, cleans up, and runs again.
Our effect failed that test in two ways:
| Problem | Effect on users |
|---|---|
| No cleanup: the first request could not be cancelled or ignored | Response from a stale mount still updated state |
| POST creates a new resource every time | Every remount wrote a new row |
| Logic belonged to an event ("user started onboarding"), not to synchronization | Any render path could trigger it |
In production, remounts came from three places we found in our logs: the back/forward sequence above, our route-level error boundary resetting after a transient failure, and a key change on the layout when the user's locale was detected after the first paint.
Environment
| Component | Version / value |
|---|---|
| OS | macOS 14.6 (dev), Ubuntu 22.04 (API) |
| React | 18.3.1, react-dom 18.3.1 |
| Bundler | Vite 5.4.8 |
| Router | React Router 6.26.2 |
| API | Node.js 20.17.0, Fastify 4.28, PostgreSQL 16.4 |
| Relevant config | <StrictMode> wraps the app in main.tsx |
Minimal reproduction
import { useEffect, useState } from 'react';
export function WelcomePage() {
const [draftId, setDraftId] = useState<string | null>(null);
useEffect(() => {
// Runs twice in development under StrictMode, and again on any real remount.
fetch('/api/workspaces/drafts', { method: 'POST' })
.then((r) => r.json())
.then((d) => setDraftId(d.id));
}, []);
return draftId ? <Wizard draftId={draftId} /> : <Spinner />;
}
Who is affected
Every React 18 or 19 app that performs writes inside useEffect. Read-only fetches without cleanup
are also affected, just less visibly: the extra GET wastes bandwidth, and in a race the slower
response can overwrite the newer one.
We fixed it in two layers: the client stopped treating effects as a "run once" hook, and the server stopped trusting the client to call it once.
The fix
Server: make draft creation idempotent. The client generates a key per onboarding attempt and the
API returns the existing draft for a repeated key. We used an Idempotency-Key request header and a
unique index:
ALTER TABLE workspace_drafts ADD COLUMN idempotency_key uuid;
CREATE UNIQUE INDEX workspace_drafts_owner_key
ON workspace_drafts (owner_id, idempotency_key);
// routes/drafts.ts (Fastify handler excerpt)
app.post('/api/workspaces/drafts', async (req, reply) => {
const key = req.headers['idempotency-key'];
if (typeof key !== 'string' || !UUID_RE.test(key)) {
return reply.code(400).send({ error: 'Idempotency-Key header required' });
}
const { rows } = await db.query(
`INSERT INTO workspace_drafts (owner_id, idempotency_key, status)
VALUES ($1, $2, 'draft')
ON CONFLICT (owner_id, idempotency_key) DO UPDATE SET updated_at = now()
RETURNING id, status, (xmax = 0) AS created`,
[req.user.id, key],
);
return reply.code(rows[0].created ? 201 : 200).send(rows[0]);
});
Client: keep the key stable across remounts and clean up the request. The key lives in
sessionStorage, so remounts and reloads in the same tab reuse it, while a new tab starts a new
attempt. The effect aborts its request in cleanup:
import { useEffect, useState } from 'react';
function onboardingKey(): string {
let key = sessionStorage.getItem('onboarding-key');
if (!key) {
key = crypto.randomUUID();
sessionStorage.setItem('onboarding-key', key);
}
return key;
}
export function WelcomePage() {
const [draftId, setDraftId] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch('/api/workspaces/drafts', {
method: 'POST',
headers: { 'Idempotency-Key': onboardingKey() },
signal: controller.signal,
})
.then((r) => r.json())
.then((d) => setDraftId(d.id))
.catch((err) => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, []);
return draftId ? <Wizard draftId={draftId} /> : <Spinner />;
}
Aborting does not guarantee the server never saw the first request; it may already be processing
it. That is exactly why the idempotency key matters. In development the two calls now return the same
draft id, the first with 201 and the second with 200.
Alternatives and trade-offs
- Create the draft on an explicit user action. The cleanest option, and what we did on the redesigned flow a month later: the draft is created when the user clicks "Get started", in the event handler, not in an effect. Effects are for synchronizing with external systems, not for reacting to "the page was shown".
- A
useRefflag to skip the second run. It hides the StrictMode symptom in development but does nothing for real remounts, because a new mount gets a new ref. We rejected it. - A data library such as TanStack Query. Its deduplication helps with GETs. For a POST you still need server-side idempotency, so it does not replace the fix.
- Removing StrictMode. It would have made the bug invisible locally while it kept producing around 50 duplicate drafts a day in production.
How to test the fix
A Vitest test renders the page inside StrictMode and asserts that only one draft id is used,
against a mock server that implements the same idempotency rule:
import { StrictMode } from 'react';
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const drafts = new Map<string, string>();
const server = setupServer(
http.post('/api/workspaces/drafts', ({ request }) => {
const key = request.headers.get('Idempotency-Key')!;
if (!drafts.has(key)) drafts.set(key, `wd_${drafts.size + 1}`);
return HttpResponse.json({ id: drafts.get(key) });
}),
);
beforeAll(() => server.listen());
afterAll(() => server.close());
test('creates one draft under StrictMode', async () => {
render(<StrictMode><WelcomePage /></StrictMode>);
await screen.findByTestId('wizard');
expect(drafts.size).toBe(1);
});
npx vitest run src/onboarding
Confirming in production
We compared drafts per onboarding session before and after the release:
SELECT date_trunc('day', created_at) AS day,
count(*)::float / count(DISTINCT owner_id) AS drafts_per_user
FROM workspace_drafts
WHERE created_at > now() - interval '14 days'
GROUP BY 1 ORDER BY 1;
The ratio went from about 1.08 to 1.00 the day after deploy and stayed flat. Requests answered with
200 instead of 201 (repeats that were deduplicated) averaged 53 per day, which matched the
duplicate volume we had seen before and confirmed the remount paths were real. We also deleted the
orphan drafts older than 7 days with a one-off job.
Regression guard
- StrictMode stays on in development and in the test suite.
- The API rejects
POST /api/workspaces/draftswithout anIdempotency-Key, so a future client cannot reintroduce the bug silently. - We enabled
react-hooks/exhaustive-depsas an error and added a code review rule: anyfetchwith a non-GET method insideuseEffectneeds a comment explaining why it is not an event handler.
Security implications
Duplicate drafts were mainly a data quality and cost problem, but the same pattern on a payment or
invite endpoint would charge twice or send two emails. The idempotency key is scoped per user by the
unique index on (owner_id, idempotency_key), so one user cannot read another user's draft by
guessing a key. We also validate the key format and length to avoid unbounded values in the index.
Prevention checklist
- Do not create resources in
useEffect; do it in the event handler that represents the user's intent. - Every effect that starts a request returns a cleanup that aborts it or ignores its result.
- Make write endpoints idempotent with a client-generated key whenever retries or remounts are possible.
- Keep StrictMode on; treat its double effects as a free test, not a bug.
- Track "rows created per user action" for important tables to catch silent duplicates.
References
- React docs: StrictMode — https://react.dev/reference/react/StrictMode
- React docs: How to handle the Effect firing twice in development — https://react.dev/learn/synchronizing-with-effects
- React docs: You Might Not Need an Effect — https://react.dev/learn/you-might-not-need-an-effect
- MDN: AbortController — https://developer.mozilla.org/en-US/docs/Web/API/AbortController
- IETF draft: The Idempotency-Key HTTP Header Field — https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/
Add a label
Embed this post
<iframe src="https://hibug.me/react-strictmode-double-fetch-useeffect" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
IDOR in a Django invoice download endpoint let users fetch other customers' PDFs
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.