I had a worker pulling large exports out of a slow upstream. It had a three-minute budget. Some jobs ran past ten and never errored. They just sat there.
The request looked like it had a deadline:
await axios.post(url, payload, { timeout: 180000 })
It doesn't. That option is a socket-idle timeout. It fires when no bytes have moved for the given interval, not when the call has taken too long in total.
Why a slow response never trips it
Picture a 20MB response arriving slowly — a big database export, a report the upstream is still assembling, anything streaming through a tunnel. Bytes keep arriving. Every chunk resets the idle timer. The connection is never idle for three minutes, so the timeout you configured can never fire, and the worker is held for exactly as long as the upstream feels like taking.
This is the worst shape of bug: it behaves correctly in every test, because tests use fast responses or fully-stalled ones. It only appears against a real upstream that is slow but alive — and that is the normal case in production, not the exceptional one.
Node's own http.request behaves the same way, and it isn't unique to
JavaScript. Most HTTP clients default to idle or connect timeouts rather than a wall-clock
cap, because that's what the socket layer naturally exposes.
The fix: own the deadline yourself
If you want a real limit, you have to hold the clock:
const abort = new AbortController();
const deadline = setTimeout(() => abort.abort(), budgetMs);
try {
const res = await axios.post(url, payload, {
timeout: budgetMs, // still useful: catches a truly dead socket
signal: abort.signal, // this is the actual deadline
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
return res.data;
} finally {
clearTimeout(deadline); // or you leak a timer per call
}
Keep both. They catch different failures: timeout handles a socket that has
gone silent, the abort signal handles one that is alive but far too slow. And put the
clearTimeout in a finally — on the happy path a forgotten timer
per request is a slow leak that only shows up under load.
The second one: a timer that could not fire
Same system, stranger failure. A 26MB XML response came back fine, then the worker went silent for minutes. No timeout, no error.
Parsing it was synchronous. A synchronous parse of something that large blocks the event loop, and the abort timer needed that same event loop to fire. The request had a perfectly good deadline. The deadline was queued behind the work it was supposed to interrupt.
That's worth sitting with, because it breaks an assumption most of us carry around: a timeout does not protect you from CPU-bound work in the same thread. It only protects you from waiting.
Guard the size, not the time
You can't time-limit something that owns the loop, so check before you start:
const MAX_PARSE_BYTES = Number(process.env.MAX_PARSE_BYTES) || 24 * 1024 * 1024;
if (Buffer.byteLength(raw) > MAX_PARSE_BYTES) {
const err = new Error('response too large to parse in one pass');
err.isParseLimit = true; // a flag, not a message to string-match on
throw err;
}
Then catch that flag and fall back to something bounded — in my case, fetching the record ids first and then pulling them in windows of 500. Slower, but it finishes. A 28.9MB export that previously failed outright now completes.
The typed flag matters more than it looks. The obvious version is
err.message.includes('too large'), which survives exactly until someone
rewords the string, and then fails silently in the direction of "no fallback".
What I took from it
Read what your client's timeout actually measures before you trust it as a deadline. If you need a wall-clock limit, hold the clock yourself. And remember that a timeout only covers waiting — for work that blocks, the guard has to come before the work starts, because afterwards there is nothing left to run it.