DEV Community

Cover image for The Hidden Bugs Behind Duplicate API Requests
Hanzla Baig
Hanzla Baig

Posted on AI-assisted

The Hidden Bugs Behind Duplicate API Requests

One click on "Place order." Two rows in the orders table, created 40 milliseconds apart, identical down to the last field.

Nothing in the frontend code looks wrong. The server logs show two requests, both returning 201. Whoever wrote the endpoint is fairly sure it only creates one order per call, and they're right. It was called twice.

"Duplicate request" is a symptom with at least six different causes, and the fixes don't overlap much. Some of these causes are harmless (React in development), some are annoying (a double-clicked button), and one is genuinely dangerous: a client that timed out and sent the request again, while the server had already done the work. This article goes through them roughly in the order I'd check them, and spends the most time on that last one, because it's the one that costs money.


Before you debug: is it really a duplicate?

Three things look like duplicates and aren't.

CORS preflight. A cross-origin request with a JSON Content-Type, an Authorization header, or a method like PUT makes the browser send an OPTIONS request first. In DevTools the URL appears twice. Look at the Method column. (MDN: CORS preflight)

Development-only behavior. If the doubles show up on your machine but not in production, go straight to the React section below.

A different caller. A service worker, browser extension, or second tab can send requests that resemble yours. The Initiator column in the Network tab points at the line that started each request.

Then do the single most useful thing: log a request ID on the server. One request on the server means you're misreading the client. Two means keep reading.


React runs your effect twice (on purpose)

function Product({ id }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(r => r.json())
      .then(setProduct);
  }, [id]);
}
Enter fullscreen mode Exit fullscreen mode

Under <StrictMode> in development, this sends two requests on mount. In a production build it sends one.

That's deliberate. With Strict Mode on, React runs an extra setup, cleanup, setup cycle on each effect in development, to expose effects that don't clean up after themselves. The behavior arrived with React 18, and the surprise it caused is well documented in facebook/react #24502, where users asked for it to be explained better in the docs.

Removing Strict Mode makes the symptom disappear and leaves the bug. If an effect misbehaves when it runs twice, it will also misbehave when a user navigates away and back.

For reads, add a cleanup that cancels the request:

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/products/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setProduct)
    .catch(err => {
      if (err.name !== 'AbortError') setError(err);
    });

  return () => controller.abort();
}, [id]);
Enter fullscreen mode Exit fullscreen mode

You'll still see two requests in the Network tab in development, but the first is cancelled and only the second can touch state. An AbortController is better than the common ignore flag here: as this DEV Community post points out, the flag discards the result but still lets the first request download its whole response.

Cancelling on the client does not undo anything the server already did. Which brings up the real question this section raises: what is the effect doing? A duplicate GET is noise. A duplicate POST fired from an effect means a non-idempotent action is tied to rendering. React's guidance is that effects exist to synchronize with external systems, and that you don't need an effect to handle a user event. Creating an order belongs in the click handler, where it runs once per click.


Race conditions: the stale response wins

Sometimes two different requests are in flight and the older one finishes last.

A search box fires on every keystroke. The user types "re", then "react". The request for "re" is slow, the one for "react" is quick. Results for "react" render, then results for "re" arrive and overwrite them. The page now shows results for a query that no longer matches the input.

Nothing was sent twice, and nothing here is React-specific. The UI depends on the order responses arrive instead of the order the user acted. The cleanup shown above fixes it, since React runs the previous effect's cleanup before the next effect. If you'd rather not cancel, the flag version works too:

useEffect(() => {
  let ignore = false;
  fetch(`/api/search?q=${encodeURIComponent(query)}`)
    .then(r => r.json())
    .then(data => { if (!ignore) setResults(data); });
  return () => { ignore = true; };
}, [query]);
Enter fullscreen mode Exit fullscreen mode

Debouncing the input reduces how many requests you send. The cleanup makes sure the right one wins. They solve different problems, and a search box usually needs both.


The double click

A checkout button, an 800 ms API call, and a user who isn't sure the first click registered.

Disabling the button during submission is the obvious fix, with one catch. State updates don't apply synchronously inside the handler that sets them, so two clicks that land before React re-renders can both pass an if (submitting) check. A ref closes that gap:

function CheckoutButton() {
  const [submitting, setSubmitting] = useState(false);
  const inFlight = useRef(false);

  async function handleClick() {
    if (inFlight.current) return;   // the real lock
    inFlight.current = true;
    setSubmitting(true);            // the visual feedback
    try {
      await placeOrder();
    } finally {
      inFlight.current = false;
      setSubmitting(false);
    }
  }

  return (
    <button onClick={handleClick} disabled={submitting}>
      {submitting ? 'Placing order…' : 'Place order'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

This protects one component in one tab. A second tab, a mobile app, or a retry after a dropped connection walks right past it. Treat it as UI feedback, and keep the safety check on the server.


The same handler attached twice

If the server log shows two identical requests a few milliseconds apart and the button logic is fine, count the listeners.

// runs on every route change
function init() {
  document.querySelector('#save')
    .addEventListener('click', () => saveDraft());
}
Enter fullscreen mode Exit fullscreen mode

addEventListener ignores a repeat registration only when the function reference and capture flag are the same. An inline arrow function is a new object every call, so after three route changes one click runs saveDraft() three times. (MDN: addEventListener)

The usual sources are SPA code that attaches listeners imperatively on every navigation, jQuery's .on() inside a function that runs on every partial render, and React effects that subscribe without returning a cleanup. A form with both onSubmit on the <form> and onClick on its submit button doing the same job produces the same symptom.

In Chrome DevTools, select the element and open the Event Listeners panel. Two click entries where you expected one is your answer.


Retries: duplicates made by code that's trying to help

This is the case worth slowing down for.

You send a POST /orders. Ten seconds pass. Your client times out. Here is everything you know at that moment: you did not receive a response. Here is what you don't know:

  1. The request never reached the server.
  2. The server processed it, and the response was lost on the way back.
  3. The server is still working on it.

From the client these three look identical. If you retry in case 2 or 3, the operation runs twice.

HTTP has vocabulary for this. RFC 9110, section 9.2.2 defines idempotent methods: GET, HEAD, OPTIONS, TRACE, PUT and DELETE, where repeating the request has the same intended effect as sending it once. POST isn't in that set, and neither is PATCH in general. The RFC's error-handling section also notes that some requests can be automatically retried after a connection failure, pointing to section 9.2.2 for which ones. Secondary write-ups summarize the rule as: don't automatically retry a non-idempotent request unless you know it's effectively idempotent or can tell the original was never applied (ApyHub, a DEV Community post on safe failover).

Check what your client library does by default

Retry behavior varies by library and version, so read your client's documentation instead of assuming. One public example: a GitLab client-library change (merge request 2945) added an option to restrict retries to idempotent methods, because its default check retried any method on a 5xx response, which could duplicate a side effect the server had already applied. That's one project's default, but it shows how the problem hides: nobody wrote a retry for POST /orders; a generic retry policy did.

Retries multiply across layers

If the browser code retries 3 times, the API gateway retries 3 times, and the service retries its database call 3 times, one click can become 27 attempts at the bottom. Amazon's Builders' Library article on timeouts, retries and backoff with jitter makes this point about stacked retries, and also calls retries "selfish": each one spends more of the server's time to improve the caller's odds. Its recommendations are timeouts based on downstream latency, capped exponential backoff, jitter so clients don't all retry in lockstep, and limiting retries (the AWS SDK has used a token bucket for this since 2016).

Infrastructure retries too. Reverse proxies can pass a failed request to another upstream. For example, nginx's proxy_next_upstream documents that non-idempotent requests aren't passed on by default unless you opt in with non_idempotent. Read the equivalent setting for whatever sits in front of your app, and check access logs on every hop.

A retry helper that actually retries HTTP failures

One trap when writing your own: fetch() resolves successfully even when the server answers 503. A try/catch around it never sees the failure. You have to look at the status yourself.

class HttpError extends Error {
  constructor(res) {
    super(`HTTP ${res.status}`);
    this.status = res.status;
  }
}

const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);

function isRetryable(err) {
  if (err.name === 'AbortError') return false;
  if (err instanceof HttpError) return RETRYABLE_STATUS.has(err.status);
  return err instanceof TypeError; // fetch rejects with TypeError on network failure
}

async function withRetry(fn, { retries = 4, baseMs = 200, capMs = 5000 } = {}) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= retries || !isRetryable(err)) throw err;
      const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
      const delay = Math.random() * ceiling;          // full jitter
      await new Promise(r => setTimeout(r, delay));
    }
  }
}

