← Writing

Bad frontend state management can take down your serverless backend

On serverless platforms, the cost driver is how many requests run at once — and that number is decided in your React components. A production incident that proved burst width is architecture.

On this page

Backend engineers have a comfortable belief: the frontend can annoy users, but it can’t hurt the infrastructure. Requests are requests; the backend either handles them or scales.

A production incident on an AI-agent platform I work on as fractional CTO broke that belief for me. Over one week, the Cloudflare Worker serving the API was out-of-memory killed 11,058 times — and when we finally had the full census, the causes were almost entirely on the client side. Not malicious traffic. Not big payloads. React components.

The thesis of this post: on serverless, burst width is architecture, and burst width lives in the frontend.

Why width matters more than weight

A Worker isolate has a fixed memory budget of 128 MB, and on this codebase every database-touching request cost roughly 3.81 MB for its Prisma client — memory that, because it lives in WASM, is never reclaimed for the isolate’s lifetime. The isolate’s footprint is set by its historical peak concurrency.

So the question that determines whether your backend lives or dies is not “how heavy is this endpoint?” It’s “how many requests arrive in the same few milliseconds?” And that number is decided entirely by frontend fetching behaviour.

The three frontend patterns that did the damage

1. The unstable dependency array

A chat page subscribed to a realtime channel inside a useEffect. The effect depended on an object rebuilt inline on every render — new identity each time — so React tore down and re-created the subscription on every render. Each re-subscribe fired an HTTP authorization call. We measured 12 authorization POSTs in 167 milliseconds from a single client, and up to 64 worker kills in one second at the peak.

Each response was about 200 bytes. The responses didn’t matter. The arrivals did.

An unstable useEffect dependency is not a rendering nit. It is a capacity bug. The fix is boring and absolute: memoize the object, or better, depend on a primitive (activeContact.userId, not activeContact), and create long-lived clients once in a []-scoped effect so only the subscription re-runs.

2. The mount burst that repeats on every click

Opening one detail view fired about twenty independent requests within a few milliseconds — account, entitlements, lists, sub-resources, counts. All twenty land on one isolate; each takes a database client; the isolate dies partway through the burst.

The design mistake that multiplied it: the view is a master–detail layout on a single route. Selecting an item changes a query param, not the page — so the app treated every click as a fresh mount and repeated the entire burst. Triaging ten items meant ten bursts of twenty.

Three of those twenty calls existed to display a single value each — one record, one boolean — and fetched a paginated list to get it. That’s three database clients per click spent on data the parent endpoint already had.

3. staleTime: 0, everywhere

The app shell refetched account info, entitlements, the project list, and notification counts on nearly every navigation, because React Query’s staleTime was left at its default of zero. Zero means “refetch on every mount.” Multiplied across every page transition of every user, that default was a standing tax on peak concurrency.

The kicker: notification freshness was already push-driven. The server publishes a change event over a websocket and the client invalidates the query — which triggers a refetch regardless of staleTime. The aggressive 15-second window bought no liveness at all. It just refetched on every navigation. We moved it to 5 minutes and badges still update the instant a notification arrives.

What the fixes looked like

Nothing exotic:

  • Memoize; depend on primitives; separate client creation from subscription.
  • staleTime set deliberately per query — 0 → 60 s for the project list, 15 s → 5 min for push-backed notification counts.
  • Fold single-value lookups into the parent detail endpoint instead of paginated list fetches.
  • Cap and dedupe retries so a failing endpoint doesn’t self-DDoS.

Result: kills went from 178/hour to under 1/hour — a 99.5% reduction — with zero backend scaling work. The backend didn’t get bigger. The frontend got narrower.

The rule: count the fan-out, not the endpoint

Backend reviews ask “is this endpoint fast? indexed? cached?” — per-endpoint questions. The incident-shaped question is different: when this feature renders, how many requests fire at once, and what else fires alongside them?

If a page already makes 15 requests on mount, the 16th is not free even if it’s fast — it raises the peak concurrency that permanently sets the isolate’s memory floor.

That question has no home in most teams. Backend engineers don’t review useEffect arrays; frontend engineers don’t think in isolate memory budgets. The gap between those two reviews is exactly where this incident lived for a week.

If you run on serverless — Workers, Lambda, anything with per-instance limits — put frontend fetching behaviour inside your architecture review. It’s not polish. It’s capacity planning.

← All writing Book a call →
Book a call → WhatsApp