How a Missing useCallback Triggered 10,000 API Requests Per Minute in Production
← Back
March 11, 2026React9 min read

How a Missing useCallback Triggered 10,000 API Requests Per Minute in Production

Published March 11, 20269 min read

At 11:40 AM on a Tuesday, our API monitoring dashboard lit up red. Request volume on the product search endpoint jumped from a baseline of 800 req/min to over 10,400 req/min in under 90 seconds. The engineering team's first instinct: DDoS attack. We spun up incident response, checked Cloudflare, began analysing IP patterns — and found every single request was coming from authenticated, logged-in users. Normal users. Doing normal things. The attacker was us.


Production Failure: The API That Couldn't Stop Being Called

The symptom was unmistakable. Our FastAPI backend, normally handling 800–1,200 req/min across the product search endpoint, was receiving 10,400 req/min — a 13× spike in 90 seconds. P99 latency climbed from 210ms to 4.8s. The PostgreSQL connection pool hit its ceiling of 100 connections. Our DigitalOcean managed database began queuing queries.

10,400req/min peak
4.8sP99 latency
100%DB pool saturation
90sspike onset

Within 4 minutes, our rate limiter started blacklisting IP ranges. Users began receiving 429 errors — including active, paying customers mid-session. We rolled back the last deployment (a UI refresh to the product listing page) and the spike vanished within 60 seconds. The backend exhaled. The investigation began.


False Assumptions: Hunting a Timer That Wasn't There

The rollback confirmed the culprit was in the last deploy. But the team's working theory was wrong: we assumed a background polling loop had been accidentally set to a 1-second interval instead of 30 seconds. That's the standard reflex — look for a timer or interval gone haywire.

We combed through every setInterval and setTimeout in the diff. Nothing. Polling logic was unchanged. We then checked for a misconfigured React Query refetchInterval — also clean. The mistake was assuming the issue was intentional repetition, not unintended re-rendering. Twenty minutes wasted chasing a ghost.


Profiling the Component Tree: Following the Re-render Trail

With the rollback live, I reproduced the issue locally by cherry-picking the offending commit onto a branch. React DevTools Profiler was the first tool: I opened the product listing page, typed a single character into the search input, and watched the flame graph.

The ProductSearch component re-rendered continuously — not once per keystroke, but in a tight loop. The profiler recorded 47 renders in 2,000ms with no user input after the initial keystroke. Each render triggered a fetch call to /api/products/search. The component was burning the CPU and the network simultaneously.

React Profiler — ProductSearch re-render storm
──────────────────────────────────────────────
Time →   0ms   40ms   80ms  120ms  160ms  200ms
         │      │      │      │      │      │
Render   ██     ██     ██     ██     ██     ██   (continuous loop)
         │      │      │      │      │      │
API      ↑      ↑      ↑      ↑      ↑      ↑
call  /search /search /search /search ...

47 renders in 2,000ms  = 23.5 renders/sec
1 API call per render  = 23.5 req/sec per user session
420 concurrent users   = ~9,870 req/sec ≈ 10,400 req/min observed
──────────────────────────────────────────────

The component was clearly in an infinite re-render loop. The question was why. I isolated each useEffect and found one with a dependency array that looked correct at a glance — but wasn't.


Root Cause: An Unstable Closure Reference Poisoning useEffect

The PR introduced a refactored ProductSearch component. A senior engineer had correctly moved the search fetch logic into an inline async function — clean separation from JSX. The code reviewed fine. The bug was invisible without knowing exactly how React's dependency comparison works.

components/ProductSearch.tsx — buggy version
// ❌ BEFORE: fetchProducts is recreated on every render
function ProductSearch({ filters }: Props) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Product[]>([]);

  // New function object allocated on EVERY render.
  // JavaScript closures don't cache — each render = new reference.
  const fetchProducts = async (q: string) => {
    const params = new URLSearchParams({ q, ...filters });
    const res = await fetch(`/api/products/search?${params}`);
    const data = await res.json();
    setResults(data.products); // ← triggers re-render
  };

  // React compares deps with Object.is().
  // fetchProducts is a NEW object each render → "changed" every time.
  // Effect fires → setResults → re-render → new fetchProducts → effect fires ...
  useEffect(() => {
    fetchProducts(query);
  }, [query, fetchProducts]); // ← fetchProducts is the infinite loop trigger

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ProductList results={results} />
    </div>
  );
}

The chain: fetchProducts is defined inside the component body without useCallback. JavaScript allocates a new function object on every render. React's useEffect does shallow reference comparison (Object.is) on every dependency — a new function object means the dependency "changed," so the effect re-runs. The effect calls setResults, which schedules a re-render. The re-render allocates a new fetchProducts. Repeat at ~24 Hz until the component unmounts or the server collapses.

"The bug wasn't a timer. It was React's referential equality check doing exactly what it's supposed to — and an unstable closure doing exactly what closures do. Correct behaviour, catastrophic outcome."

