Learn How to Add a Timeout to Every Server-Side HTTP Call in Node.js
AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.
Introduction
This article demonstrates how to put a timeout on every HTTP request your server-side code makes, and why a missing one can fail in a way that is very hard to read.
This one cost us a build. Not a slow build — a failed build, with an error that pointed at the wrong thing entirely.
The Problem
Our Angular app is fully prerendered. At build time, Angular renders every route to static HTML, and the blog routes fetch their data from the live production API while doing it.
One day the build started failing. The error talked about route extraction timing out. Routes that had nothing to do with the blog were failing too.
The actual cause was a single fetch call.
const res = await fetch(API_URL);
That line has no timeout. Node's fetch has no default timeout at all. If the connection stalls — in our case a TLS renegotiation hang against the live site — that promise never settles. Not after a minute. Not ever.
And the try/catch around it never runs, because nothing threw. A hang is not an error. It is the absence of one.
The failure is worse than it sounds. Angular's prerender runs routes in worker processes. One stuck request blocked past Angular's own route-extraction deadline, which killed the whole worker — taking down every unrelated route it was working on. So a stalled blog request produced a build error about route extraction.
That is why this is worth an article. The symptom appeared nowhere near the cause.
Features of the fix
- A hang becomes an ordinary error your existing
catchalready handles. - No new dependency.
- Applies to server-side rendering only, so browser behaviour is unchanged.
With the following steps, you can protect your own build.
- Bound the direct
fetchcalls. - Bound every request the framework makes.
Bound the Direct fetch Calls
Node 18 and later have AbortSignal.timeout(), which is one argument.
const res = await fetch(API_URL, { signal: AbortSignal.timeout(10_000) });
That is the whole change. After ten seconds the request aborts and fetch rejects with a TimeoutError, which the surrounding try/catch handles like any other failure.
Before AbortSignal.timeout existed you had to write this yourself.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
const res = await fetch(url, { signal: controller.signal });
return res;
} finally {
clearTimeout(timer); // easy to forget, and it leaks if you do
}
Six lines, one of which people forget, versus one. Use the built-in.
My suggestion: treat a fetch without a signal in server-side code the same way you treat a missing await. It is not a style issue. It is a bug that only appears when something upstream is unhealthy, which is exactly when you least want a second problem.
Bound Every Request the Framework Makes
Fixing one call is not enough. Every component that loads data during prerendering makes its own request through Angular's HttpClient, and none of those go through your fetch call.
The fix is an interceptor that applies only on the server.
// frontend/src/app/services/ssr-timeout.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { PLATFORM_ID, inject } from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { timeout } from 'rxjs/operators';
const SSR_REQUEST_TIMEOUT_MS = 10_000;
export const ssrTimeoutInterceptor: HttpInterceptorFn = (req, next) => {
if (!isPlatformServer(inject(PLATFORM_ID))) return next(req);
return next(req).pipe(timeout(SSR_REQUEST_TIMEOUT_MS));
};
Register it once.
provideHttpClient(withFetch(), withInterceptors([ssrTimeoutInterceptor])),
Three things about this that are worth understanding.
isPlatformServer limits it to the server. In a browser, a slow request is the user's problem and they can see it happening — a spinner, a stalled page, a refresh button. During a build there is no user and no refresh. So the timeout applies where the hang is unrecoverable and stays out of the way where it is not.
RxJS timeout() does the work. Angular's HttpClient is Observable-based, so the operator is the natural fit. It errors the stream after the deadline, which flows into each caller's existing error: callback.
The error is one your components already handle. Nothing new to write. Every component that subscribes with an error: handler now sees a timeout the same way it would see a 500. A hang became an ordinary failure, and the build continues with that one route rendering its error state instead of the whole worker dying.
Pick the Number Honestly
Ten seconds is our number. It is not a magic value.
Choose it from what the call actually does. If your API normally answers in 200ms, ten seconds is already fifty times slower than normal — anything past that is not slow, it is broken. Setting it to sixty seconds means your build waits a minute before failing, which helps nobody.
Set it too low and you fail on a genuinely slow but working response. The tell is whether failures correlate with load. Start generous and tighten it once you can see real timings.
The General Rule
Every outbound HTTP call from server-side code needs a deadline.
Not just the ones that seem risky. The call that hung for us was a plain GET to our own API, on infrastructure we control, that had worked every day for months.
The reason to be strict is the failure mode. An error is loud and lands in the code path you wrote for it. A hang is silent, and it holds whatever it is running inside — a worker, a request handler, a connection from the pool — until something else eventually gives up and reports a problem somewhere unrelated.
This is the same reasoning behind the captcha verifier failing closed elsewhere in this codebase. Decide in advance what happens when a dependency stops answering, and make that decision explicit, because the default is usually "wait forever".
Conclusion
In this article we learned that Node's fetch never times out on its own, that a stalled build-time request can kill an entire Angular prerender worker and report the failure as something else, and how to fix both cases — AbortSignal.timeout() for direct calls, and a server-only RxJS timeout interceptor for everything HttpClient does.
Two small changes. Together they turn the worst kind of failure — silent and misattributed — into one your error handling already knows how to deal with.
Reference
Comments
Be the first to comment.