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.