Mesrai
Back to blog
// essayTechnical Deep Dive

Missing Timeout on Outbound HTTP: The Hanging-Worker Bug

Real PR catch: fetch with no timeout — hung upstream froze workers, cascading outage. AbortSignal fix.

Mesrai TeamAugust 28, 20268 min read

An integration calling an FX rate provider without a timeout. When the provider slowed down, every worker calling this hung for 5+ minutes waiting on a TCP read. Workers piled up; the service became unresponsive.

The vulnerable diff

tsintegrations/exchange-rate.ts
// integrations/exchange-rate.ts
export async function getRate(base: string, quote: string): Promise<number> {
  // BUG: no timeout — hangs forever on slow upstream
  const r = await fetch(`https://fx.example/rate?from=${base}&to=${quote}`);
  return (await r.json()).rate;
}

What is wrong

Node's `fetch` (since 18) does NOT have a default timeout. Neither does the older `http.request`. Slow or hung upstreams hold the connection open until the OS TCP keepalive timeout — typically minutes. Every concurrent caller is blocked for the same duration. Worker pools exhaust; latency spikes; if your service is single-threaded (Node), the whole event loop fills with pending fetches. The fix is to always pass `AbortSignal.timeout(ms)` or an explicit AbortController with a deadline.

The attack

Outage timeline:

textexploit
t+0:00 — Upstream FX provider degrades. Response time 30s.
t+0:01 — Workers calling getRate now hang for 30s each.
t+0:05 — Worker pool full. New requests queue.
t+0:10 — Queue overflows. Requests time out at the LB.
t+0:30 — Pager. SRE finds the hung fetch in stack trace.
t+0:45 — Hotfix: add AbortSignal.timeout(5_000). Deploy.

Same shape under any outbound call without timeout.

Mesrai's review comment

textreview-comment.txt
mesraipilot · Bot · reviewed 1 min ago

[mesrai] [code-review] [Performance] [CWE-400] [critical]

`fetch` without timeout. Hung upstreams freeze workers indefinitely.

Always pass AbortSignal.timeout:

  const r = await fetch(url, { signal: AbortSignal.timeout(5_000) });

Or with retry + backoff:

  async function safeFetch(url: string) {
    for (let i = 0; i < 3; i++) {
      try {
        return await fetch(url, { signal: AbortSignal.timeout(5_000) });
      } catch (err) {
        if (err.name === "TimeoutError" && i < 2) {
          await sleep(2 ** i * 200);
          continue;
        }
        throw err;
      }
    }
    throw new Error("unreachable");
  }

For axios: `{ timeout: 5000 }`.
For http.get: pass `timeout` option + listen for 'timeout' event.

The fix

tsintegrations/exchange-rate.ts (fixed)
// integrations/exchange-rate.ts — fixed
export async function getRate(base: string, quote: string): Promise<number> {
  const r = await fetch(
    `https://fx.example/rate?from=${base}&to=${quote}`,
    { signal: AbortSignal.timeout(5_000) }
  );
  if (!r.ok) throw new Error(`FX provider ${r.status}`);
  return (await r.json()).rate;
}

5-second timeout. Upstream slowdown surfaces as a clean TimeoutError that the caller can handle (retry, fallback, surface as 503).

Why human review missed it

Missing timeout is the most common cause of cascading outages in microservices. Mesrai catches every outbound HTTP call without an explicit timeout.

Related rules + further reading

Mesrai rule pack: performance/http-timeout-required — flags fetch/axios/http.get without timeout.

CWE-400 — Uncontrolled Resource Consumption.

Common cause of cascade failures across services.

Takeaway

Every outbound HTTP needs a timeout. AbortSignal.timeout for fetch. Mesrai catches every untimed call.

// try

See it on your next PR.

Free for individuals. Install in two minutes. Mesrai reviews every commit.