DevLift
Back to Blog

The Async Bug That Only Shows Up Under Load

Here's a scenario I see in almost every codebase I review. A developer writes an async function that reads some state, does a bit of work, then writes back a result. In tests, it's fine. In development, it's fine.

Admin
August 2, 20267 min read2 views

The Async Bug That Only Shows Up Under Load

Here's a scenario I see in almost every codebase I review. A developer writes an async function that reads some state, does a bit of work, then writes back a result. In tests, it's fine. In development, it's fine. You deploy it, and six weeks later you get a Slack message: "Hey, why does our order count keep going wrong on busy days?"

Race conditions in async JavaScript don't crash anything immediately. They show up as wrong numbers, duplicate records, or stale data — intermittently, only when things are busy. By the time someone notices, the offending code has been in production for months.

Node.js is single-threaded, which lulls developers into thinking "race conditions are a C++ problem." That's wrong. Anytime you await, you yield execution back to the event loop. Another piece of code can run. If two async operations touch the same state, you've got a race.

The Mistake

The most common form looks like this:

// lib/inventory.ts
async function reserveItem(itemId: string, userId: string) {
  const item = await db.item.findUnique({ where: { id: itemId } });
  const reserved = await db.reservation.count({ where: { itemId } }); // READ
 
  if (item.stock - reserved <= 0) {
    throw new Error("Out of stock");
  }
 
  await db.reservation.create({ data: { itemId, userId } });          // WRITE
}

Five requests arrive at nearly the same time for an item with stock: 1. All five reach the count before any of them reaches the create, so all five read 0, all five pass the check, and all five insert. I ran exactly this against a stubbed store with realistic latencies: 5 granted, 5 rows written, stock 1.

The precise condition matters more than the vibe, because it's what makes this reviewable. It is not "there's an await somewhere near shared state." It's:

a read, then an await, then a write that depends on what you read

Move the increment to the same synchronous block as the check and the bug disappears — nothing can interleave, because nothing yields:

// This variant does NOT race. The check and the mutation are one turn.
let reservedCount = 0;
 
async function reserveItem(itemId: string, userId: string) {
  const item = await db.item.findUnique({ where: { id: itemId } });
 
  if (item.stock - reservedCount <= 0) throw new Error("Out of stock");
  reservedCount++;              // same tick as the check — atomic by construction
 
  await db.reservation.create({ data: { itemId, userId } });
}

Same five concurrent requests against that version: 1 granted, 4 rejected, every time. The await before the check is irrelevant. Only an await between the read and the write opens a window.

Why It's a Problem

The word "race condition" sounds exotic, but the consequences are mundane and expensive:

  • Double charges. Two concurrent checkout requests both read "payment not processed yet," both submit to Stripe, both succeed. Customer gets charged twice.
  • Overselling inventory. The classic e-commerce footgun. Works perfectly on staging with one user. Fails in production with 50 concurrent users during a flash sale.
  • Duplicate records. Two concurrent user registration flows both check "does this email exist?" — both get null — both insert. Now you have two accounts for the same email.
  • Stale UI. A user clicks through pages quickly. Three fetch requests fire. They resolve out of order. The UI shows results from request #1 even though request #3 was for the current page.

The tricky part is that these bugs only surface under concurrency. A single-user test won't catch them. Your CI suite won't catch them. They hide until your traffic is real.

Real-World Examples

Example 1: The Double-Submit Button

This is the most common one I see in frontend code. A button fires an async request, but nothing prevents it from firing twice.

// Before — broken
function CheckoutButton({ cartId }: { cartId: string }) {
  const [loading, setLoading] = useState(false);
 
  async function handleClick() {
    setLoading(true);
    await submitOrder(cartId); // user clicks again during this await
    setLoading(false);
  }
 
  return (
    <button onClick={handleClick} disabled={loading}>
      Place Order
    </button>
  );
}

