On September 12, 2025, Cloudflare—one of the world's largest cloud infrastructure providers—experienced a three-hour outage that took down their dashboard and multiple APIs. The culprit? A seemingly innocent React useEffect hook with an object in its dependency array. This bug caused the dashboard to flood their own Tenant Service API with thousands of redundant requests, effectively creating a self-inflicted distributed denial-of-service (DDoS) attack.
This incident serves as a stark reminder that even experienced engineering teams at top-tier companies can fall victim to React's reference equality pitfalls. If you've ever wondered why your useEffect runs more often than expected, or why the ESLint exhaustive-deps rule seems to cause more problems than it solves, this article will clarify the core issue and provide battle-tested solutions.
The Problem: Reference vs. Value Equality
React's useEffect hook uses Object.is() to compare dependencies between renders. This works perfectly for primitive values like strings and numbers:
Object.is('hello', 'hello') // true
Object.is(42, 42) // true
But for objects and arrays, Object.is() compares references, not values:
Object.is({a: 1}, {a: 1}) // false!
Object.is([1, 2], [1, 2]) // false!
This means that even if two objects contain identical data, React treats them as different if they're separate instances in memory.
The Cloudflare Case Study
What Went Wrong
Cloudflare's dashboard code likely resembled this pattern:
function Dashboard() {
const config = {
endpoint: '/api/tenant',
headers: { 'Content-Type': 'application/json' }
};
useEffect(() => {
fetchTenantData(config);
}, [config]); // ⚠️ Problem: config is recreated every render
return <div>Dashboard content...</div>;
}
The Cascade Effect
Every time the component re-rendered (due to any state or prop change), a new config object was created. React compared the new object reference to the previous one, found them different, and re-executed the effect. This triggered an API call, which might update state elsewhere, causing another render, creating another config object, firing the effect again—an infinite loop of API requests.
When this buggy code deployed alongside a Tenant Service API update, the combination created a perfect storm:
- 17:50 UTC: New Tenant Service deployment goes live with added validation logic
- 17:57 UTC: Dashboard bug starts flooding the API with redundant calls
- 18:17 UTC: Engineers attempt to scale resources—partial recovery to 98% availability
- 18:58 UTC: Attempted fix backfires, making things worse
- 19:12 UTC: Rollback finally restores service
The outage lasted nearly three hours and affected millions of users trying to access Cloudflare's control panel.
Root Cause Analysis
The ESLint exhaustive-deps rule likely contributed to this bug. Consider this progression:
// Developer writes this initially
function Dashboard() {
const config = { endpoint: '/api/tenant' };
useEffect(() => {
fetchData(config);
}, []); // ESLint error: missing dependency 'config'
}
The linter complains that config is used but not in the dependency array. The developer "fixes" it:
useEffect(() => {
fetchData(config);
}, [config]); // Linter happy, but now effect runs every render!
This is a textbook example of following the rules but missing the bigger picture.
Solution 1: Use Primitive Dependencies
The most straightforward fix is to depend on primitive values instead of objects:
function Dashboard() {
const endpoint = '/api/tenant';
const contentType = 'application/json';
useEffect(() => {
const config = { endpoint, headers: { 'Content-Type': contentType } };
fetchTenantData(config);
}, [endpoint, contentType]); // ✅ Primitives compare by value
}
Pros: Simple, performant, linter-friendly
Cons: Requires destructuring complex objects into individual values
Solution 2: Define Config Outside Component
If the configuration never changes, move it outside the component:
const TENANT_CONFIG = {
endpoint: '/api/tenant',
headers: { 'Content-Type': 'application/json' }
};
function Dashboard() {
useEffect(() => {
fetchTenantData(TENANT_CONFIG);
}, []); // ✅ No dependencies needed
}
Pros: Zero re-runs, clean code
Cons: Only works for truly static configuration
Solution 3: Use useMemo for Stable References
For configurations that depend on props or state, use useMemo:
function Dashboard({ userId, apiVersion }) {
const config = useMemo(() => ({
endpoint: `/api/tenant/${userId}`,
version: apiVersion,
headers: { 'Content-Type': 'application/json' }
}), [userId, apiVersion]); // Only recreate when these change
useEffect(() => {
fetchTenantData(config);
}, [config]); // ✅ Stable reference unless userId/apiVersion change
}
Pros: Flexible, efficient for derived data
Cons: Adds complexity, requires understanding memoization
Solution 4: Stringify for Deep Comparison
For simple data structures, you can use JSON stringification:
function Dashboard() {
const config = {
endpoint: '/api/tenant',
retries: 3
};
const configString = JSON.stringify(config);
useEffect(() => {
const parsedConfig = JSON.parse(configString);
fetchTenantData(parsedConfig);
}, [configString]); // ✅ Strings compare by value
}
Pros: Works for any JSON-serializable data
Cons: Performance overhead, doesn't handle functions or circular references
Solution 5: Use Custom Hook with Deep Equality
For complex cases, leverage libraries like react-use:
import { useDeepCompareEffect } from 'react-use';
function Dashboard() {
const config = {
endpoint: '/api/tenant',
headers: { 'Content-Type': 'application/json' }
};
useDeepCompareEffect(() => {
fetchTenantData(config);
}, [config]); // ✅ Deep comparison of object contents
}
Pros: Clean API, handles nested objects
Cons: External dependency, performance cost for deep comparisons
Reproduction and Testing
Here's a minimal reproduction to test the problem:
import React, { useState, useEffect } from 'react';
function ProblematicComponent() {
const [count, setCount] = useState(0);
const [apiCalls, setApiCalls] = useState(0);
// Recreated every render
const config = {
endpoint: '/api/data',
timestamp: Date.now()
};
useEffect(() => {
console.log('Effect fired! API call #' + (apiCalls + 1));
setApiCalls(prev => prev + 1);
// Simulated API call
fetch(config.endpoint)
.then(res => res.json())
.catch(err => console.error(err));
}, [config]); // ⚠️ Dependency causes infinite loop
return (
<div>
<p>Component renders: {count}</p>
<p>API calls made: {apiCalls}</p>
<button onClick={() => setCount(c => c + 1)}>
Trigger Re-render
</button>
</div>
);
}
export default ProblematicComponent;
Expected behavior: Clicking the button should increment the render count once.
Actual behavior: The effect fires on every render, incrementing API calls uncontrollably.
Fixed Version
function FixedComponent() {
const [count, setCount] = useState(0);
const [apiCalls, setApiCalls] = useState(0);
const endpoint = '/api/data'; // ✅ Primitive value
useEffect(() => {
console.log('Effect fired! API call #' + (apiCalls + 1));
setApiCalls(prev => prev + 1);
const config = { endpoint, timestamp: Date.now() };
fetch(config.endpoint)
.then(res => res.json())
.catch(err => console.error(err));
}, [endpoint]); // ✅ Only runs when endpoint changes
return (
<div>
<p>Component renders: {count}</p>
<p>API calls made: {apiCalls}</p>
<button onClick={() => setCount(c => c + 1)}>
Trigger Re-render
</button>
</div>
);
}
Common Mistakes to Avoid
1. Disabling the Linter Without Understanding Why
useEffect(() => {
fetchData(config);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // ❌ Silencing the warning doesn't fix the bug
2. Including Functions in Dependencies
Functions suffer from the same reference equality issue:
function Dashboard() {
const handleFetch = () => { // New function every render
fetch('/api/data');
};
useEffect(() => {
handleFetch();
}, [handleFetch]); // ⚠️ Effect runs every render
}
Fix: Use useCallback to stabilize the function reference:
const handleFetch = useCallback(() => {
fetch('/api/data');
}, []); // Only created once
3. Depending on Arrays
Array literals have the same problem:
useEffect(() => {
processItems(items);
}, [items]); // If items = [1, 2, 3] every render, effect runs every time
Fix: Derive arrays from primitive values or use useMemo.
Advanced Best Practices
Use Data Fetching Libraries
Modern data fetching libraries like TanStack Query, SWR, or Apollo Client handle dependency management internally:
import { useQuery } from '@tanstack/react-query';
function Dashboard({ tenantId }) {
const { data, error, isLoading } = useQuery({
queryKey: ['tenant', tenantId], // ✅ Primitives only
queryFn: () => fetchTenant(tenantId)
});
// No useEffect needed!
}
Consider useEffectEvent (Experimental)
React 19 introduced useEffectEvent to separate reactive and non-reactive logic:
import { useEffect, useEffectEvent } from 'react';
function Component({ theme, roomId }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme); // Can read theme without dependency
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ Only depends on roomId
}
Lessons from the Cloudflare Outage
Code reviews aren't enough: This bug passed through Cloudflare's review process, highlighting that even experienced engineers can miss subtle issues.
Automated rollbacks save lives: Cloudflare noted that having Argo Rollouts (which auto-reverts on errors) would have limited the damage.
Observability is critical: Better API telemetry distinguishing retries from new requests would have identified the loop faster.
Capacity planning matters: The Tenant Service lacked resources to handle unexpected traffic spikes.
Test under realistic load: This bug only manifested under production-level concurrent requests.
Conclusion
The useEffect dependency array isn't inherently broken—it's doing exactly what it was designed to do: detect changes via reference equality. The problem arises when developers (understandably) expect value equality for objects.
The key takeaways:
- Default to primitives in dependency arrays whenever possible
- Move static configs outside components
- Use useMemo for dynamic configurations
- Consider abstractions like TanStack Query that handle this complexity
- Don't blindly follow linters—understand why they complain
Cloudflare's three-hour outage was costly, but the lessons learned are invaluable. By understanding the reference equality trap and applying the solutions outlined here, you can avoid creating your own accidental DDoS attack.
Remember: in React, {} !== {}, and that tiny detail can bring down a multi-billion dollar infrastructure. Code defensively, test thoroughly, and always question whether that object really needs to be in your dependency array.
Top comments (0)