When native fetch() became stable in Node.js, engineering teams celebrated. We finally had a standard, zero-dependency HTTP client built directly i...
For further actions, you may consider blocking this person and/or reporting abuse
Really useful article! πΈ
I use APIs generated with hey-api together with TanStack Query, and this reminded me how important it is to actually forward the
signalfrom the query function:If that signal never reaches the underlying
fetch(), cancellation canβt propagate properly.And of course, thatβs separate from adding an explicit timeout and handling response bodies correctly.
Lots of small details to remember with
fetch()in production. πΉGreat point, @nyaomaru! π± Forwarding the caller's
signalis so easy to overlook when using query libraries or generated SDKs.And the nicest part about modern Node/browsers is that you can combine both using
AbortSignal.any([callerSignal, AbortSignal.timeout(5000)])- so you get clean cancellation propagation from TanStack Query plus a hard safety deadline at thefetch()layer!The undrained-body trap is the one I'd never thought about β I always assumed throwing on a failed response was the safe, clean move, and it's exactly what leaves the socket hanging until the garbage collector feels like it. The 50 requests-per-second maths made it real for me: 500 in-flight promises in ten seconds, long before any timeout fires. Do you set the same 5-second deadline everywhere, or do you vary it by upstream β payment calls versus internal services, say?
Throwing immediately on
!res.okfeels like the cleanest textbook code until you see what it does to the connection pool under load!For deadlines, I definitely vary them by upstream tier rather than using a flat 5s everywhere:
The timeout I'd add on top is a deadline for the whole call, not per attempt. Five seconds times three retries with backoff quietly becomes twenty seconds of a handler holding a socket, the same exhaustion in a different hat. Pass the remaining budget into each attempt and the retries can never outlive the user who asked.
Spot on. Per-attempt timeouts multiplied by retries and jittered backoff will quietly blow right past the caller's SLA if there isn't a shared parent deadline.
Propagating a single top-level budget (and combining it with the incoming request's close signal via
AbortSignal.any()) is the only way to guarantee retries don't keep burning sockets after the user has already disconnected. Great addition!Solid writeup. One more behavior worth covering: AbortSignal.timeout() cancels the fetch, but the undici socket underneath doesn't always release right away β we saw socket retention linger after the signal fired under bursty low-concurrency loads. Capping the undici Agent itself (dispatcher option) instead of relying only on per-request signals stopped that for us. Also the TimeoutError vs AbortError distinction is underrated; mixing them up in alerting makes dead upstreams look like client-side cancels. Did you measure connection pool retention before/after, or only request-level latency?
Spot on about tuning the underlying Undici
Agent, Max. Per-requestAbortSignal.timeout()is the quickest safeguard, but configuring a customAgent(headersTimeout,bodyTimeout, andkeepAliveTimeout) viadispatcherorsetGlobalDispatchergives much tighter control over socket lifecycle.To answer your question: We tracked both! Request-level p99 latency was the initial symptom, but watching
undici:Poolstats (connected,free,pending, andrunningsockets) alongside open file descriptors was what actually revealed the socket retention before and after draining the bodies and capping timeouts.The 300s default timeout is the silent killer β a hung upstream looked fine until the socket table filled. Explicit AbortSignal plus always draining error bodies is the floor we'd want before shipping Node fetch to prod.
Spot on. Five minutes is practically infinity for an HTTP handler under load. By the time the default 300s timeout fires, the connection pool is already toast. Explicit
AbortSignal.timeout()+ draining error bodies really should be the standard production baseline. Thanks for reading!the 500 in flight promises after ten seconds at 50 rps is the math people skip. we hit the same thing in a Next.js route handler calling a payment partner, except the cost showed up as serverless duration billing before it showed up as errors. AbortSignal.timeout(8000) plus cancelling the body on every non 2xx cleaned it up. are you setting one timeout for the whole call, or splitting headers and body timeouts through a custom undici dispatcher?
The waterfall audit advice deserves emphasis. On gaming.ir the biggest single win was discovering a third-party widget was rendering above the fold and shifting layout β deferring it to after interaction fixed both CLS and LCP in one move. Measure before optimizing, always.
Nothing beats killing two Core Web Vitals bottlenecks with a single deferral!
Third-party scripts above the fold are almost always the hidden culprit when both LCP and CLS suffer, and a quick waterfall check exposes them immediately. Thanks for sharing that real-world win!