The usual explanation for this is "React state is async, so the button isn't disabled yet and a fast double-click gets through." That explanation is out of date. Since React 18, updates originating in a discrete event — click, keydown, submit — are flushed synchronously at the end of that event, before the browser dispatches the next one. handleClick returns at its first await, React commits disabled={true}, and a plain human double-click on a plain button really is blocked. Don't expect to reproduce the two-orders bug that way on React 19.

The reason to still write the guard is that disabled={loading} protects exactly one entry path. It does nothing about:

  • the same handler reached by Enter on a focused input and a click on the submit button
  • a call wrapped in startTransition, or triggered from a setTimeout / requestAnimationFrame / IntersectionObserver callback — none of those get the synchronous flush
  • a parent re-render that resets loading while the request is still in flight
  • anything invoking handleClick programmatically, including your own tests

A ref is a single synchronous variable that closes all of those at once, and it costs three lines.

// After — fixed with a ref guard
function CheckoutButton({ cartId }: { cartId: string }) {
  const [loading, setLoading] = useState(false);
  const submitting = useRef(false); // synchronous guard, no re-render needed
 
  async function handleClick() {
    if (submitting.current) return;
    submitting.current = true;
    setLoading(true);
 
    try {
      await submitOrder(cartId);
    } finally {
      submitting.current = false;
      setLoading(false);
    }
  }
 
  return (
    <button onClick={handleClick} disabled={loading}>
      Place Order
    </button>
  );
}

The useRef guard is a plain mutable box: it's set the instant the function is entered, before any await, regardless of which path got you here or whether React has committed anything. loading stays because it's what drives the UI. The ref is what enforces the invariant.

Example 2: The useEffect Fetch Race

This one is everywhere in React codebases, and the React docs themselves call it out. You fetch data based on a prop or route param. The user navigates quickly. Requests resolve in the wrong order.

// Before — broken
function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState(null);
 
  useEffect(() => {
    fetchProfile(userId).then(setProfile); // no cleanup
  }, [userId]);
 
  return <div>{profile?.name}</div>;
}

If userId changes from "user-1" to "user-2", both effects run. If the request for user-1 resolves after user-2, you display the wrong profile.

// After — fixed with AbortController
function UserProfile({ userId }: { userId: string }) {
  const [profile, setProfile] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((r) => r.json())
      .then(setProfile)
      .catch((err) => {
        if (err.name === "AbortError") return; // expected on cleanup, not an error
        reportError(err);                      // do NOT re-throw here
      });
 
    return () => controller.abort(); // cancel stale request on cleanup
  }, [userId]);
 
  return <div>{profile?.name}</div>;
}

The cleanup function aborts the stale fetch. When userId changes, React calls the previous effect's cleanup before running the new one. Stale response is discarded.

Note the catch handler swallows AbortError and logs everything else rather than re-throwing. Re-throwing inside a .catch on a promise nobody is awaiting just creates a fresh unhandled rejection — which on Node 22 terminates the process, and in the browser reaches window.onunhandledrejection with no useful stack. The whole point of catching here is that this is the end of the chain.

If you can't use AbortController (e.g. you're calling a library function that doesn't accept a signal), the boolean flag approach works:

useEffect(() => {
  let cancelled = false;
 
  fetchProfile(userId).then((data) => {
    if (!cancelled) setProfile(data);
  });
 
  return () => { cancelled = true; };
}, [userId]);

Less elegant, but it prevents the state update without stopping the network request.

Example 3: Shared Server-Side Counter

This surfaces in Node.js services that maintain in-memory state — counters, caches, queues. The code looks sequential but isn't.

// Before — broken
class RateLimiter {
  private requests = 0;
 
  async isAllowed(userId: string): Promise<boolean> {
    const count = await redis.get(`rate:${userId}`);
 
    if (parseInt(count ?? "0") >= 100) {
      return false;
    }
 
    // Two concurrent calls both reach here with the same count.
    // Both increment. Both set. Net result: one increment instead of two.
    const newCount = parseInt(count ?? "0") + 1;
    await redis.set(`rate:${userId}`, newCount, { EX: 60 });
 
    return true;
  }
}

