Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · October 17, 2024 · 9 min read · 7 Views
Banner: React StrictMode double fetch in useEffect exposing a duplicate POST bug

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:

  1. npm run dev with <StrictMode> in main.tsx.
  2. Open /welcome as a fresh user.
  3. 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 useRef flag 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/drafts without an Idempotency-Key, so a future client cannot reintroduce the bug silently.
  • We enabled react-hooks/exhaustive-deps as an error and added a code review rule: any fetch with a non-GET method inside useEffect needs 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

Add a label

#react #useeffect #strictmode #abortcontroller #idempotency
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.