DEV Community

Cover image for The React useEffect Object Dependency Trap: How Cloudflare Accidentally DDoSed Itself
Vasu Ghanta
Vasu Ghanta

Posted on

The React useEffect Object Dependency Trap: How Cloudflare Accidentally DDoSed Itself

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 17:50 UTC: New Tenant Service deployment goes live with added validation logic
  2. 17:57 UTC: Dashboard bug starts flooding the API with redundant calls
  3. 18:17 UTC: Engineers attempt to scale resources—partial recovery to 98% availability
  4. 18:58 UTC: Attempted fix backfires, making things worse
  5. 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'
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Fix: Use useCallback to stabilize the function reference:

const handleFetch = useCallback(() => {
  fetch('/api/data');
}, []);  // Only created once
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Lessons from the Cloudflare Outage

  1. Code reviews aren't enough: This bug passed through Cloudflare's review process, highlighting that even experienced engineers can miss subtle issues.

  2. Automated rollbacks save lives: Cloudflare noted that having Argo Rollouts (which auto-reverts on errors) would have limited the damage.

  3. Observability is critical: Better API telemetry distinguishing retries from new requests would have identified the loop faster.

  4. Capacity planning matters: The Tenant Service lacked resources to handle unexpected traffic spikes.

  5. 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)