The read-modify-write is not atomic. Two concurrent requests for the same userId both read 50 and both write 51 — two requests served, one counted. Ten concurrent calls against a stubbed Redis with 3ms latency left the counter at 1. Scale that up and your 100/minute limit lets through as much traffic as the client can fan out in one round trip, which is the whole point of a rate limiter defeated.

// After — use atomic Redis increment
class RateLimiter {
  async isAllowed(userId: string): Promise<boolean> {
    const key = `rate:${userId}`;
 
    // INCR is atomic — no read-modify-write, no race
    const count = await redis.incr(key);
 
    if (count === 1) {
      await redis.expire(key, 60); // set TTL only on first increment
    }
 
    return count <= 100;
  }
}

INCR is a single atomic operation on the Redis side. No race window — the same ten concurrent calls leave the counter at 10. This is the general principle: push the critical section down to a system that can guarantee atomicity (Redis, Postgres, a proper mutex).

Example 4: Autocomplete Search Out-of-Order Responses

A search input fires a request on every keystroke. Network is unpredictable. An older request can resolve after a newer one.

// Before — broken
async function handleSearch(query: string) {
  const results = await searchAPI(query);
  setResults(results); // might be stale if a newer request resolved first
}

User types "reac" → "react" → "react h". Three requests fire. The first one ("reac") is slow and resolves last. User sees results for "reac" while looking for "react h".

// After — cancel stale requests
let currentSearchId = 0;
 
async function handleSearch(query: string) {
  const searchId = ++currentSearchId;
 
  const results = await searchAPI(query);
 
  // Discard if a newer search has started since this one was dispatched
  if (searchId !== currentSearchId) return;
 
  setResults(results);
}

The incrementing ID acts as a version counter. Any response that doesn't match the latest ID gets thrown away. Simple, no external library needed.

The Fix: General Principles

These examples share a common thread. The fix is always one of:

1. Make the critical section atomic. Use database transactions, Redis atomic commands (INCR, SET NX), or a mutex library. Don't do read-modify-write across await boundaries unless the storage layer guarantees atomicity.

2. Cancel stale operations. AbortController for fetch, cleanup functions in useEffect, version counters for in-memory state. When a newer operation starts, the old one becomes irrelevant — discard its result.

3. Guard against concurrent entry. The useRef guard pattern for UI interactions. An in-flight map (keyed by operation ID) for server-side deduplication. If the same operation is already running, don't start another.

4. Avoid shared mutable state across async boundaries. This is the root cause. If your function reads a variable, awaits, then writes the same variable, you have a race window. Either make the operation atomic, or eliminate the shared state.

What to Look for in Code Review

When you're reviewing async code, slow down at every await. Ask: what state is being read before this await? What state is being written after it? Could two concurrent calls both reach this point before either finishes?

Specific things to flag:

  • Read, then await, then write — any read-modify-write across an await is suspicious
  • No useRef guard on submit handlersdisabled={loading} alone isn't enough
  • useEffect fetches without cleanup — no AbortController or cancelled flag
  • In-memory counters on a server — fine for metrics, wrong for business logic
  • Promise.all where the operations share state — concurrent by design, races by accident
  • "check then act" patterns — checking existence before inserting, checking stock before reserving, without a database-level lock or constraint enforcing it

The hardest part is that this code works in every test you write. You have to think about concurrency at review time, not runtime.

Any await inside a read-modify-write is a race window — either push the operation down to a system that supports atomicity, or guard against concurrent entry before you yield.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Stop casting with `as` and `!`. Discriminated unions give TypeScript enough information to narrow types automatically — making impossible states unrepresentable and exhaustiveness checking free.
AdminAugust 2, 20265 min read
TypeScript ships with 15+ utility types that let you derive new types from existing ones. Here's the full toolkit with the production patterns you'll actually reach for.
AdminAugust 2, 20265 min read
TypeScript is the default for serious JavaScript work now — but the real question is when the type overhead pays off, and which parts of your codebase genuinely don't need it.
AdminAugust 2, 20268 min read