At 420 active sessions on the product listing page, each generating 23.5 req/sec once any search interaction occurred: 420 × 23.5 = 9,870 req/sec. The monitoring math finally matched the observed 10,400 req/min spike.


Architecture Fix: Three Layers — Stabilise, Debounce, Enforce

A single useCallback would stop the infinite loop. But we needed two more layers to make the search production-safe, and a fourth change to ensure this class of bug never merges again.

Fix Architecture — Three Layers
────────────────────────────────────────────────────────────
Layer 1: Stabilise the function reference
  fetchProducts = useCallback(fn, [filters])
  → Same JS object across renders unless `filters` prop changes
  → useEffect dep check sees no change → effect does NOT re-fire

Layer 2: Debounce user input (300ms)
  debouncedQuery = useDebounce(query, 300)
  useEffect deps: [debouncedQuery, fetchProducts]
  → API fires 300ms after user stops typing
  → 4-char query "shoe" = 1 request, not 4

Layer 3: Abort in-flight requests (race condition)
  AbortController per effect invocation
  → If debouncedQuery changes before response, cancel previous fetch
  → No stale results rendered out of order

Layer 4: CI lint enforcement
  eslint-plugin-react-hooks: exhaustive-deps → "error" (was "warn")
  → This exact bug fails the lint check before merging

────────────────────────────────────────────────────────────
Before: 1 search interaction → 96 API calls (4 keystrokes × 24/sec)
After:  1 search interaction → 1 API call (300ms after last keystroke)
────────────────────────────────────────────────────────────
components/ProductSearch.tsx — fixed version
// ✅ AFTER: stable reference + debounce + abort
function ProductSearch({ filters }: Props) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Product[]>([]);

  const debouncedQuery = useDebounce(query, 300); // only changes 300ms after last keystroke

  // useCallback returns the SAME function reference across renders.
  // Only reallocated when `filters` actually changes (prop equality).
  const fetchProducts = useCallback(async (q: string, signal: AbortSignal) => {
    if (!q.trim()) { setResults([]); return; }
    const params = new URLSearchParams({ q, ...filters });
    const res = await fetch(`/api/products/search?${params}`, { signal });
    if (!res.ok) throw new Error(`Search failed: ${res.status}`);
    const data = await res.json();
    setResults(data.products);
  }, [filters]); // ← only real dependency; stable across renders

  useEffect(() => {
    const controller = new AbortController();
    fetchProducts(debouncedQuery, controller.signal).catch(err => {
      if (err.name !== 'AbortError') console.error('Search error:', err);
    });
    return () => controller.abort(); // cancel on next effect or unmount
  }, [debouncedQuery, fetchProducts]); // ← both are now stable references

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ProductList results={results} />
    </div>
  );
}

Why useCallback over moving the function outside the component? Because fetchProducts needs filters from props — it must live inside the component's closure. useCallback memoises the function, returning the same reference until filters changes. That's the only structurally correct solution here.

Why debounce on top of useCallback? useCallback stops the infinite loop. Debounce stops the per-keystroke barrage. A user typing a 10-character query would still trigger 10 API calls without it. With 300ms debounce, it's one. Both fixes are necessary; neither alone is sufficient for production search.

The lint change was the most operationally significant. We already had eslint-plugin-react-hooks configured. exhaustive-deps was set to "warn". The PR had a warning in CI that nobody acted on. Upgrading to "error" caused the CI pipeline to fail on this exact file. This incident would not have merged under the stricter rule.


Lessons Learned

  • Inline functions in useEffect deps are latent infinite loops. Every function defined in a component body without useCallback is a new object each render. If it's in a dependency array, you have a loop waiting for the first state update to trigger it. The lint rule catches this automatically — only if set to "error".
  • React DevTools Profiler should be the first tool, not the last. We wasted 20 minutes checking Cloudflare logs for DDoS patterns. Opening the Profiler took 30 seconds and showed continuous re-renders immediately. For any "unexpected API volume" incident, profile the component tree before touching infrastructure.
  • "warn" in CI is functionally the same as "off." Warnings that don't block merges get ignored. Hook dependency rules are correctness constraints, not style preferences. Treat them as "error" or remove them — there's no useful middle ground.
  • Rollback first, investigate second. We spent 4 minutes investigating before triggering the rollback. That's 4 minutes of 429s hitting paying customers. The right protocol: rollback the moment you correlate a spike with a deploy, then investigate from a stable baseline.
  • Rate limiting bought us survivability, not availability. Without the rate limiter, the loop would have sustained at 10,000 req/min until the database connection pool was fully exhausted — a complete outage instead of degraded 429s. Rate limiting is a blast shield; debounce and stable refs are the fix.
10,400 → 12req/min post-fix
4.8s → 210msP99 latency restored
96×fewer API calls per search
0hook loop incidents since CI change

— Darshan Turakhia

Share this
← All Posts9 min read