Your fetch call has no timeout, and neither does the outage it causes
Por Seaworthy · 15 de agosto de 2026
Photo by Kevin Ache on Unsplash
Here's a request handler an AI assistant will happily generate on the first try:
app.get('/api/weather', async (req, res) => {
const response = await fetch('https://weather-provider.example.com/v1/current')
const data = await response.json()
res.json(data)
})
It works. It's short. It's also missing a timeout, which means that if weather-provider.example.com ever stops responding instead of returning an error, this handler hangs indefinitely waiting for a response that's never coming.
One slow dependency becomes your whole outage
A single hung request isn't the problem. Node's event loop can juggle plenty of pending I/O without breaking a sweat. The problem is what happens under load: every request to /api/weather opens a new connection and waits, and if the upstream provider is degraded rather than fully down, every one of those requests can pile up waiting on the same slow response. Each hung request holds onto a socket, a slot in whatever connection pool is being used, and often a piece of application-level state until it resolves or the client gives up.
If your server has a concurrency limit, whether that's an explicit worker pool, a database connection pool, or just the operating system's file descriptor limit, a wave of hung outbound requests can exhaust it. At that point, requests that have nothing to do with the weather endpoint start failing too, because there's no capacity left to serve them. A dependency you don't control has just taken down parts of your app that don't even call it.
Fetch's default is to wait forever
This is worth being explicit about because it surprises people: the fetch API has no default timeout. Neither does axios out of the box. Both will wait as long as the underlying connection stays open, which in the case of a server that accepts the TCP connection but never sends a response, is indefinitely.
The fix is a few lines using AbortController:
async function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), ms)
try {
return await fetch(url, { signal: controller.signal })
} finally {
clearTimeout(timeout)
}
}
Now a slow upstream fails after five seconds instead of never. That failure needs to be handled, obviously, probably with a try/catch around the call site and a sensible error response, but a handled failure after five seconds is a completely different incident than an unbounded hang that slowly starves the rest of the app.
Why AI-generated code tends to skip this
Timeouts are exactly the kind of code that only earns its keep during an incident. In a demo, a code sample, or a quick test against a fast local server, the version without a timeout works identically to the version with one, because nothing is ever actually slow. The assistant is optimizing for a working example, and a working example doesn't need defensive code that never triggers during testing. The gap only shows up in production, against a real dependency, on a bad day.
The same reasoning applies to any outbound call your server makes on behalf of an incoming request: third-party APIs, internal microservices, even database queries in some drivers. If it's not yours and it's not guaranteed to respond quickly, it needs a bound on how long you'll wait for it.
A timeout on its own can still cause a retry storm
Adding a timeout is the first fix, not the last one. If the code around the call retries automatically on failure, and plenty of generated HTTP client wrappers do, a five-second timeout on a degraded upstream just means every request now fails and retries every five seconds instead of hanging forever. Under enough concurrent load, that's still enough repeated connection attempts to keep the struggling upstream from ever recovering, which is the same outcome as the hang, just with extra steps and a misleading appearance of resilience.
Pairing a timeout with a short backoff and a cap on retry attempts, or better, a circuit breaker that stops calling a dependency for a cooldown period after repeated failures, closes that gap. None of this needs to be elaborate. A retry count of two or three with a growing delay between attempts, and giving up cleanly after that, covers the overwhelming majority of real transient failures without turning a timeout into a self-inflicted request flood.
What to check for
Grep for fetch( and axios. calls that don't pass a signal or timeout option. Anywhere an incoming request handler waits on an outbound one is a candidate for this, and the fix is small enough to apply everywhere it's missing rather than triaging which ones matter most.
Seaworthy flags outbound HTTP calls made without a timeout, since a dependency that only fails slowly is more dangerous than one that fails fast.
Este artículo fue generado por IA y resume fuentes públicamente disponibles.
Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.
Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.