// usage
const res = await withRetry(async () => {
  const r = await fetch('/api/orders', { method: 'POST', /* ... */ });
  if (!r.ok) throw new HttpError(r);
  return r;
});
Enter fullscreen mode Exit fullscreen mode

I ran this helper against a stub that returns 503 twice and then 201: it made three attempts and returned the 201. A 400 was thrown after one attempt, as intended. A production version should also honor a Retry-After header when the server sends one.

Retrying a POST with this helper is only safe if the server can recognize the repeat. That's the next section.


Making the server safe to call twice

Everything above reduces how often a duplicate arrives. None of it can remove the three-way ambiguity after a timeout, because the network is allowed to lose a response. For operations that must not happen twice, the main defense is an idempotency key: the client generates a unique key per logical operation, the server records it with the result, and a repeat with the same key returns the stored result instead of doing the work again.

Stripe's API is the best-known example. Their documentation says the idempotency layer saves the status code and body of the first request for a key, whether it succeeded or failed, and returns that for later requests with the same key. Keys can be up to 255 characters, and reusing a key with different parameters produces an error. GET and DELETE don't take keys because they're idempotent by definition.

There's also a standards effort: the IETF HTTPAPI working group's Idempotency-Key HTTP header field draft. As of the version I read (draft-07, October 2025), it's an active Internet-Draft, not an RFC, so details may change.

Client: create the key once, reuse it on every retry

// Create it when the user's intent is created (page load, checkout start),
// never inside the retry loop.
const idempotencyKey = crypto.randomUUID();

await withRetry(async () => {
  const r = await fetch('/api/orders', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
    },
    body: JSON.stringify(order),
  });
  if (!r.ok) throw new HttpError(r);
  return r;
});
Enter fullscreen mode Exit fullscreen mode

A new key per attempt defeats the whole mechanism.

Server: let the database arbitrate

The sketch below uses Express and PostgreSQL. It's an illustration of the pattern: I haven't run the SQL against a live database, so test it in your own stack, including the concurrent case.

CREATE TABLE idempotency_keys (
  key           text PRIMARY KEY,
  request_hash  text NOT NULL,
  response_code int  NOT NULL,
  response_body jsonb NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode
// Same payload in a different key order must hash the same.
function canonicalize(v) {
  if (Array.isArray(v)) return v.map(canonicalize);
  if (v && typeof v === 'object') {
    return Object.fromEntries(
      Object.keys(v).sort().map(k => [k, canonicalize(v[k])])
    );
  }
  return v;
}

app.post('/api/orders', async (req, res) => {
  const key = req.get('Idempotency-Key');
  if (!key) return res.status(400).json({ error: 'Idempotency-Key required' });

  const hash = sha256(JSON.stringify(canonicalize(req.body)));
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // Claim the key. The PRIMARY KEY decides who wins a race.
    const claim = await client.query(
      `INSERT INTO idempotency_keys (key, request_hash, response_code, response_body)
       VALUES ($1, $2, 0, '{}')
       ON CONFLICT (key) DO NOTHING
       RETURNING key`,
      [key, hash]
    );

    if (claim.rowCount === 0) {
      await client.query('ROLLBACK');
      const { rows: [saved] } = await pool.query(
        'SELECT request_hash, response_code, response_body FROM idempotency_keys WHERE key = $1',
        [key]
      );
      if (!saved) return res.status(409).set('Retry-After', '1').end(); // first attempt rolled back
      if (saved.request_hash !== hash) {
        return res.status(422).json({ error: 'Key reused with a different payload' });
      }
      return res.status(saved.response_code).json(saved.response_body);
    }

    // We own the key. Do the work, in the same transaction.
    const order = await createOrder(client, req.body);
    const body = { id: order.id };

    await client.query(
      `UPDATE idempotency_keys SET response_code = 201, response_body = $2 WHERE key = $1`,
      [key, body]
    );
    await client.query('COMMIT');
    return res.status(201).json(body);
  } catch (err) {
    await client.query('ROLLBACK').catch(() => {});
    throw err;
  } finally {
    client.release();
  }
});
Enter fullscreen mode Exit fullscreen mode

