Fix: TypeError: fetch failed (Node.js)
Part of: JavaScript & TypeScript Errors
Quick Answer
Fix Node.js 'TypeError: fetch failed' by reading error.cause to find what really failed: DNS, a refused connection, a timeout, or a bad certificate.
The message itself tells you nothing, and that used to drive me up the wall until I learned to stop reading the first line and go straight to error.cause. Every real reason your request failed, DNS, a refused connection, a timeout, a bad certificate, lives one level down, and Node’s fetch just wraps all of it in the same flat TypeError: fetch failed regardless of which one it actually was. This covers how to get at the real error fast, what each cause means, and the two gotchas that catch people even after they find the cause: fetch ignoring your proxy settings by default, and Docker networking breaking DNS resolution in ways that look identical to a typo.
TypeError: fetch failed
Your Node.js script or server throws this when an outbound request fails for any network-level reason:
TypeError: fetch failed
at node:internal/deps/undici/undici:13185:13
at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
[cause]: Error: getaddrinfo ENOTFOUND api.example.com
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:120:26) {
errno: -3008,
code: 'ENOTFOUND',
syscall: 'getaddrinfo',
hostname: 'api.example.com'
}Node prints the [cause] block by default in modern versions, but plenty of frameworks, test runners, and logging setups swallow it and show you only the top line:
TypeError: fetch failedThat bare line is the same error with the useful part stripped away. The fix always starts the same way: get the cause back.
Why the message is useless on its own
Node’s global fetch is built on undici, and it went from experimental to stable in Node 21 (available since Node 18, but carrying an experimental warning until then). fetch is a web standard API, and the standard does not define separate error types for “DNS failed” versus “connection refused” versus “timed out”, browsers do not expose that detail to web pages for security reasons. Node’s implementation inherited that same generic-error shape even though a server-side script has every right to know exactly what went wrong. So fetch throws one TypeError with the message fetch failed, and attaches the actual, specific, useful error as error.cause.
If your error handling only logs error.message, you throw away the one property that tells you anything:
// USELESS: only logs "fetch failed"
try {
await fetch('https://api.example.com/data');
} catch (err) {
console.error(err.message);
}// USEFUL: logs the real cause
try {
await fetch('https://api.example.com/data');
} catch (err) {
console.error(err.message, err.cause);
}Read error.cause to find the real problem
The cause is a plain Node.js system error with a code property. That code tells you exactly what happened:
error.cause.code | What it means |
|---|---|
ENOTFOUND | DNS lookup failed, the hostname does not resolve |
ECONNREFUSED | The host resolved, but nothing is listening on that port |
ECONNRESET | The connection was established, then closed abruptly by the other side |
UND_ERR_CONNECT_TIMEOUT | The connection did not complete within the connect timeout |
UND_ERR_HEADERS_TIMEOUT | The connection succeeded, but response headers did not arrive in time |
UND_ERR_BODY_TIMEOUT | Headers arrived, but the response body did not finish in time |
DEPTH_ZERO_SELF_SIGNED_CERT / SELF_SIGNED_CERT_IN_CHAIN / UNABLE_TO_VERIFY_LEAF_SIGNATURE | TLS certificate validation failed |
EAI_AGAIN | Temporary DNS resolution failure, often a flaky or unreachable resolver |
Fix ENOTFOUND: DNS resolution failed
This means the hostname itself did not resolve, before any connection was attempted. The usual causes:
- A typo in the URL or hostname, especially one built from a template string or environment variable.
- The environment variable pointing at the API is unset or empty in this environment, so you end up fetching a malformed or relative-looking URL.
- You are inside Docker and using
localhostto reach another container. Containers do not sharelocalhostwith each other or with the host by default; use the service name fromdocker-compose.ymlinstead:
// WRONG inside a container: localhost refers to the container itself
await fetch('http://localhost:5000/api');
// CORRECT: use the Compose service name as the hostname
await fetch('http://api-service:5000/api');- No network access at all in the current environment, a sandboxed CI runner or an offline build step trying to reach the internet.
Fix ECONNREFUSED: nothing is listening
DNS resolved fine, but the target host actively refused the connection, usually because no process is listening on that port, or a firewall rejected it outright. Confirm something is actually listening:
lsof -i :5000 # macOS / Linuxnetstat -ano | findstr :5000 # WindowsIf you expected your own server to be there and it is not, see Fix: listen EADDRINUSE for the reverse problem (something else already on the port) and Fix: ECONNREFUSED connect localhost for a deeper walkthrough of this exact cause on the client side.
Fix timeouts: UND_ERR_CONNECT_TIMEOUT and friends
fetch has no timeout by default; a hung connection waits indefinitely unless you set one. Use AbortSignal.timeout():
const response = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000), // aborts after 5 seconds
});An abort fires a different error entirely (DOMException: The operation was aborted, or AbortError), not fetch failed. So if you still see UND_ERR_CONNECT_TIMEOUT instead, undici’s own connect timeout, 10 seconds by default, expired before your AbortSignal did. This happens whenever the signal’s timeout is set longer than 10 seconds for a genuinely slow-to-connect host: the internal connect timeout is the shorter of the two, so it fires first. Raise it via a custom dispatcher when the target is just slow to establish a connection, not actually unreachable:
const { Agent, setGlobalDispatcher } = require('undici');
setGlobalDispatcher(new Agent({ connectTimeout: 30_000 }));A CPU-bound script can trigger a false UND_ERR_CONNECT_TIMEOUT. undici’s timeout tracking depends on its internal timers firing on schedule, and a busy event loop, a heavy synchronous loop, a large JSON parse, expensive middleware, delays those timers. I’ve chased a “the API is down” alert for an hour before finding the actual cause was our own request handler blocking the event loop long enough for undici’s timer to fire on a connection that had completed just fine. If this only happens under load, profile the event loop before assuming the network is at fault.
Fix certificate errors
DEPTH_ZERO_SELF_SIGNED_CERT, SELF_SIGNED_CERT_IN_CHAIN, and UNABLE_TO_VERIFY_LEAF_SIGNATURE all mean the same underlying thing: the server’s TLS certificate was not signed by something Node’s trust store recognizes. This happens constantly against internal APIs, staging environments, and corporate proxies that intercept HTTPS with their own certificate authority.
The correct fix is telling Node about the extra CA, not disabling verification:
NODE_EXTRA_CA_CERTS=/path/to/internal-ca.pem node app.jsDo not reach for NODE_TLS_REJECT_UNAUTHORIZED=0. It disables certificate verification for every request the process makes, not just the one you are debugging, which opens the door to man-in-the-middle attacks on every outbound connection. Treat it as a last-resort, local-only diagnostic step you remove immediately, never something that ships.
The proxy gotcha: fetch ignores HTTP_PROXY by default
This is the one that costs people the most time, because it contradicts how most other HTTP tools behave. curl, axios with certain adapters, and many other libraries read HTTP_PROXY/HTTPS_PROXY environment variables automatically. Node’s built-in fetch does not, by default, on any version. Behind a corporate proxy, every outbound fetch call fails, usually as ENOTFOUND or a timeout, for a host that is perfectly reachable, and the environment variables that “should” fix it are silently ignored.
On Node 22.21.0+ or 24.0.0+, opt in with an environment flag, no code changes required:
NODE_USE_ENV_PROXY=1 node app.jsOn earlier Node versions, wire up undici’s proxy-aware agent manually and register it as the global dispatcher:
const { EnvHttpProxyAgent, setGlobalDispatcher } = require('undici');
if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY) {
setGlobalDispatcher(new EnvHttpProxyAgent());
}EnvHttpProxyAgent is still marked experimental as of this writing and prints a one-time warning; that is expected and not a sign anything is broken.
Still not working?
IPv6 resolution picks a dead address. Node’s DNS resolution can return an IPv6 address for a host that only actually answers on IPv4 (or the reverse), producing a connect failure that looks identical to the host being down. I’ve seen this exact symptom on a home network where the ISP advertised broken IPv6 connectivity, so every fetch to a dual-stack host timed out even though the same URL loaded fine in a browser that fell back to IPv4 automatically. Force IPv4 resolution to test the theory:
const { Agent, setGlobalDispatcher } = require('undici');
setGlobalDispatcher(new Agent({ connect: { family: 4 } }));A VPN or corporate firewall is intercepting TLS. If the certificate error only happens on the corporate network and never on a home connection, the proxy is almost certainly re-signing HTTPS traffic with its own CA. This is the same fix as the certificate section above, add that CA via NODE_EXTRA_CA_CERTS rather than disabling verification.
Fails in tests but not in the running app. Jest and some other test environments run in a sandbox with restricted or mocked networking, or with a library like nock or msw intercepting requests. If a test hits a real endpoint unintentionally, it can fail with ENOTFOUND or a timeout that has nothing to do with your application code. See Fix: Jest Async Test Timeout if the failure is time-related specifically.
You manually installed a different undici version than Node bundles, and pass it as a custom per-request dispatcher. Node embeds its own undici version internally, and a mismatched major version’s dispatcher interface can be incompatible with what the built-in fetch expects, producing fetch failed for reasons that have nothing to do with the network at all. Check process.versions for the bundled version and align your installed undici package with it, or avoid passing a custom dispatcher unless you specifically need one.
The target API works in a browser but not from Node. Server-side fetch is not subject to CORS, so a CORS-shaped error is not the cause here; a browser and a Node process do differ in DNS behavior, default headers, and TLS trust stores, so re-check the cause code rather than assuming it is the same problem you have seen in frontend code.
For related networking errors, see Fix: ECONNREFUSED connect localhost, Fix: listen EADDRINUSE, Fix: Docker port is already allocated, and Fix: Next.js API route not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: listen EADDRINUSE: address already in use (Node.js)
Fix Node.js 'listen EADDRINUSE: address already in use': find and kill the process on the port (macOS, Linux, Windows), or run your server on a different port.
Fix: Error: spawn ENOENT (Node.js child_process)
Fix Node.js 'Error: spawn ENOENT' from child_process.spawn(): PATH resolution, the Windows .cmd/.bat problem, and why shell: true is now discouraged (DEP0190).
Fix: Cannot set headers after they are sent to the client
Fix Node.js/Express 'Cannot set headers after they are sent to the client', usually a missing return, double next(), or a duplicate response.
Fix: Error [ERR_REQUIRE_ESM]: require() of ES Module not supported
Fix Node.js 'Error [ERR_REQUIRE_ESM]: require() of ES Module not supported' by checking your Node version, using dynamic import(), or converting to ESM.