Last week our dashboard's time-to-interactive jumped from 800ms to over 1s on the p75. No deploys to the frontend. No new dependencies. Just a quiet, persistent 200ms tax on every session.
This is the story of how we tracked it down, and why it ended up being a single missing dependency in a useMemo.
The first signal
The alert came from our RUM dashboard at 9:14 AM:
p75 TTI for
/dashboardexceeded 1000ms threshold for 3 consecutive 5-minute windows.
The graph was clean — not a spike, just a step function. Something had changed at exactly 02:00 UTC the night before, and nothing on the frontend had shipped in 36 hours.
Narrowing the window
I pulled up the deploy log and grepped for anything that touched the relevant code path:
git log --since="2 days ago" --until="now" \
--name-only --pretty=format:"%h %s" \
-- 'src/dashboard/**'
Three commits. Two were pure CSS. The third was a backend change that added a new field to the /api/usage response — a nested breakdown object with ~40 keys per row.
That was the smoking gun. The frontend hadn't changed — but the shape of the data flowing into it had.
The hypothesis
Our <UsageTable> memoizes a derived list:
const rows = useMemo(() => {
return data.map((row) => ({
...row,
total: sumBreakdown(row.breakdown)
}))
}, [data])
data is a new reference on every fetch. The memo invalidates every poll. Before the backend change this was cheap — sumBreakdown over 4 keys. After, it was 40 keys, run for every row, every 5 seconds, on the main thread.
But that alone shouldn't be 200ms. The real cost was downstream.
The actual bug
Here's the offending child component, simplified:
| Prop | Type | Notes |
|---|---|---|
rows | Row[] | New reference every poll |
onSelect | (id: string) => void | Stable via useCallback |
filters | Filters | Stable, from URL |
function UsageChart({ rows, filters }: Props) {
const series = useMemo(() => {
return buildSeries(rows, filters)
}, [rows]) // <-- missing `filters`
return <Chart data={series} />
}
See it? filters is read inside the memo but missing from the dependency array. Normally that's a stale-closure bug. Here it was worse: buildSeries does an internal sort that mutates a cached array keyed by filters.range. With a new rows reference every 5 seconds and a stale filters reference inside the closure, the cache was thrashing — every call rebuilt the sorted array from scratch, and the chart library re-ran its layout pass because the series identity changed.
Three things compounded:
- Backend payload grew ~10×.
- A memo dependency was missing, defeating the cache.
- The chart library does an O(n log n) layout on every prop identity change.
Individually, none of these would have tripped the alert. Together, exactly 214ms of extra main-thread work per poll.
The fix
One line:
const series = useMemo(() => {
return buildSeries(rows, filters)
}, [rows, filters])
Plus an eslint-plugin-react-hooks rule we'd had set to warn instead of error. We bumped it.
{
"rules": {
"react-hooks/exhaustive-deps": "error"
}
}
Press Ctrl+Shift+P in your editor, run Restart ESLint Server, and you'll see the warnings light up immediately.
Lessons
A few things I'm taking from this:
- Backend changes are frontend changes. Any time the shape of an API response changes, treat it as a frontend deploy for the purposes of regression hunting.
exhaustive-depsshould be an error, not a warning. The whole point of the rule is that you can't tell by reading the code whether a missing dep matters. Letting it warn means it gets ignored.- Memoize the inputs, not the outputs. If
datahad been stable across polls when its contents were equal, none of this would have mattered. AuseDeepMemoat the fetch boundary would have absorbed the regression entirely.
The whole investigation took about 90 minutes from alert to fix. The actual diff was one word in a dependency array. That ratio feels about right for production debugging.
If you want to reproduce the pattern locally, the minimal repro is in this gist. The interesting bit is how stable Chart looks in React DevTools — the props are "the same" by Object.is until you remember that rows is rebuilt every poll.