Three design points are worth spelling out.

Why INSERT ... ON CONFLICT and not "check, then insert." A SELECT followed by an INSERT has a gap, and two simultaneous requests can both pass the check. Letting a unique constraint decide closes the gap. As I understand PostgreSQL's behavior, a second insert of the same key blocks until the first transaction commits or rolls back, which is why the example doesn't need an in_progress state. Verify that against your isolation level and driver.

Why one transaction. Creating the order and recording the key together means a crash can't leave an order without a key, or a key without an order. This only works when both writes live in the same database. Once the operation involves something outside that transaction (a payment provider, a message queue, an email), the guarantee stops at the transaction boundary. For those, pass a key derived from yours to the provider's own idempotency mechanism, and consider an explicit in_progress state with a defined policy for concurrent callers, such as 409 with Retry-After.

Why canonicalize before hashing. JSON.stringify preserves key order, so {a:1,b:2} and {b:2,a:1} hash differently even though they mean the same thing. Without canonicalization, a legitimate retry from a different serializer gets rejected as "key reused with a different payload."

When a simpler rule is enough

If the domain gives you natural uniqueness, such as one open order per (user_id, cart_id), a unique constraint can reject the duplicate without any key table. Idempotency keys are the more general tool, and constraints are the cheaper one when they fit.

Idempotency isn't the only defense either. Uniqueness constraints, transactional design and operation-specific checks all contribute. What an idempotency key gives you that they don't is a consistent answer to the retry: the same response, not an error saying "already exists."


A checklist for the next time you see doubles

  1. Is one of them an OPTIONS preflight?
  2. Does it happen in a production build? If not, check Strict Mode and effect cleanups.
  3. What does the Initiator column say about each request?
  4. How many listeners does the element have?
  5. Does the server log show one request or two? Log a request ID.
  6. Are the two requests identical, or do query strings differ? Different ones suggest a race or dependency-array bug, not a retry.
  7. How far apart are the timestamps? Milliseconds points at a double-click or double-mount. Seconds points at a timeout and retry.
  8. Which layers retry? Browser code, client library, gateway, load balancer, service client, job queue.
  9. Can this endpoint safely run twice? If not, fix that first.

The first eight steps tell you why a duplicate happened. The ninth decides whether it matters.

Top comments (5)

Collapse
 
arhancanli profile image
Arhan Canli •

A quick way to tell the causes apart before reading any code is the gap between the two requests. A 40 ms pair points at a double click, StrictMode or a double submit handler, because a timeout retry cannot fire before the timeout, so those pairs sit seconds apart (client timeout plus backoff). 40 ms rows and 10 s rows in the same table need different fixes.

For the timeout case, an idempotency key only protects you if the key and the insert share a transaction with a unique constraint on the key. Checking "seen this key?" and inserting afterwards lets two parallel retries both pass the check. Storing a hash of the body next to the key also catches a client reusing a key for a different payload, which should be a 422 rather than a replay. On a replay, do you return the stored response of the first request, or only a 409?

Collapse
 
hanzla profile image
Hanzla Baig •

Good point about the timing and payload validation. I’d return the original response on a valid replay so the client gets the same result without creating another order. That consistency is what makes idempotency so useful.

Collapse
 
challan116ux profile image
challan116-ux •

Ordering the causes the way you do β€” and spending the most time on the timeout case β€” is exactly right, because that is the only one where both sides behaved reasonably and the system still produced two orders. A timeout tells the client nothing about whether the work happened, so treating it as a failure and retrying blindly converts an unknown outcome into a duplicate. The server-side request ID is the dividing line: until you can prove whether one request or two reached the server, every other fix is guessing at which of the six causes you actually have.

Collapse
 
hanzla profile image
Hanzla Baig •

Exactly , a timeout doesn't mean the operation failed, and that's where things get tricky. Idempotency is what makes retries safe. Thanks for adding this perspective!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.