Native fetch is the HTTP client to reach for in any Node.js project starting today. Axios cold-starts 4x slower (I measured), node-fetch is 20% slower on throughput, and raw undici serializes sequential requests in a way that fetch’s wrapper transparently avoids. The fetch case is “we’re on Node 22, we want no extra dependency, and we already know the API from the browser.”
The minimal fetch is four lines.
const response = await fetch('https://hacker-news.firebaseio.com/v0/item/1.json');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const item = await response.json();
console.log(item.title, item.score);Fetch never rejects on 5xx, does not time out on a hung socket, only streams when you touch .body, and needs an undici dispatcher to route through a proxy.
Common Takeaways:
response.okmust be checked manually.fetchonly rejects on network failure, not on HTTP 4xx or 5xx status codes.- Fetch has no default timeout. Use
AbortSignal.timeout(ms)on every request that touches an external host. - Sequential requests are almost always the wrong default.
Promise.allmoved a 20-request workload from 1418 ms to 81 ms in my benchmark.
You need Node 22+ and basic async/await.
When to use fetch (vs axios, node-fetch, undici)
Native fetch is the default in Node 22, and there is a specific reason to reach for each alternative. I benchmarked all four against a local Express server, running each in a fresh child process for cold-start accuracy.
| Client | Cold-start | 100 sequential | 100 concurrent | Peak RSS | Reach for it when |
|---|---|---|---|---|---|
| native fetch | 109 ms | 187 ms | 150 ms | 58 MB | Default. Node 18+, no extra dependency. |
| axios | 469 ms | 186 ms | 148 ms | 58 MB | Interceptors, transforms, upload progress, or a codebase already on it. |
| node-fetch@3 | 167 ms | 226 ms | 198 ms | 61 MB | Node 17 or earlier only. |
| undici raw | 378 ms | 1186 ms | 94 ms | 54 MB | High-concurrency infra with an explicit Agent. |
Median of 5 runs on Node 22.18.0. Cold-start is import + first request in a fresh process.
Axios cold-starts 4x slower than native fetch (469 vs 109 ms), which matters on Lambda or Cloud Run where every invocation pays the cold-start tax. Undici raw is the opposite, slow on sequential (1186 ms because each request call goes through the pool’s serialization step) and fastest on concurrent (94 ms, because that same pool works in your favor when you fire 100 requests through Promise.all).
Node version compatibility
Native fetch was added as an experimental global in Node 18 (April 2022) and became stable in Node 21 (October 2023). On Node 22, the current LTS in 2026, there is no flag, no import, no polyfill.
For codebases still on Node 17 or earlier, install node-fetch@3. Native fetch and node-fetch@3 accept the same URL-first, options-second signature, so the switch is import-only when you upgrade Node.
Migrating from node-fetch
If your codebase runs on Node 18+ but still imports node-fetch, the import removal is a two-line change. Remove the import and drop the dependency from package.json. Beyond that, existing catch handlers and streaming code may need updates. Native fetch throws TypeError with .cause on network failures where node-fetch throws FetchError with a .code property (ENOTFOUND, ECONNREFUSED), so catch handlers reading error.code need to check error.cause?.code. And node-fetch@2 returned a Node stream you could pipe to a file writer directly, where native fetch returns a Web Streams ReadableStream that needs pipeline() from node:stream/promises to write to disk.
Fetch vs axios
Native fetch handles scraping workloads on Node 22 without the axios cold-start cost. Reach for axios only when you specifically need interceptors, request/response transforms, or upload progress events. Those three are the gaps that keep axios worth its runtime on codebases that use them. For everything else, native fetch matches or beats axios on cold-start, RAM, and throughput.
Your first fetch (GET)
fetch(url) returns a Promise that resolves to a Response object, not to the body. To read the body, call one more method on the response that also returns a Promise. Two awaits, always.
const response = await fetch('https://api.github.com/repos/nodejs/node');
const data = await response.json();
console.log(`${data.full_name}: ${data.stargazers_count} stars`);If you write const data = await fetch(...), data is a Response object, not the JSON body. Reading data.stargazers_count returns undefined. This is the single most common fetch mistake in Node, and the biggest cognitive shift coming from axios, which unwraps the body into response.data for you.
.json() vs .text() vs .arrayBuffer()
The Response object exposes four body-reading methods that all consume the stream once. Pick the one that matches your payload.
.json() parses the body as JSON and throws SyntaxError if the body isn’t valid JSON. Use it for REST APIs, GraphQL, and any endpoint that returns Content-Type: application/json.
.text() returns the body as a UTF-8 string. Use it for HTML scraping (feed the string into cheerio or a regex), plain-text APIs, and CSV or XML responses.
.arrayBuffer() returns the raw bytes as an ArrayBuffer. Wrap it with Buffer.from() to get a Node Buffer for writing to disk or piping into image processing libraries.
import { writeFile } from 'node:fs/promises';
const response = await fetch('https://books.toscrape.com/media/cache/2c/da/2cdad67c44b002e7ead0cc35693c0e8b.jpg');
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile('cover.jpg', buffer);
console.log(`Saved ${buffer.length} bytes`);The fourth method, .blob(), exists for browser compatibility, but I don’t use it in Node, since Buffer is the ergonomic choice on the server.
You can only call one body method per response. The stream is consumed on first read, so calling response.json() followed by response.text() throws TypeError: Body is unusable: Body has already been read. If you don’t know which method to use, call .text() and JSON.parse() manually, which gives you the raw body for debugging when parsing fails.
Checking response.ok
Native fetch does not throw on HTTP errors. A 404, 500, or 503 all resolve the Promise normally, and .json() might still succeed if the server returned a JSON error body. You have to check response.ok (or response.status) yourself.
const response = await fetch('https://api.github.com/repos/nodejs/does-not-exist');
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const data = await response.json();response.ok is true when status is in the 200-299 range. This is another divergence from axios, which throws on any non-2xx by default. The safe default is a response.ok check on the first line after await fetch(), either as a throw (bubble up to a retry layer) or a branch (log and skip).
POST, PUT, PATCH, DELETE
Every non-GET request is the same shape as GET, plus a method option and, usually, a body. What changes between methods is how the server interprets the body, not how you serialize it.
POST with JSON
The default POST for REST APIs, GraphQL endpoints, and any modern backend. Set the method, the Content-Type header, and stringify the body.
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'foo',
body: 'bar',
userId: 1,
}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(await response.json());If you pass the object directly as body without JSON.stringify(), fetch coerces it to a string with .toString() and sends [object Object]. If you set the body but forget Content-Type: application/json, some servers respond with 415 (Unsupported Media Type), and others accept it but fail to parse. Both errors return an HTTP response, so response.ok catches the missing-header case, but the missing-stringify one only surfaces in the server’s response body.
POST with form-urlencoded
For OAuth token endpoints, older PHP backends, and any endpoint that expects application/x-www-form-urlencoded. Wrap the payload in URLSearchParams and fetch sets the header automatically.
const params = new URLSearchParams();
params.set('grant_type', 'client_credentials');
params.set('client_id', 'abc123');
params.set('client_secret', 'secret456');
const response = await fetch('https://httpbin.org/post', {
method: 'POST',
body: params,
});
const data = await response.json();
console.log(data.form);No headers block is needed. Native fetch sees URLSearchParams and sets Content-Type: application/x-www-form-urlencoded;charset=UTF-8 for you.
POST with multipart (FormData)
For file uploads and forms that mix files with text fields. Use FormData, and fetch sets the boundary and Content-Type automatically. Streaming large files is covered later in the file uploads section.
const form = new FormData();
form.set('title', 'Cover image');
form.set('file', new Blob(['fake image bytes']), 'cover.jpg');
const response = await fetch('https://httpbin.org/post', {
method: 'POST',
body: form,
});
const data = await response.json();
console.log(data.files, data.form);Never set Content-Type yourself when the body is FormData. The boundary token is generated per request and lives in the header. Overriding it breaks the multipart parser on the server.
PUT, PATCH, DELETE
The remaining methods follow the same pattern with a different method value. Scrapers use them rarely. They’re mostly relevant if you’re building an API client on top of fetch.
// PATCH: partial update, JSON body
await fetch('https://jsonplaceholder.typicode.com/posts/1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'updated' }),
});
// DELETE: no body
await fetch('https://jsonplaceholder.typicode.com/posts/1', {
method: 'DELETE',
});PUT is a full-resource replace and takes the same JSON body shape as POST. DELETE typically has no body, though some non-conforming APIs expect one, in which case pass body: JSON.stringify(...).
Headers, cookies, auth
Every fetch call takes a headers option that accepts a plain object, a Headers instance, or an array of tuples. Header names are case-insensitive on the wire, so authorization and Authorization reach the server identically.
const response = await fetch('https://api.github.com/user', {
headers: {
'Authorization': 'Bearer ghp_abc123...',
'Accept': 'application/vnd.github+json',
'User-Agent': 'my-scraper/1.0',
},
});A plain object is fine for a fixed set of headers. A Headers instance is better when you’re merging defaults with per-request overrides, because it handles case normalization and multi-value headers like Set-Cookie.
const url = 'https://api.github.com/user';
const token = process.env.GITHUB_TOKEN;
const defaults = new Headers({ 'User-Agent': 'my-scraper/1.0' });
defaults.append('Accept', 'application/json');
defaults.set('Authorization', `Bearer ${token}`);
const response = await fetch(url, { headers: defaults });Auth patterns
Bearer tokens are the default for OAuth and most modern APIs. Basic auth still shows up on HTTP proxies and legacy admin endpoints. API keys usually live in a vendor-specific header like X-API-Key.
// Bearer (OAuth, GitHub)
await fetch(url, {
headers: { 'Authorization': `Bearer ${token}` },
});
// Basic (HTTP proxies, legacy APIs)
const creds = Buffer.from(`${user}:${pass}`).toString('base64');
await fetch(url, {
headers: { 'Authorization': `Basic ${creds}` },
});
// API key (Anthropic, AWS API Gateway, vendor APIs)
await fetch(url, {
headers: { 'X-API-Key': process.env.API_KEY },
});Fetch is stateless, so pass credentials on every call or wrap fetch in a thin function that injects them.
User-Agent
Node’s default UA is Node.js/22.18.0 (or the equivalent for your version). Many production sites either 403 that UA or serve degraded content, so scrapers set an explicit browser-shaped UA on every request.
const CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36';
const response = await fetch('https://httpbin.org/headers', {
headers: { 'User-Agent': CHROME_UA },
});
console.log((await response.json()).headers['User-Agent']);Rotate UA per session, not per request. Request-level rotation flags you to Cloudflare, Akamai, and other fingerprinting stacks because real browsers keep the same UA for their whole lifetime.
Cookies
Native fetch does not have a cookie jar. It reads Set-Cookie from responses but never sends anything back automatically. If your scraper needs to maintain a session (login, add to cart, checkout), you handle cookies yourself.
const login = await fetch('https://httpbin.org/cookies/set?session=abc123', {
redirect: 'manual',
});
const cookies = login.headers.getSetCookie();
console.log(cookies);getSetCookie() (Node 20+) returns raw Set-Cookie header values as an array. Before Node 20, response.headers.get('set-cookie') returned only the first cookie when a response had multiple, which silently dropped data on any login flow with more than one cookie.
For a follow-up request, parse the array, keep only the name=value prefix of each entry, and join with ;.
const cookieHeader = cookies.map(c => c.split(';')[0]).join('; ');
await fetch('https://httpbin.org/cookies', {
headers: { 'Cookie': cookieHeader },
});That works for simple cases. For anything with expiry tracking, domain scoping, or multi-host flows (typical login sessions), use tough-cookie or a fetch wrapper like fetch-cookie that manages a real jar.
Timeouts, retries, and cancellation in Node.js fetch
Fetch has no default timeout, so a hung TCP connection or a slow server can block your scraper indefinitely.
AbortSignal.timeout()
The modern way (Node 17.3+) is one line. Pass AbortSignal.timeout(ms) on every request that touches an external host.
try {
const response = await fetch('https://httpbin.org/delay/10', {
signal: AbortSignal.timeout(3000),
});
console.log(response.status);
} catch (error) {
if (error.name === 'TimeoutError') {
console.log('request timed out');
}
}The signal wires an internal timer that fires after ms milliseconds, and fetch throws a DOMException with name === 'TimeoutError' when it fires. I measured this against a slow endpoint at four target timeouts.
| Target (ms) | Actual (ms) | Overhead |
|---|---|---|
| 100 | 110 | +10 |
| 500 | 515 | +15 |
| 2000 | 2014 | +14 |
| 5000 | 5015 | +15 |
Median of 5 runs on Node 22.18.0. The overhead is roughly 10-15 ms regardless of target, dominated by libuv timer resolution and the abort event loop tick. Accurate enough for any real-world timeout budget.
AbortController for combined signals
AbortSignal.timeout() covers the timeout case. Reach for AbortController when you need to combine timeout with a user-cancel signal or a parent-request signal (rate limiter, batch queue).
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
return await response.json();
} catch (error) {
if (error.name === 'AbortError') {
console.log('aborted (timeout or manual)');
}
throw error;
}The manual path throws AbortError, not TimeoutError, which matters when you branch on error type. AbortSignal.any([signal1, signal2]) (Node 20+) merges multiple signals into one, which replaces most manual AbortController glue when you’re combining a user signal with a timeout.
Retry with exponential backoff
Real scrapers hit transient failures like rate limits, 502s from a load balancer, or ECONNRESET mid-response. None of them mean giving up. A small retry wrapper handles the common case in about 20 lines.
async function fetchWithRetry(url, options = {}, maxAttempts = 3) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const attemptSignal = options.signal
? AbortSignal.any([options.signal, AbortSignal.timeout(10000)])
: AbortSignal.timeout(10000);
const response = await fetch(url, {
...options,
signal: attemptSignal,
});
if (response.status >= 500 || response.status === 429) {
throw new Error(`Retriable HTTP ${response.status}`);
}
return response;
} catch (error) {
lastError = error;
// Caller aborted (user cancel, rate limiter). Do not retry.
if (error.name === 'AbortError') throw error;
if (attempt === maxAttempts) break;
const delay = Math.min(1000 * 2 ** (attempt - 1), 10000);
const jitter = Math.random() * 250;
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}
throw lastError;
}Exponential backoff (1s, 2s, 4s, 8s and so on) plus jitter avoids the thundering herd on the third attempt when 50 workers all hit the same 502 at once. Cap the backoff at 10 seconds. Beyond that, the outer job queue is a better place to schedule the retry. The AbortError filter matters when the caller passes their own signal for rate limiting or user cancel. TimeoutError from the per-attempt timeout still retries, but a caller’s abort propagates out immediately. AbortSignal.any() (Node 20+) merges the two so per-attempt timeouts still respect the caller’s cancel.
What to retry
Retry on network errors, timeouts, 429 (Too Many Requests), and 5xx server errors. Never retry on 400, 401, 403, 404, or 422. Those are permanent failures that a retry hits again with the same result. The attempt === maxAttempts break in the loop above is what stops the retry storm when a real permanent failure looks like a transient one.
File uploads and downloads
Both directions use the same fetch API from earlier sections, but the memory profile diverges when files get large. Uploads via FormData scale linearly with file size. Downloads have two very different paths, and picking the wrong one is how a 500 MB PDF turns into an OOM crash on a 1 GB container.
Uploading a file
FormData in Node handles a file the same way the browser does. Wrap the payload in a Blob, append it with a filename, and let fetch build the multipart body.
import { readFile } from 'node:fs/promises';
const buffer = await readFile('./product.jpg');
const form = new FormData();
form.set('title', 'Product photo');
form.set('file', new Blob([buffer]), 'product.jpg');
const response = await fetch('https://httpbin.org/post', {
method: 'POST',
body: form,
});
const data = await response.json();
console.log(`uploaded ${data.files.file ? 'ok' : 'failed'}`);The new Blob([buffer]) wrap is the Node-specific bit. In the browser you’d have a File from a form input. On the server you build the Blob yourself.
Downloading into memory
Loading the whole response into a buffer works for small files and dies for anything above your available RAM.
// Fine for JSON, HTML, small assets. Wrong for large files.
import { writeFile } from 'node:fs/promises';
const response = await fetch('https://example.com/large.pdf');
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile('./large.pdf', buffer);arrayBuffer() reads the entire body into a single allocation before the write ever starts. On a 100 MB file, peak RSS ballooned to 352 MB, roughly 3.5x the file size, because the extra is fetch’s internal chunk buffering plus the copy from ArrayBuffer to Buffer. Multiply that by a 1 GB file and you push past the default 4 GB V8 heap limit before the write completes.
Downloading with streams
Pipe response.body directly to a write stream. Peak memory stays around the fetch chunk size (64 KB), regardless of file size.
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
const response = await fetch('https://example.com/large.pdf');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await pipeline(
Readable.fromWeb(response.body),
createWriteStream('./large.pdf')
);
console.log('download complete');response.body is a Web Streams ReadableStream, and Node’s pipeline() expects a Node Readable, so Readable.fromWeb() bridges them. pipeline() handles error propagation, cleanup, and back-pressure. If the destination write stream stalls, fetch pauses reading from the socket.
I measured both approaches on a 100 MB file.
| Method | Elapsed | Peak RSS |
|---|---|---|
response.arrayBuffer() | 642 ms | 352 MB |
response.body piped to disk | 633 ms | 86 MB |
Median of 3 runs. Time is essentially identical because both approaches wait on the network, not on memory allocation. The streaming version uses 4x less memory. On a 1 GB file, arrayBuffer() would push past 3 GB RSS and OOM on a small container.
Proxies with fetch in Node.js
Native fetch supports HTTP proxies through undici’s ProxyAgent, passed through the dispatcher option. undici ships with Node 18+ and does not need a separate install.
import { ProxyAgent } from 'undici';
const proxyAgent = new ProxyAgent('http://user:pass@proxy.example.com:8080');
const response = await fetch('https://httpbin.org/ip', {
dispatcher: proxyAgent,
});
console.log(await response.json());The proxy URL supports HTTP CONNECT tunneling, credentials embedded in the URL, and both HTTP and HTTPS destinations through the same agent.
Per-request vs global proxy
dispatcher on a single fetch call applies to that request only. For a session where every request goes through the same proxy, call setGlobalDispatcher() once at startup instead of passing dispatcher every time.
import { setGlobalDispatcher, ProxyAgent } from 'undici';
setGlobalDispatcher(new ProxyAgent('http://user:pass@proxy.example.com:8080'));
const a = await fetch('https://httpbin.org/ip');
const b = await fetch('https://httpbin.org/headers');
// both go through the proxyReset with setGlobalDispatcher(new Agent()) when you need to bypass the proxy for internal calls.
Rotating proxies
For rotating pools, create one ProxyAgent per proxy at startup and pick one per request. Do not create a new agent on every request. The constructor sets up a keep-alive connection pool, and throwing it away per request destroys the reuse benefit and drops throughput.
import { ProxyAgent } from 'undici';
const pool = [
'http://user:pass@proxy1.example.com:8080',
'http://user:pass@proxy2.example.com:8080',
'http://user:pass@proxy3.example.com:8080',
].map(url => new ProxyAgent(url));
function randomProxy() {
return pool[Math.floor(Math.random() * pool.length)];
}
const response = await fetch('https://httpbin.org/ip', {
dispatcher: randomProxy(),
});SOCKS proxies
ProxyAgent speaks HTTP CONNECT only. For SOCKS4 or SOCKS5, native fetch needs a custom undici connector, which is more setup than most scrapers need. If SOCKS is a hard requirement, pair node-fetch with socks-proxy-agent. HTTP proxies (with CONNECT for HTTPS) cover the vast majority of scraping workloads.
Managed proxy alternative
Running your own residential or datacenter pool is a maintenance job with health checks, ban detection, and region rotation on top of the fetch code. Managed scraping APIs like HasData’s Web Scraping API replace that layer with a single endpoint that handles proxy rotation, retries, and anti-bot bypass. Worth pricing out before spending weeks on a rotation service you’ll maintain long-term.
Speed and connection reuse
For a Node scraper hitting many URLs, running requests in parallel gives the biggest speedup available. Connection reuse and dispatcher tuning still matter, but they are second-order effects compared to concurrency.
Sequential vs concurrent
I ran the same 20-URL workload sequentially and with Promise.all against a local mock server that responds after 50 ms per request (typical remote API RTT).
| Mode | Total (ms) | Per-request avg (ms) | Speedup |
|---|---|---|---|
| Sequential for-loop | 1418 | 70.9 | 1.00x |
Promise.all (unlimited) | 81 | 4.0 | 17.5x |
Promise.all capped at 5 | 266 | 13.3 | 5.3x |
Median of 3 runs. Unlimited Promise.all fires all 20 requests at once. The capped version keeps 5 in-flight, which matters when hammering a remote host triggers rate limits.
// Sequential (slowest)
async function fetchAllSequential(urls) {
const results = [];
for (const url of urls) {
results.push(await fetch(url).then(r => r.json()));
}
return results;
}
// Promise.all is 17.5x faster on this workload
async function fetchAllConcurrent(urls) {
return Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);
}Bounded concurrency
Unlimited Promise.all is fine for 20 URLs, dangerous for 20,000. It opens as many concurrent TCP connections as the OS allows and floods the target. Cap concurrent requests with undici’s Agent connections option, which sets the pool size per host.
import { Agent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new Agent({
connections: 10, // max concurrent connections per host
pipelining: 1, // HTTP/1.1 pipelining (0 disables, 1 default)
}));
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.json()))
);undici queues requests beyond connections and drains as sockets free up. That gives you Promise.all ergonomics with hard-capped parallelism per host, which is what most rate-limited APIs actually need.
keepAlive
Node 22 keeps TCP connections alive by default. The second request to the same host reuses the socket from the first, skipping DNS lookup, TCP handshake, and TLS negotiation. On a remote HTTPS endpoint that saves 50-200 ms per request that a fresh connection would pay.
You almost never turn keepAlive off. The edge cases where you would (a memory-leaking proxy in the middle, a misconfigured load balancer that recycles idle connections badly) are debug scenarios, not production defaults.
High-throughput tuning
For scrapers that fetch tens of thousands of URLs, tune the undici Agent explicitly.
import { Agent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new Agent({
connections: 128, // per host
pipelining: 10, // HTTP/1.1 pipelining
keepAliveTimeout: 60_000, // keep sockets 60s idle
keepAliveMaxTimeout: 120_000,
bodyTimeout: 30_000,
headersTimeout: 10_000,
}));connections: 128 gives you 128 concurrent sockets per remote host. pipelining: 10 lets undici send 10 requests on the same socket without waiting for the response, which cuts latency on servers that support it (many production hosts do not, so start at 1 and raise while measuring). keepAliveTimeout is how long an idle socket stays open before undici closes it. bodyTimeout and headersTimeout are per-request budgets separate from AbortSignal.timeout(), working as undici’s own safety net.
Common errors and fixes
Node 22 fetch errors are terse and often just point at a wrapper. The real cause lives in error.cause. Every catch block that handles fetch should log both.
try {
const response = await fetch(url);
return await response.json();
} catch (error) {
console.error(error.name, error.message, error.cause);
throw error;
}fetch failed
Node throws TypeError: fetch failed for any low-level network error and puts the real error in error.cause. If your catch handler logs only error.message, you lose the underlying ECONNRESET, ENOTFOUND, or ETIMEDOUT code that tells you what actually broke.
try {
await fetch('https://this-host-does-not-exist-abc123.example');
} catch (error) {
console.log(error.message); // 'fetch failed'
console.log(error.cause?.code); // 'ENOTFOUND'
console.log(error.cause?.message); // 'getaddrinfo ENOTFOUND ...'
}Always log error.cause alongside error.message.
Body is unusable
The full error is TypeError: Body is unusable: Body has already been read. Calling .json(), .text(), or .arrayBuffer() twice on the same Response triggers it. The body is a one-shot stream, and the second read has nothing to return. This happens when logging middleware reads the body before forwarding the response, or when the same response object is passed to two consumers that both try to read it.
Clone the response before reading if the caller needs the body too.
const response = await fetch(url);
const clone = response.clone();
const text = await clone.text(); // for logging
const data = await response.json(); // for the callerCloning duplicates the body stream in memory, so avoid it on large responses. For a 100 MB download, clone() doubles the peak RSS.
fetch is not defined
The full error is ReferenceError: fetch is not defined. You’re on Node 16.14 or earlier, or on Node 16.15-17.4 without the --experimental-fetch flag. Upgrade to Node 18 or newer, where fetch is global by default. If stuck on ancient Node, install node-fetch@3 (ESM) or node-fetch@2 (CommonJS) and import it explicitly at the top of every file.
unable to verify the first certificate
Your fetch target has a self-signed or expired TLS certificate. Common on internal dev servers and staging environments.
For production, fix the cert. For local development against a known-safe endpoint, bypass verification per-request via an undici Agent.
import { Agent } from 'undici';
const insecureAgent = new Agent({
connect: { rejectUnauthorized: false },
});
const response = await fetch('https://self-signed.local', {
dispatcher: insecureAgent,
});Never do this in production. And never set the global NODE_TLS_REJECT_UNAUTHORIZED=0 environment variable, which disables cert verification for the entire process. A real security audit will flag it.
If you own the certificate authority (internal corporate CA, self-hosted PKI), point Node at your CA bundle with NODE_EXTRA_CA_CERTS=/path/to/ca.pem. That path keeps full cert verification enabled and trusts your CA, which is what you actually want for production.
ECONNRESET and hung sockets
The server closed the socket mid-request, or a proxy killed the idle keep-alive connection. Usually transient. error.cause.code === 'ECONNRESET' is retriable, so run it through the retry wrapper from earlier.
Persistent ECONNRESET from the same host usually means the server rate-limits by connection count. Lower connections on your undici Agent, add jitter between requests, or spread the load across multiple source IPs.
Character encoding
.text() decodes the body as UTF-8. If the server sends a page in Windows-1251, Shift_JIS, or another legacy encoding, .text() returns mojibake. Read the raw bytes and decode explicitly with TextDecoder.
const response = await fetch('https://legacy-site.example');
const buffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || '';
const charset = contentType.match(/charset=["']?([^;"'\s]+)/i)?.[1] || 'utf-8';
const html = new TextDecoder(charset).decode(buffer);FAQ
Is fetch built into Node.js?
Yes. Fetch has been a global since Node 18 (behind an experimental flag until Node 21). On Node 22 there’s no import, no flag, no polyfill.
Do I still need node-fetch?
Only on Node 17 or earlier. If you’re on Node 18+, native fetch handles every common case. The one exception is a CommonJS codebase locked to node-fetch@2 where the migration cost outweighs the modest cold-start and code-clarity wins.
Why does fetch not throw on 404?
An HTTP error response is still a response, so fetch resolves the Promise and hands you the response to inspect. Your code must check response.ok (200-299 range) or response.status explicitly. Axios throws on non-2xx by default, which is why porting axios code to fetch often ships bugs that treat 4xx errors as success.
What’s the default timeout for fetch in Node.js?
There isn’t one. A fetch call without an AbortSignal will wait forever if the server hangs the connection. Pass AbortSignal.timeout(ms) on every outbound request.
Can I use fetch with a proxy in Node.js?
Yes. Native fetch supports HTTP proxies through undici’s ProxyAgent, passed via the dispatcher option. SOCKS proxies need extra glue and are usually easier via node-fetch + socks-proxy-agent.
How do I upload a file with fetch?
Wrap the file bytes in a Blob, append it to FormData with a filename, and pass the FormData as the body. Fetch generates the multipart boundary automatically, so don’t set Content-Type yourself.
import { readFile } from 'node:fs/promises';
const buffer = await readFile('./image.jpg');
const form = new FormData();
form.set('file', new Blob([buffer]), 'image.jpg');
await fetch('https://api.example.com/upload', {
method: 'POST',
body: form,
});Conclusion
Native fetch on Node 22 covers the ninety-percent case for scrapers and API clients. The stack that handles most workloads is native fetch plus AbortSignal.timeout() on every request, response.ok after every await, a small retry wrapper, and pipeline() for large downloads. Don’t pull in axios out of habit. On cold-start alone it’s 4x slower than native fetch, and the ergonomic wins (auto JSON parsing, .data, interceptors) rarely justify the runtime cost.
The alternatives earn their runtime cost for specific needs. Axios is the migration path when you need interceptors, request/response transforms, or upload progress. undici raw wins on peak concurrent throughput if you’re ready to tune the Agent yourself. A managed scraping API like HasData’s Web Scraping API replaces the whole proxy-rotation and ban-detection layer that you’d otherwise build on top of fetch. Each choice trades off something (code complexity, runtime cost, per-request price) for something else. Default to native fetch until the trade-off actually pays.


