Node.js Native Fetch in Production: Timeouts, Streaming, and Error Handling Patterns
Node.js ships a built-in fetch since v18, but it does not handle timeouts, streaming, or HTTP errors the way production code needs. Here is a zero-dependency HTTP client class that wraps fetch with every safeguard your API calls require.
Your Node.js service calls three external APIs, two internal microservices, and a database proxy. Every call goes through fetch(), the shiny new built-in that replaced node-fetch and axios in your stack.
Then production breaks.
One external API takes 90 seconds to respond because their endpoint is busted. Your service holds 512 concurrent connections open, waiting. Connection pool exhaustion cascades to every downstream call. All your endpoints return 502s. The incident postmortem says “add timeouts.”
You add a timeout. Now requests that fail throw an AbortError. But you do not catch it differently from a network error, and you do not catch a 500 status code at all because fetch() only rejects on network failures. Your error monitoring dashboard lights up with TypeError: fetch failed and AbortError and your catch block handles all of them the same way: log and retry. The retries hit the same timeout. You have amplified the outage.
The built-in fetch() is not broken. It is just raw. It gives you the primitives — AbortSignal, Response.body as a ReadableStream, Response.ok — but it does not compose them into production-safe patterns. That is your job.
Here is how to build a zero-dependency HTTP client that handles timeouts, streaming, retries, response size limits, and error classification correctly.
The three lies of fetch()
The fetch() API looks simple. You call it, you get a response. Three properties of the API are deceptive, and each one will burn you in production if you do not handle it explicitly.
Lie 1: fetch() rejects on network errors only
HTTP 4xx and 5xx responses are not exceptions. They are valid HTTP responses. A 500 error resolves successfully with response.ok === false.
Most team code looks like this:
const response = await fetch('https://api.example.com/data');
const data = await response.json();
// If the server returned 500, data might be HTML or an error page
// response.json() will throw a JSON parse error, not an HTTP error
The fix is trivial but widely missed: check response.ok before reading the body.
Lie 2: Response.json() has no timeout
You set a timeout on fetch() with AbortSignal.timeout(). You think you are safe. But response.json() also reads the body, and if the server starts sending a response but dribbles the bytes at 1 KB/s, the initial fetch completes (headers arrived) but response.json() blocks forever.
The AbortSignal you passed to fetch() is already consumed by the time you call .json(). You need a fresh signal for the body read.
Lie 3: Response.body is a ReadableStream, but you cannot always read it twice
You call response.text() in a logging middleware and then try response.json() in the handler. The body is already consumed. The second call throws a TypeError: body is already consumed.
This is correct per the Fetch spec, but it means you must decide upfront how you will consume the body. If you need to log the raw body and also parse it, clone the response or buffer the stream.
The production client
Here is a self-contained HTTP client that addresses every lie above. It uses zero external dependencies — just what Node.js ships.
// http-client.ts
import { setTimeout as sleep } from 'node:timers/promises';
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface HttpClientOptions {
baseUrl?: string;
timeout?: number; // Total request+body timeout in ms (default: 10_000)
retries?: number; // Number of retry attempts (default: 2)
retryDelay?: number; // Base delay in ms (default: 500, exponential with jitter)
maxBodyBytes?: number; // Maximum response body size (default: 10 MB)
headers?: Record<string, string>;
}
export class HttpError extends Error {
constructor(
public readonly status: number,
public readonly statusText: string,
public readonly body: unknown,
public readonly url: string,
public readonly method: string
) {
super(`HTTP ${status} ${statusText} for ${method} ${url}`);
this.name = 'HttpError';
}
get isClientError(): boolean {
return this.status >= 400 && this.status < 500;
}
get isServerError(): boolean {
return this.status >= 500;
}
get isRetryable(): boolean {
// 429 (rate limit), 5xx (server errors), and connection errors are retryable
return this.isServerError || this.status === 429;
}
}
export class NetworkError extends Error {
constructor(
message: string,
public readonly url: string,
public readonly method: string,
public readonly cause: unknown
) {
super(message);
this.name = 'NetworkError';
}
get isRetryable(): boolean {
return true;
}
}
export class TimeoutError extends Error {
constructor(
public readonly url: string,
public readonly method: string,
public readonly ms: number
) {
super(`Request to ${url} timed out after ${ms}ms`);
this.name = 'TimeoutError';
}
get isRetryable(): boolean {
return true;
}
}
export class BodyTooLargeError extends Error {
constructor(
public readonly url: string,
public readonly maxBytes: number,
public readonly actualBytes: number
) {
super(`Response body from ${url} exceeded ${maxBytes} bytes (was ${actualBytes})`);
this.name = 'BodyTooLargeError';
}
get isRetryable(): boolean {
return false;
}
}
interface RetryConfig {
attempt: number;
maxRetries: number;
baseDelay: number;
}
function calculateDelay({ attempt, maxRetries, baseDelay }: RetryConfig): number {
if (attempt >= maxRetries) return 0;
const exponential = Math.min(baseDelay * Math.pow(2, attempt), 10_000);
// Full-jitter: random between 0 and exponential
return Math.random() * exponential;
}
async function readBody(
response: Response,
maxBytes: number
): Promise<Buffer> {
const reader = response.body?.getReader();
if (!reader) return Buffer.alloc(0);
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new BodyTooLargeError(
response.url,
maxBytes,
totalBytes
);
}
chunks.push(value);
}
return Buffer.concat(chunks);
}
export type HttpClientResponse<T = unknown> = {
data: T;
status: number;
statusText: string;
headers: Headers;
url: string;
};
export class HttpClient {
private readonly baseUrl: string;
private readonly defaultTimeout: number;
private readonly maxRetries: number;
private readonly retryDelay: number;
private readonly maxBodyBytes: number;
private readonly defaultHeaders: Record<string, string>;
constructor(options: HttpClientOptions = {}) {
this.baseUrl = options.baseUrl ?? '';
this.defaultTimeout = options.timeout ?? 10_000;
this.maxRetries = options.retries ?? 2;
this.retryDelay = options.retryDelay ?? 500;
this.maxBodyBytes = options.maxBodyBytes ?? 10 * 1024 * 1024;
this.defaultHeaders = {
'accept': 'application/json',
'user-agent': 'production-http-client/1.0',
...options.headers,
};
}
async request<T = unknown>(
method: HttpMethod,
path: string,
options?: {
body?: unknown;
headers?: Record<string, string>;
timeout?: number;
query?: Record<string, string | string[] | undefined>;
}
): Promise<HttpClientResponse<T>> {
const url = this.buildUrl(path, options?.query);
const timeout = options?.timeout ?? this.defaultTimeout;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await this.executeRequest<T>(url, method, timeout, options);
} catch (err) {
lastError = err as Error;
const shouldRetry = err instanceof HttpError
? err.isRetryable
: err instanceof NetworkError || err instanceof TimeoutError;
if (!shouldRetry || attempt >= this.maxRetries) {
throw err;
}
const delay = calculateDelay({
attempt,
maxRetries: this.maxRetries,
baseDelay: this.retryDelay,
});
await sleep(delay);
}
}
throw lastError;
}
private async executeRequest<T>(
url: string,
method: HttpMethod,
timeout: number,
options?: {
body?: unknown;
headers?: Record<string, string>;
}
): Promise<HttpClientResponse<T>> {
const abortController = new AbortController();
// Timeout timer that fires once for the whole operation
const timeoutHandle = setTimeout(() => abortController.abort(), timeout);
try {
const headers: Record<string, string> = {
...this.defaultHeaders,
...options?.headers,
};
if (options?.body !== undefined) {
headers['content-type'] = 'application/json';
}
let response: Response;
try {
response = await fetch(url, {
method,
headers,
body: options?.body !== undefined
? JSON.stringify(options.body)
: undefined,
signal: abortController.signal,
});
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
throw new TimeoutError(url, method, timeout);
}
throw new NetworkError(
`Failed to connect to ${url}: ${(err as Error).message}`,
url,
method,
err
);
}
// Read the body with a size limit
const rawBody = await readBody(response, this.maxBodyBytes);
let data: T;
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json') && rawBody.length > 0) {
data = JSON.parse(rawBody.toString('utf-8')) as T;
} else {
data = rawBody.toString('utf-8') as unknown as T;
}
if (!response.ok) {
throw new HttpError(
response.status,
response.statusText,
data,
url,
method
);
}
return {
data,
status: response.status,
statusText: response.statusText,
headers: response.headers,
url: response.url,
};
} finally {
clearTimeout(timeoutHandle);
}
}
private buildUrl(path: string, query?: Record<string, string | string[] | undefined>): string {
const base = this.baseUrl ? `${this.baseUrl}${path.startsWith('/') ? '' : '/'}${path}` : path;
if (!query || Object.keys(query).length === 0) return base;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const v of value) {
params.append(key, v);
}
} else {
params.set(key, value);
}
}
const qs = params.toString();
return qs ? `${base}${base.includes('?') ? '&' : '?'}${qs}` : base;
}
// Convenience methods
get<T = unknown>(path: string, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
return this.request<T>('GET', path, options);
}
post<T = unknown>(path: string, body?: unknown, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
return this.request<T>('POST', path, { ...options, body });
}
put<T = unknown>(path: string, body?: unknown, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
return this.request<T>('PUT', path, { ...options, body });
}
patch<T = unknown>(path: string, body?: unknown, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
return this.request<T>('PATCH', path, { ...options, body });
}
delete<T = unknown>(path: string, options?: Parameters<HttpClient['request']>[2]): Promise<HttpClientResponse<T>> {
return this.request<T>('DELETE', path, options);
}
}
The executeRequest method fixes all three lies. It uses AbortController to enforce a total timeout that covers both the header receive and the body read phase. It catches AbortError from the signal and translates it into a typed TimeoutError. It reads the body as a stream with a byte limit so a misbehaving server cannot exhaust your memory. It checks response.ok before returning and throws a structured HttpError if the status code indicates failure.
Streaming responses without memory bloat
Some APIs return large collections as NDJSON (newline-delimited JSON) streams. Loading the entire response into memory defeats the purpose of streaming. Here is how to process a stream row by row using ReadableStream with the same timeout and size protections.
// stream-client.ts
import { createInterface } from 'node:readline';
export async function* streamLines(
url: string,
options: {
timeout?: number;
maxBytes?: number;
signal?: AbortSignal;
} = {}
): AsyncGenerator<string> {
const { timeout = 30_000, maxBytes = 100 * 1024 * 1024 } = options;
const abortController = new AbortController();
const timeoutHandle = setTimeout(() => abortController.abort(), timeout);
// Link external signal so the caller can cancel too
if (options.signal) {
options.signal.addEventListener('abort', () => abortController.abort());
}
try {
const response = await fetch(url, {
signal: abortController.signal,
});
if (!response.ok) {
throw new HttpError(
response.status,
response.statusText,
null,
url,
'GET'
);
}
const reader = response.body?.getReader();
if (!reader) return;
let totalBytes = 0;
// Wrap the ReadableStream into a Node.js Readable so we can use readline
const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
controller.error(new BodyTooLargeError(url, maxBytes, totalBytes));
return;
}
controller.enqueue(value);
},
});
const nodeStream = Readable.fromWeb(stream);
const rl = createInterface({ input: nodeStream, crlfDelay: Infinity });
for await (const line of rl) {
yield line;
}
} finally {
clearTimeout(timeoutHandle);
}
}
This generator yields one line at a time. Processing 100,000 rows of NDJSON uses almost no heap because you never hold the full response body. The BodyTooLargeError and timeout guards still apply.
Testing the client
A production HTTP client needs tests that simulate every failure mode. Here is a test suite using Node’s built-in test runner (covered in another post on this site) and a test server.
// http-client.test.ts
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { HttpClient, HttpError, TimeoutError, BodyTooLargeError } from './http-client';
describe('HttpClient', () => {
let server;
let baseUrl: string;
before(async () => {
server = createServer((req, res) => {
if (req.url === '/ok') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} else if (req.url === '/slow') {
// Hold the connection open past any reasonable timeout
setTimeout(() => {
res.writeHead(200);
res.end('too late');
}, 60_000);
} else if (req.url === '/500') {
res.writeHead(500, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'internal error' }));
} else if (req.url === '/429') {
res.writeHead(429, { 'retry-after': '1' });
res.end();
} else if (req.url === '/big') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end('x'.repeat(5 * 1024 * 1024)); // 5 MB
} else if (req.url === '/empty') {
res.writeHead(200);
res.end();
} else if (req.url === '/not-json') {
res.writeHead(200, { 'content-type': 'text/plain' });
res.end('plain text response');
}
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const addr = server.address();
baseUrl = `http://localhost:${addr.port}`;
});
after(() => server.close());
it('makes a successful GET request', async () => {
const client = new HttpClient({ baseUrl });
const result = await client.get('/ok');
assert.equal(result.status, 200);
assert.deepEqual(result.data, { status: 'ok' });
});
it('throws HttpError on 500', async () => {
const client = new HttpClient({ baseUrl });
await assert.rejects(
() => client.get('/500'),
(err: HttpError) => {
assert.equal(err.status, 500);
assert.equal(err.isRetryable, true);
return true;
}
);
});
it('throws HttpError on 429 but marks it retryable', async () => {
const client = new HttpClient({ baseUrl });
await assert.rejects(
() => client.get('/429'),
(err: HttpError) => {
assert.equal(err.status, 429);
assert.equal(err.isRetryable, true);
return true;
}
);
});
it('times out on slow responses', async () => {
const client = new HttpClient({ baseUrl, timeout: 500, retries: 0 });
await assert.rejects(
() => client.get('/slow'),
(err: TimeoutError) => {
assert.ok(err.message.includes('timed out'));
assert.equal(err.isRetryable, true);
return true;
}
);
});
it('limits response body size', async () => {
const client = new HttpClient({ baseUrl, maxBodyBytes: 1024, retries: 0 });
await assert.rejects(
() => client.get('/big'),
(err: BodyTooLargeError) => {
assert.ok(err.message.includes('exceeded'));
return true;
}
);
});
it('handles non-JSON responses', async () => {
const client = new HttpClient({ baseUrl });
const result = await client.get('/not-json');
assert.equal(result.status, 200);
assert.equal(result.data, 'plain text response');
});
it('handles empty response bodies', async () => {
const client = new HttpClient({ baseUrl });
const result = await client.get('/empty');
assert.equal(result.status, 200);
assert.equal(result.data, '');
});
it('retries on 500 and succeeds on third attempt', async () => {
let attempts = 0;
const retryServer = createServer((_req, res) => {
attempts++;
if (attempts < 3) {
res.writeHead(500);
res.end();
} else {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ success: true }));
}
});
await new Promise<void>((resolve) => retryServer.listen(0, resolve));
const addr = retryServer.address();
const url = `http://localhost:${addr.port}`;
const client = new HttpClient({ baseUrl: url, retries: 3, retryDelay: 10 });
const result = await client.get('/');
assert.equal(result.data.success, true);
assert.equal(attempts, 3);
retryServer.close();
});
});
The test server simulates every failure mode: slow connections, 5xx errors, rate limiting, oversized bodies, non-JSON responses, and empty responses. The suite verifies that the client classifies each error correctly and that retries work.
Integrating with your service
Here is how the client fits into a real service that calls a payment provider and a notification service.
// services/payment.ts
import { HttpClient, HttpError } from '../lib/http-client';
interface ChargeRequest {
amount: number;
currency: string;
source: string;
idempotencyKey: string;
}
interface ChargeResponse {
id: string;
status: 'succeeded' | 'failed';
amount: number;
}
const paymentClient = new HttpClient({
baseUrl: process.env.PAYMENT_API_URL,
timeout: 5_000, // Payments need fast feedback
retries: 1, // One retry on network error or 5xx
retryDelay: 200,
headers: {
'authorization': `Bearer ${process.env.PAYMENT_API_KEY}`,
},
});
export async function chargeCustomer(params: ChargeRequest): Promise<ChargeResponse> {
try {
const response = await paymentClient.post<ChargeResponse>('/charges', {
...params,
idempotencyKey: params.idempotencyKey,
});
return response.data;
} catch (err) {
if (err instanceof HttpError && err.status === 402) {
// Payment declined by the provider -- business logic, not a retry
throw new PaymentDeclinedError(params.source);
}
if (err instanceof TimeoutError) {
// Payment may or may not have succeeded. Check with idempotency.
throw new PaymentAmbiguousError(params.idempotencyKey);
}
throw err;
}
}
// services/notifications.ts
import { HttpClient } from '../lib/http-client';
const notifyClient = new HttpClient({
baseUrl: process.env.NOTIFICATION_API_URL,
timeout: 2_000, // Notifications are fire-and-forget
retries: 2,
retryDelay: 100,
});
export async function sendWelcomeEmail(userId: string, email: string): Promise<void> {
// Fire and forget: log failures but do not fail the request
try {
await notifyClient.post('/emails', {
template: 'welcome',
to: email,
userId,
});
} catch {
console.error(`Failed to send welcome email to user ${userId}`);
}
}
Each downstream service gets its own client instance with per-service timeouts, retry counts, and headers. The payment client uses a short timeout and minimal retries because payment operations must fail fast. The notification client uses a longer retry budget because notifications are non-critical and benefit from best-effort delivery.
The practical takeaway
The built-in fetch() API is a solid primitive, but it is not production-ready out of the box. You need explicit handling for every failure mode: timeouts that cover the full request-and-body lifecycle, response size limits, typed errors that distinguish network failures from HTTP errors from timeouts, retry logic with jitter, and per-service configuration.
The HttpClient class above gives you all of that in about 200 lines with zero dependencies. Drop it into any Node.js 18+ project and you get typed, predictable HTTP calls that handle every failure mode a production service encounters.
Before your next API integration, run through this checklist:
- Every
fetch()call has a timeout that covers both header receive and body read. - HTTP 4xx and 5xx status codes are thrown as typed errors, not swallowed.
- Response body reads have an explicit byte limit.
- Retries use full-jitter backoff, not fixed delays.
- Distinguishable error types (network, timeout, HTTP client, HTTP server, body too large) allow caller-specific handling.
- Streaming responses enforce both a timeout and a byte limit.
Your downstream APIs will fail. Your HttpClient should handle the failure without taking down your service.
A note from Yojji
Building resilient service-to-service communication is a core engineering challenge that separates production-grade infrastructure from prototypes. Yojji’s engineering teams apply these same HTTP client patterns when integrating payment gateways, notification providers, and internal microservices for clients, ensuring that a single upstream failure does not cascade through the entire system. Yojji is an international custom software development company founded in 2016, specializing in the JavaScript ecosystem, cloud platforms, and full-cycle product delivery from discovery through deployment.