This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.
Introduction: The Innocent Tuesday Deployment
It was 4:45 PM on a quiet Tuesday. My code was reviewed, my pipeline was green, and I was one click away from deploying a simple "User Preferences" dashboard card. I hit merge, grabbed a cup of coffee, and prepared to close my laptop for the day.
Then, the alerts started.
First, my laptop’s cooling fans began to scream like a jet engine preparing for takeoff. Seconds later, our team's Slack channel erupted with high-severity database alerts.
CRITICAL ALERT: Database CPU Utilization at 99.8% CRITICAL ALERT: API Gateway Response Times > 8000ms
We weren't experiencing a sudden viral wave of traffic, nor were we under a external malicious cyberattack. The culprit was much closer to home. We were DDOSing ourselves, and the source of the attack was my innocent dashboard card.
The Chaos: The Runaway Hamster Wheel
Within three minutes of deployment, our analytics showed that a single page in our frontend application was aggressively hitting our /api/user-preferences endpoint thousands of times per second for every connected user.
Instead of rendering a clean UI, the browser was locked in an endless, violent render loop.
To prevent a total system outage, we immediately rolled back the deployment. The database slowly recovered, the fans quieted down, and I was left staring at my code in absolute disbelief.
How did a simple fetch hook trigger a catastrophic system meltdown?
The Investigation: Hunting with Sentry's Breadcrumbs
To find the root cause, I dived headfirst into our Sentry dashboard. Because we had Sentry telemetry wired up, we didn’t have to guess or manually reproduce the issue.
Sentry’s Transaction Spikes instantly pointed us to the exact transaction: /dashboard/settings.
When I opened Sentry's Session Replays, the mystery unravelled in high definition:
- Sentry recorded the user landing on the dashboard.
- The browser immediately fired a fetch request to /api/user-preferences.
- The component re-rendered.
- The browser immediately fired another identical fetch request.
- Sentry’s Breadcrumbs recorded a continuous, cascading waterfall of identical HTTP GET requests firing every 4 milliseconds.
The code responsible for this chaos looked like this:
// The Innocent-Looking Buggy Component
export function UserPreferencesCard() {
const [preferences, setPreferences] = useState({});
// The invisible killer: a dynamic object declared directly in the component body
const queryConfig = { includeMeta: true, theme: 'dark' };
useEffect(() => {
fetchUserPreferences(queryConfig).then((data) => {
setPreferences(data);
});
}, [queryConfig]); // Trigger fetch whenever queryConfig changes... right?
return (
<div className="preferences-card">
{/* UI components here */}
</div>
);
}
The Science: Why {} is Not Equal to {}
On paper, this code looks logical. "Fetch the user preferences whenever queryConfig changes." Since queryConfig is always { includeMeta: true, theme: 'dark' }, it should only fetch once, right?
Wrong. This is the classic trap of JavaScript Reference vs. Value Equality.
In JavaScript, primitives (like strings, numbers, and booleans) are compared by their value. But objects, arrays, and functions are compared by their reference (their location in computer memory).
When React re-renders a component:
- It executes the functional component from top to bottom.
- It redeclares queryConfig = { includeMeta: true, theme: 'dark' }. This creates a brand-new object in a different memory slot.
- React looks at the useEffect dependency array and compares the old queryConfig with the new queryConfig using Object.is().
- Because they reside in different memory locations, JavaScript declares: oldQueryConfig !== newQueryConfig.
- React thinks the dependency changed, so it triggers the useEffect fetch again.
- The fetch updates the preferences state.
- The state update forces the component to re-render, restarting the cycle ad infinitum.
The Resolution: Bringing Peace to the Database
Once the reference trap was exposed, the fix was incredibly simple. I had to ensure that the object reference remained stable across renders. I rewrote the component using a primitive value dependency array:
// The Beautiful, Quiet, Non-DDOSing Solution
export function UserPreferencesCard() {
const [preferences, setPreferences] = useState({});
// Primitives are clean, safe, and stable!
const includeMeta = true;
const theme = 'dark';
useEffect(() => {
fetchUserPreferences({ includeMeta, theme }).then((data) => {
setPreferences(data);
});
}, [includeMeta, theme]); // Compared by VALUE. No more infinite loops!
return (
<div className="preferences-card">
{/* UI components here */}
</div>
);
}
I deployed the fix, and our Sentry dashboard fell silent. The dashboard loaded in milliseconds, and the database CPU returned to a peaceful 3%.
The Big Takeaway
This bug was an incredible "aha!" moment for me. It transformed how I think about React's state lifecycles and JavaScript's underlying memory structures. It taught me that:
Dependency arrays are not magic: They rely strictly on JavaScript's standard equality checks. Passing an object or array literal directly into a dependency array without memoization is a ticking time bomb.
Observability is non-negotiable: Without Sentry’s Session Replays and telemetry, we would have spent hours digging through thousands of lines of server logs. Sentry allowed us to pinpoint the exact line of client-side code causing the storm in minutes.
Have you ever accidentally created an infinite loop that set your server on fire? Let me know your favorite debugging horror stories in the comments below!


Top comments (5)
The reference-equality trap on
useEffectdeps is one of those bugs that reads correctly in code review — the dependency is listed, the logic is sound, and the mistake is a language-level detail humans consistently miss under time pressure. What's interesting from a static-analysis angle is thatreact-hooks/exhaustive-depswon't flag this case because the dep is in the array; you'd need a rule that recognizes "this dep will be a new reference on every render" — a structural property, not a syntactic one. That gap is exactly the class of thing SDK-specific ESLint detectors are good at: bugs that are invisible in the diff but obvious to a rule that understands the runtime semantics of the surrounding context. The Sentry breadcrumb waterfall at 4ms intervals is also a forensic tell worth naming explicitly — that cadence is synchronous-render-triggered and identifies the bug class before you've read a single line of code.Lived a version of this. Once we moved data fetching off manual useEffect and onto TanStack Query, this whole class of bug basically disappeared, the library dedupes by a stable query key instead of relying on referential equality of an object literal. Cut our redundant network requests 60-80% as a side effect too. Doesn't help once you're mid-fire like this though, Sentry catching the exact line is the real hero here.
Fixing the dependency array is right but what would worry me more is that one bad deploy could reach the database at all. A rate limit per client at the API gateway would turn the next infinite loop into a noisy pile of 429s instead of a melted database. Frontend bugs like this are pretty much guaranteed to happen again.
the schema loading strategy matters more than people think. lazy loading saves a ton of context window.
includeMeta and theme from props or a parent object sit outside that primitive-deps fix. How do you handle those?
Some comments may only be visible to logged-in visitors. Sign in to view all comments.