Node 26 fetch failed: Fixing the Undici 8 Mismatch Behind Crashes, Empty Headers and Ignored Proxies
You moved a service from Node 24 to Node 26, the tests passed, and the first call to a third-party API dies with TypeError: fetch failed. Or nothing throws at all: the corporate proxy you set up is skipped, or a POST comes back with zero headers and a body full of gzip bytes. In almost every node 26 fetch failed report I read, the network was fine. Two copies of undici in the same process disagree with each other.
Node 26 bundles undici 8, and its built-in fetch() can't drive dispatchers from npm undici 5 or 6 (invalid onError method). It also ignores setGlobalDispatcher() from undici below 7.27.0. Run npm ls undici, then upgrade the npm copy to undici 7.27.1 or newer (or 8.x), import fetch from the same undici as your ProxyAgent, or drop ProxyAgent and start Node with NODE_USE_ENV_PROXY=1.
Why does fetch break after upgrading to Node 26?
It breaks because the global fetch() in Node 26 speaks the undici 8 handler API, while the Agent or ProxyAgent you pass to it often comes from an older undici on npm. Node.js 26.0.0 shipped on 2026-05-05 with undici 8.0.2 inside it, and the bundled copy keeps moving within the 26.x line. On the Node 26.12.0 binary I tested, process.versions.undici printed 8.11.2.
That bundled copy is separate from whatever sits in node_modules. The undici docs are blunt about it: installing undici from npm does not replace the built-in globals (see undici vs built-in fetch). So a typical app ends up with two HTTP stacks in one process, and they only cooperate if their contracts match.
Two contracts changed in undici 8. The first is the dispatch handler shape. The official v7 to v8 migration guide lists the renames:
| Legacy handler (undici 5/6/7) | undici 8 handler |
|---|---|
onConnect | onRequestStart |
onHeaders | onResponseStart |
onData | onResponseData |
onComplete | onResponseEnd |
onError | onResponseError |
onUpgrade | onRequestUpgrade |
Node 26's fetch builds a handler with the new names and hands it to your dispatcher. An undici 5 or 6 Agent validates the handler, looks for onError, doesn't find it, and throws. Fetch wraps that as fetch failed.
The second change is where the global dispatcher lives. undici 8 stores it under Symbol.for('undici.globalDispatcher.2'). Older copies write to Symbol.for('undici.globalDispatcher.1'). Node 26's fetch reads .2, so a setGlobalDispatcher() call from an old copy lands somewhere nobody looks. No error, no warning.
Node 26 fetch failed: the three symptoms and their error strings
One root cause shows up as three bugs that look unrelated. Which one you see narrows down the undici version you're carrying.
1. TypeError: fetch failed with "invalid onError method"
This is the loud one. You pass dispatcher: new ProxyAgent(...) (or an Agent, or EnvHttpProxyAgent) from npm undici 5.x or 6.x, and every request throws before a single byte leaves the machine:
TypeError: fetch failed
[cause]: InvalidArgumentError: invalid onError method
code: 'UND_ERR_INVALID_ARG'
Plenty of popular tools hit this. Vercel CLI 59.23.1 bundles undici@5.29.0 and prints An unexpected error occurred in whoami: TypeError: fetch failed, but only when HTTP_PROXY, HTTPS_PROXY or ALL_PROXY is set. Corepack crashed the same way behind a proxy until 0.35.0. Redocly CLI surfaced it as "Login failed. Please check your credentials and try again.", which sends you hunting for a password problem that doesn't exist.
2. setGlobalDispatcher not working on Node 26 (proxy silently ignored)
This one is quieter and, in my opinion, nastier. You call setGlobalDispatcher(new ProxyAgent(proxyUrl)) at startup using npm undici 5.x, 6.x, or any 7.x below 7.27.0. The call returns normally. Then Node 26's fetch ignores it and connects directly.
On a laptop with open internet, everything still works and you never notice. Inside a locked-down network the direct connection fails with DNS or connect errors like getaddrinfo ENOTFOUND, and those look like a network problem instead of a code problem. The fix landed in undici 7.27.0 through PR #5319, which makes setGlobalDispatcher() write the same dispatcher to both symbols.
3. Node 26 fetch returns empty headers and a gzipped body
The third symptom has no error at all. With an Agent from undici 7.x below 7.27.1 passed to the built-in fetch, a POST comes back with status 200, but res.headers is empty. Since content-encoding is gone too, fetch doesn't decompress the body, and res.json() chokes on raw gzip bytes. That's undici issue #5341, "Undici v8 drops headers when using Undici v7 Agent", fixed by PR #5345 in undici 7.27.1.
The maintainer couldn't reproduce it on v8 main, so I half expected it to be gone on a recent Node 26. It wasn't: Node 26.12.0 still drops them, as the lab output below shows.
Find the old undici before you change anything
I run these checks in this order, because bumping undici in your own package.json does nothing if the bad copy lives inside an SDK.
First, confirm what your runtime bundles. This is the version the global fetch really uses:
node -v
node -p process.versions.undici
Second, print the real cause. fetch failed on its own tells you nothing. The useful part is always on err.cause, and a lot of CLIs swallow it. If you own the code, log it explicitly (for where this logging belongs in a bigger app, see my Node.js error handling patterns):
try {
const res = await fetch(url, options);
// ...
} catch (err) {
console.error(err.message, '|', err.cause?.name, err.cause?.code, err.cause?.message);
throw err;
}
An UND_ERR_INVALID_ARG with "invalid on... method" points to the handler mismatch. ENOTFOUND or ECONNREFUSED to a host that should have gone through your proxy points to the ignored global dispatcher.
Third, find which package drags in the old copy. Your own package.json may say undici 7.30, while an SDK three levels down ships its own 6.x:
npm ls undici
npm ls can't see a copy that a CLI bundled into its own dist/ folder, which is exactly how Vercel CLI carries undici 5.29.0. For those, I search for undici's error code outside the real undici package. I didn't have such a CLI in the lab, so treat this as a starting point, not a tested recipe:
grep -rl "UND_ERR_INVALID_ARG" node_modules --include=*.js | grep -v "node_modules/undici/"
I reproduced all three on a Node 26.12.0 binary with a fake proxy
Most of what's online about this is one GitHub issue per tool, so I rebuilt all three failures from scratch. My setup: a Linux x64 box with system Node 25.2.1, plus the official Node 26.12.0 tarball extracted into a lab folder (no global install). Plain HTTP only, no TLS, no Docker. The first thing I logged was the bundled undici on both runtimes:
N26=$PWD/node-v26.12.0-linux-x64/bin/node
$N26 -v; $N26 -p process.versions.undici; PATH=$(dirname $N26):$PATH npm -v
echo ---
node -v; node -p process.versions.undici
v26.12.0
8.11.2
11.20.0
---
v25.2.1
7.16.0
Then I installed five undici versions side by side with npm aliases (u5@npm:undici@5.29.0, u6@npm:undici@6.29.1, u726@npm:undici@7.26.0, u7@npm:undici@7.30.1, u8@npm:undici@8.12.3), and later added 7.27.0 and 7.27.1 to pin down exactly where each fix landed.
The trick that made the silent bypass visible: a tiny forward proxy on 127.0.0.1:4001 that maps a made-up host, lab.internal, to a local origin server on port 4000. That host doesn't exist in DNS. A request that really goes through the proxy succeeds. A request that sneaks around it fails with ENOTFOUND lab.internal. A small run.sh wrapper printed the proxy and origin log lines each run caused, so an empty proxy log: line means the proxy never saw the request.
The silent bypass
This is the script I used for the global dispatcher test. It's the exact pattern a lot of "fetch behind a proxy" guides still recommend:
// Built-in fetch after setGlobalDispatcher() from an npm undici copy.
// Usage: node t2-global.mjs u726
import { header, show } from './report.mjs';
const pkg = process.argv[2];
const { ProxyAgent, setGlobalDispatcher } = await import(pkg);
const { version } = (await import(`${pkg}/package.json`, { with: { type: 'json' } })).default;
header(`t2 ${pkg} = undici@${version}`);
setGlobalDispatcher(new ProxyAgent('http://127.0.0.1:4001'));
console.log(' setGlobalDispatcher() returned without error');
await show(fetch('http://lab.internal:4000/t2'));
...
$ node scripts/t2-global.mjs u6
[t2 u6 = undici@6.29.1] node v26.12.0, bundled undici 8.11.2
setGlobalDispatcher() returned without error
FAIL TypeError: fetch failed
cause: Error: getaddrinfo ENOTFOUND lab.internal (code: ENOTFOUND)
proxy log:
origin log:
$ node scripts/t2-global.mjs u726
[t2 u726 = undici@7.26.0] node v26.12.0, bundled undici 8.11.2
setGlobalDispatcher() returned without error
FAIL TypeError: fetch failed
cause: Error: getaddrinfo ENOTFOUND lab.internal (code: ENOTFOUND)
proxy log:
origin log:
$ node scripts/t2-global.mjs u7
[t2 u7 = undici@7.30.1] node v26.12.0, bundled undici 8.11.2
setGlobalDispatcher() returned without error
OK 200 {"ok":true,"path":"/t2"}
proxy log: PROXY CONNECT lab.internal:4000;
origin log: ORIGIN GET /t2 host=lab.internal:4000;
...
Look at the empty proxy log. With 5.29.0, 6.29.1 and 7.26.0 the request never touched the proxy. On a normal network it would have gone direct and "worked". When I added 7.27.0, it went through the proxy, which lines up with PR #5319. A small symbol check showed why:
[t2b u6 = undici@6.29.1] node v26.12.0
.1 is my Agent: true
.2 is my Agent: false
[t2b u726 = undici@7.26.0] node v26.12.0
.1 is my Agent: true
.2 is my Agent: false
[t2b u7 = undici@7.30.1] node v26.12.0
.1 is my Agent: true
.2 is my Agent: true
[t2b u8 = undici@8.12.3] node v26.12.0
.1 is my Agent: false
.2 is my Agent: true
The crash, and the reverse crash
Passing a ProxyAgent per call instead of globally gave the loud failure on Node 26 for undici 5.29.0 and 6.29.1. Versions 7.x and 8.x went through. Then I ran the same script on the system Node 25.2.1, whose fetch runs on undici 7.16.0, and the picture flipped:
$ node scripts/t1-dispatcher.mjs u6
[t1 u6 = undici@6.29.1] node v26.12.0, bundled undici 8.11.2
FAIL TypeError: fetch failed
cause: InvalidArgumentError: invalid onError method (code: UND_ERR_INVALID_ARG)
proxy log:
origin log:
...
$ node scripts/t1-dispatcher.mjs u6
[t1 u6 = undici@6.29.1] node v25.2.1, bundled undici 7.16.0
OK 200 {"ok":true,"path":"/t1"}
proxy log: PROXY CONNECT lab.internal:4000;
origin log: ORIGIN GET /t1 host=lab.internal:4000;
$ node scripts/t1-dispatcher.mjs u8
[t1 u8 = undici@8.12.3] node v25.2.1, bundled undici 7.16.0
FAIL TypeError: fetch failed
cause: InvalidArgumentError: invalid onRequestStart method (code: UND_ERR_INVALID_ARG)
proxy log:
origin log:
undici 6 works on 25 and crashes on 26. undici 8 works on 26 and crashes on 25. So "just upgrade to undici 8" isn't a free fix if you also support older runtimes.
The empty headers bug still reproduces
For issue #5341 I POSTed to a /gzip route through the built-in fetch with dispatcher: new Agent() from each copy:
$ node scripts/t5-headers.mjs u7270
[t5 u7270 = undici@7.27.0] node v26.12.0, bundled undici 8.11.2
status 200
headers []
body first bytes 1f8b0800000000000003ab56 | as text: ...
proxy log:
origin log: ORIGIN POST /gzip host=127.0.0.1:4000;
$ node scripts/t5-headers.mjs u7271
[t5 u7271 = undici@7.27.1] node v26.12.0, bundled undici 8.11.2
status 200
headers [["connection","keep-alive"],["content-encoding","gzip"],["content-type","application/json"],["date","Wed, 21 Oct 2026 06:52:10 GMT"],["keep-alive","timeout=5"],["transfer-encoding","chunked"],["x-lab","origin"]]
body first bytes 7b226f6b223a747275652c22 | as text: {"ok":true,"route":"gzip"}
proxy log:
origin log: ORIGIN POST /gzip host=127.0.0.1:4000;
1f8b08 is the gzip magic number. Status 200, no exception, and a body your JSON parser can't read. 7.26.0 behaved the same as 7.27.0.
undici 7.27.0 fixes the ignored global dispatcher, but in my run it still returned headers [] and a raw gzip body. 7.27.1 was the first version that fixed both. If you pin a minimum, pin >=7.27.1.
Finding the culprit in a dependency
Last, I built a stand-in for a real SDK: a local fake-sdk package that depends on undici@^6.21.0 and calls setGlobalDispatcher(new ProxyAgent(url)) for you. The app itself depended on undici 7.30.1, so a quick look at package.json said everything was fine. npm ls told the real story, and an override fixed it:
npm ls undici
../run.sh culprit-before node main.mjs # node = Node 26.12.0 (PATH set to lab/node26)
npm pkg set 'overrides.undici=$undici'
grep -A3 overrides package.json
npm i
npm ls undici
../run.sh culprit-after node main.mjs
culprit@1.0.0 /opt/blog-pipeline/runs/2026-10-26/lab/culprit
├─┬ fake-sdk@1.0.0 -> ./fake-sdk
│ └── undici@6.29.1
└── undici@7.30.1
$ node main.mjs
node v26.12.0, bundled undici 8.11.2
FAIL fetch failed | ENOTFOUND getaddrinfo ENOTFOUND lab.internal
...
culprit@1.0.0 /opt/blog-pipeline/runs/2026-10-26/lab/culprit
├─┬ fake-sdk@1.0.0 -> ./fake-sdk
│ └── undici@7.30.1 deduped
└── undici@7.30.1
$ node main.mjs
node v26.12.0, bundled undici 8.11.2
OK 200 {"ok":true,"path":"/culprit"}
proxy log: PROXY CONNECT lab.internal:4000;
origin log: ORIGIN GET /culprit host=lab.internal:4000;
The override forced 7.x on a package whose range said ^6.21.0. My toy SDK didn't care. A real one might use v6-only API, so run its tests after you do this.
One thing went wrong on my side, and it's worth knowing. I first put the test scripts in a separate lab/scripts/ folder, but bare imports like import('u6') resolve from the script's own folder upward, and node_modules lived in lab/app/. I moved the scripts into lab/app/scripts/ before running anything. If a repro script can't find a package you just installed, check that first.
Pick a fix: undici 8 breaking changes vs your code
The right fix depends on who owns the code that creates the dispatcher: you, an SDK, or a CLI you only run. Each row below is marked with what I actually saw on Node 26.12.0.
| Fix | Fixes the crash | Fixes ignored global proxy | Fixes empty headers | Best when |
|---|---|---|---|---|
npm undici >=7.27.1 (or 8.x) | Yes | Yes | Yes | You or an overrides entry control the version |
Import fetch from the same undici as the dispatcher | Yes, even with 5.29.0 | Yes (dispatcher passed per call) | Not tested (no bridge involved) | You own the call site and can't bump undici yet |
NODE_USE_ENV_PROXY=1 or --use-env-proxy, no dispatcher | Only after you remove the dispatcher | Yes | n/a (no Agent involved) | The dispatcher only existed to add a proxy |
http.setGlobalProxyFromEnv() | Only after you remove the dispatcher | Yes | n/a | You can't change how Node is started |
undici 8 + Dispatcher1Wrapper | Yes, on 25 and 26 | n/a (per call) | Not tested | A library that must run on old and new runtimes |
Upgrade the npm copy
If undici is your own direct dependency, bump it. If it comes from an SDK, use an override in the root package.json. The $undici value tells npm to reuse the version from your own dependencies entry, so the two can't drift apart:
{
"dependencies": {
"undici": "^7.30.1"
},
"overrides": {
"undici": "$undici"
}
}
I'd stay on the 7.x line here rather than jump to 8.x if the app still has to run on Node 22. undici 8 requires Node >=22.19.0, and its dispatchers break the older built-in fetch, as the Node 25 run showed. Add the new runtime to your CI matrix before the upgrade lands. My GitHub Actions CI/CD setup guide shows how to run tests on several Node versions in one workflow.
Use undici's own fetch with its own dispatcher
When fetch and the dispatcher come from the same copy, the handler shapes and the global symbol always match. In the lab even undici 5.29.0 worked on Node 26 this way. The change is one import:
import { fetch, ProxyAgent } from 'undici';
const proxy = new ProxyAgent(process.env.HTTPS_PROXY);
const res = await fetch('https://api.example.com/v1/orders', { dispatcher: proxy });
console.log(res.status, await res.json());
Be careful with the swap in a large codebase. The career-ops project fixed its crash this way in v1.36.0 but noted that the naive one-line swap broke a test on Node 20 and 22. Run the full suite on every runtime you support after the change.
Use NODE_USE_ENV_PROXY instead of ProxyAgent
If the only reason you created a ProxyAgent was to get through a corporate proxy, you probably don't need undici from npm at all. Node has built-in proxy support for fetch() and http.request(): set NODE_USE_ENV_PROXY=1 (added in v24.0.0 and v22.21.0) or pass --use-env-proxy (v24.5.0 and v22.21.0). Node then reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY at startup. The Node 26 CLI docs still mark both as "Stability: 1.1 - Active Development".
NODE_USE_ENV_PROXY=1 \
HTTPS_PROXY=http://proxy.internal:8080 \
NO_PROXY=localhost,127.0.0.1,.internal.example.com \
node server.js
Setting HTTP_PROXY by itself does nothing for the built-in fetch. In my lab, the env var alone ended in ENOTFOUND lab.internal with an empty proxy log, while adding NODE_USE_ENV_PROXY=1 or --use-env-proxy sent the request through the proxy. NO_PROXY accepts forms like example.com, .example.com, *.example.com, IP ranges and host:port.
In containers, put these variables in the service environment rather than in code, so the same image works inside and outside the proxied network. The environment section of my Docker Compose deployment guide covers where they go.
I tried NODE_USE_ENV_PROXY=1 while the code still passed an undici 6 ProxyAgent. It crashed with the same invalid onError method. An explicit dispatcher wins over the env proxy, so delete the dispatcher: option as part of the fix.
If you can't control how Node starts (a serverless platform, a test runner), Node 26 also has http.setGlobalProxyFromEnv(), added in v25.4.0 and v24.14.0. It turns on the same built-in proxy at runtime and returns a function that undoes it. Per the http module docs, it overrides any previously configured global agent or undici global dispatcher:
import http from 'node:http';
const restore = http.setGlobalProxyFromEnv({ HTTPS_PROXY: 'http://proxy.internal:8080' });
const res = await fetch('https://api.example.com/health');
restore(); // back to direct connections
In my run, the request before restore() went through the proxy and the one after it went direct, which is what you'd want for tests. It only holds one proxy at a time, though. If you rotate IPs through a proxy pool, as I suggest for larger jobs in my Node.js web scraping guide, I'd keep undici's own fetch and build a ProxyAgent per proxy instead.
If you maintain a library or CLI
Don't hand a dispatcher from your bundled undici to the user's global fetch. You can't know which undici their runtime ships. That single habit is behind the Vercel, Corepack and Redocly breakages.
Bumping your own dependency to undici 8 just moves the problem to older runtimes: cachy-app hit invalid onRequestStart method when an undici 8 dispatcher met the fetch in Node 22 and 24. If you need one dispatcher that works on both, undici 8 exports a bridge. This is the script that passed on both Node 25.2.1 and Node 26.12.0 in my lab:
// Reverse case: undici 8 dispatcher on a runtime whose fetch is undici 7.
// Dispatcher1Wrapper adapts a v8 dispatcher to the legacy handler API.
import { ProxyAgent, Dispatcher1Wrapper } from 'u8';
import { header, show } from './report.mjs';
header('t6 u8 Dispatcher1Wrapper');
console.log(' Dispatcher1Wrapper exported:', typeof Dispatcher1Wrapper);
await show(fetch('http://lab.internal:4000/t6', {
dispatcher: new Dispatcher1Wrapper(new ProxyAgent('http://127.0.0.1:4001')),
}));
(u8 is my npm alias for undici@8.12.3. In your code it's just 'undici'.)
cachy-app took a different route: try the runtime fetch first and fall back to undici's own fetch only when the error is a handler mismatch. The narrow check matters. According to their write-up, a proxy that answers 407 Proxy Authentication Required raises the same UND_ERR_INVALID_ARG code, so matching on the code alone would turn an auth failure into a silent retry. (If the 4xx family is fuzzy, my HTTP methods and status codes guide walks through it.) My sketch of the pattern, not their code:
import { fetch as undiciFetch } from 'undici';
function isHandlerMismatch(err) {
const cause = err?.cause;
return cause?.code === 'UND_ERR_INVALID_ARG' && /invalid on\w+ method/.test(cause.message);
}
export async function request(url, init) {
try {
return await fetch(url, init);
} catch (err) {
if (!isHandlerMismatch(err)) throw err;
return undiciFetch(url, init);
}
}
Only use this with buffered bodies (strings, Buffers). A stream body can't be sent twice. And whatever you choose, print error.cause in your CLI's error output. Vercel's --debug flag didn't show it, so users saw a bare fetch failed with nothing to act on.
When fetch failed has nothing to do with undici
If npm ls undici is clean and you don't pass a dispatcher anywhere, look at the cause code instead. Two common ones aren't specific to Node 26 at all:
connect ECONNREFUSED ::1:3333:localhostresolved to IPv6 and your server only listens on IPv4. Use127.0.0.1or calldns.setDefaultResultOrder('ipv4first').ETIMEDOUTorENETUNREACHwhile curl works: a broken IPv6 route.NODE_OPTIONS=--dns-result-order=ipv4firstis the quick test.
There's also a 26.x-specific one I didn't reproduce. The faxios project reported that on Node 26.11.1 a POST with a Node stream or async-iterable body plus an explicit Content-Length fails with TypeError: terminated and RequestContentLengthMismatchError, while 26.8.1 passed. Their fix was converting the stream to a single web ReadableStream. The undici maintainers haven't confirmed the root cause, so treat it as a lead, not a diagnosis.
FAQ
How do I check which undici version Node's built-in fetch uses?
Run node -p process.versions.undici with the same Node binary that runs your app. That prints the bundled copy, which is what the global fetch uses. Node 26.12.0 printed 8.11.2 in my test. The undici in node_modules is a separate copy and never replaces the built-in fetch.
Does Node fetch respect HTTP_PROXY and HTTPS_PROXY?
Only when you opt in. Start Node with NODE_USE_ENV_PROXY=1 or the --use-env-proxy flag, or call http.setGlobalProxyFromEnv() at runtime. Without one of those, the built-in fetch ignores the proxy variables completely and connects directly, even when curl on the same machine goes through the proxy just fine.
Do I need to upgrade undici to 8 for Node 26, and will it break Node 22 or 24?
No, undici 7.27.1 or newer already works with Node 26. Moving to undici 8 needs care: it requires Node 22.19.0 or newer, and an undici 8 dispatcher passed to the built-in fetch of an older runtime fails with invalid onRequestStart method unless you wrap it in Dispatcher1Wrapper.
Is it safe to set NODE_TLS_REJECT_UNAUTHORIZED=0 to fix fetch failed?
No. It turns off certificate checks for every TLS connection in the process, and it does nothing for the undici mismatch, because invalid onError method and the ignored global dispatcher aren't TLS errors. If err.cause really is a certificate error behind a corporate proxy, give Node the proxy's CA with NODE_EXTRA_CA_CERTS instead.
Should I upgrade to Node 26 now or wait?
Upgrade, but test outgoing HTTP first. Node 26 becomes Active LTS on 2026-10-28. I'd move a staging environment over, run npm ls undici, and test every outgoing HTTP path behind your real proxy, since the ignored-proxy bug only shows up on restricted networks.