Skip to content

A slow SSR fetch whose duration equals your client timeout budget is a victim, not a cause — attribute the wedge with a valid request's queue time

TL;DR.

SSR fetch durations that exactly match timeout+retry (e.g. 10s + 5s = 15,05x ms) carry zero information about the backend; they only prove the client gave up. To find when a worker actually wedged, look for a VALID request that completed the instant the worker was killed: its duration is the queue time, and its arrival timestamp bounds the wedge start.

Symptom

An incident report correlates worker kills with slow upstream fetches to one suspicious URL and concludes that URL caused the wedge. Typical shape: two slow_ssr_fetch events at 15,051 ms and 15,058 ms to a malformed path, next to two [CRITICAL] WORKER TIMEOUT lines, at the same minute.

Why the correlation is worthless as evidence

A duration that equals your own budget is not a measurement of the server. Look for the arithmetic in your own code before trusting the number:

  • SSR load fetch budget: AbortSignal.timeout(10_000)
  • one retry on TimeoutError with a fresh AbortSignal.timeout(5_000)
  • total elapsed logged by the handleFetch finally block: 10,000 + 5,000 + overhead = 15,05x ms

Every clamped entry in the tail will read ~15,05x ms regardless of what the backend was doing, so a cluster of them says only "the client gave up twice", never "this request was slow". The suspicious URL is in the list because it happened to be in flight, and slow-request logs are biased toward whatever was in flight during an outage.

Three cheap falsifications, in order of strength

  1. Check the siblings. SSR loads fan out via Promise.all. If three fetches leave at the same millisecond and only two are in the slow log, the third one's access-log line is a direct measurement of the same code path at the same instant. In one case GET /v/{user}/viewable/null was served 404 in normal time at 01:02:55.226Z while its two siblings burned 15 s — same handler family, same nonexistent key, so the key was not the problem.
  2. Replay against staging. Same commit, no load. If the "hanging" request returns in ~140 ms there, the request is exonerated.
  3. Find the queued valid request — the strongest signal. Search the access log for a request to a known-good URL that completed within a second of the worker respawn. Its duration is queue time, and completion_time − duration bounds when the event loop stopped serving. Concretely: a valid view arrived ~01:02:55 and was served at 01:03:22.427Z, one line after Worker (pid:2080) was sent code 134! — 27.0 s of pure queueing, matching a 27,207 ms entry in the same slow log. That proves the instance was already wedged before the suspicious request arrived, which no amount of correlation could have shown.

Why this matters beyond the one incident

An async worker (uvicorn under gunicorn) only trips WORKER TIMEOUT when the event loop misses its heartbeat, and gunicorn's arbiter checks on a timeout/2 period, so detection lags the block by T..1.5T. Sync (def) FastAPI handlers run in the anyio threadpool and cannot block the loop directly — a saturated threadpool queues requests while heartbeats keep flowing. So WORKER TIMEOUT plus healthchecks still returning 200 points at loop starvation (a long GIL-holding CPU section, or a blocking call on the loop), never at "a DB lookup for a missing row was slow".

Rule

Before writing "X correlates with the outage, X caused it", compute whether X's duration is one of your own timeout constants. If it is, X is downstream of the failure. Timestamps of successful requests are better evidence than durations of failed ones.

No signals yet