Your script sets HTTPS_PROXY, calls fetch(), waits ten seconds and dies with this:
node:internal/modules/run_main:107
triggerUncaughtException(
^
[TypeError: fetch failed] {
[cause]: ConnectTimeoutError: Connect Timeout Error (attempted address: shop.example.de:443, timeout: 10000ms)
at onConnectTimeout (node:internal/deps/undici/undici:1991:23)
at Immediate._onImmediate (node:internal/deps/undici/undici:1972:11)
at process.processImmediate (node:internal/timers:574:21) {
code: 'UND_ERR_CONNECT_TIMEOUT'
}
}
Node.js v24.21.0curl, run in the same shell with the same HTTPS_PROXY, gets through:
curl -sS -o /dev/null -w '%{http_code}\n' https://shop.example.de/p/espressomuehle-k2
# 200Nothing is wrong with the proxy. Node's built-in fetch doesn't read HTTP_PROXY or HTTPS_PROXY unless you tell it to, so it tried to reach the shop directly. The short fix, on Node 22.21 or later and on Node 24 and later:
NODE_USE_ENV_PROXY=1 node price-check.mjsOn older releases, including Node 20, you need an undici dispatcher instead. Both are below, along with the traps around them. Every command and output on this page was run on 2 October 2026 against a logging test proxy, on the Node versions listed in How this was tested.
The script that times out
The running example is a small job that checks the price of one product on a German shop every hour, through a residential proxy. shop.example.de stands in for the real shop:
// price-check.mjs: log one product's price every hour.
const PRODUCT_URL = 'https://shop.example.de/p/espressomuehle-k2';
const ONE_HOUR = 60 * 60 * 1000;
async function checkPrice() {
const res = await fetch(PRODUCT_URL, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const html = await res.text();
const price = html.match(/itemprop="price" content="([\d.]+)"/)?.[1];
console.log(new Date().toISOString(), price ? `${price} EUR` : 'price not found');
}
await checkPrice();
setInterval(() => checkPrice().catch((err) => console.error(err)), ONE_HOUR);The proxy comes from the environment, the way curl, Python Requests and most CLI tools expect it. proxy.example.net, USERNAME and PASSWORD are placeholders for your provider's gateway and credentials; percent-encode any reserved characters in them, and load the real values from your secret manager rather than typing them into shell history:
export HTTPS_PROXY='http://USERNAME:PASSWORD@proxy.example.net:8080'
node price-check.mjsOn every Node version tested, from 20.20.2 to 26.10.0, this run never contacted the proxy. It failed after about 10.6 seconds with the error above. Node 20 and 22 print the same [cause], under a TypeError: fetch failed line with a stack trace instead of the bracketed form.
Why Node's fetch ignores HTTPS_PROXY
Node's built-in fetch is undici. Its default dispatcher opens a direct connection to whatever host the URL names. Proxy variables are only read when you opt in: PR #57165 added that support in 2025 behind NODE_USE_ENV_PROXY, and its description calls the opt-in a first step, with turning it on by default left for later.
So the request goes straight to shop.example.de:443. In a network where only the proxy may reach the internet, the connection attempt gets no answer, and undici gives up after its 10-second connect timeout with UND_ERR_CONNECT_TIMEOUT. Read the address in the message: attempted address: shop.example.de:443 is the shop, so the proxy was never used. If the message names your proxy's host instead, the proxy itself is unreachable, which is a different problem; Fix Node.js TypeError: fetch failed behind a proxy decodes that and the other cause strings.
Where direct traffic is allowed, there is no error at all. In the test, with no opt-in, a fetch to a host the machine could reach directly returned 200 and the proxy logged nothing. For a price check, that means the shop sees your server's own address instead of the proxy's.
The one-line fix: NODE_USE_ENV_PROXY=1 or --use-env-proxy
Turn on Node's built-in proxy support for the process:
NODE_USE_ENV_PROXY=1 node price-check.mjs
# 2026-10-02T12:35:44.149Z 49.90 EURThe command-line flag does the same thing, and it also works in NODE_OPTIONS:
node --use-env-proxy price-check.mjs
NODE_OPTIONS=--use-env-proxy node price-check.mjsWith either one, every tested release that supports it sent CONNECT shop.example.de:443 to the proxy, with the credentials from the URL, and printed the price. The two switches arrived in different releases:
| Opt-in | Added in (Node.js docs) | Test result |
|---|---|---|
NODE_USE_ENV_PROXY=1 | v24.0.0, v22.21.0 | Proxied on 22.21.0, 22.23.3, 24.0.0, 24.4.1, 24.5.0, 24.21.0 and 26.10.0. Silently ignored on 20.20.2 and 22.20.0, which timed out as before |
--use-env-proxy | v24.5.0, v22.21.0 | Proxied on 22.21.0, 22.23.3, 24.5.0, 24.21.0 and 26.10.0. Node 20.20.2, 22.20.0, 24.0.0 and 24.4.1 refused to start: bad option: --use-env-proxy |
NODE_OPTIONS=--use-env-proxy | Listed among the options allowed in NODE_OPTIONS | Same as the flag. Older releases exit with --use-env-proxy is not allowed in NODE_OPTIONS |
The Enterprise network configuration guide adds that the same switch routes node:http and node:https requests from v22.21.0 and v24.5.0. That matched the test: https.get used the proxy on 22.21.0 and 24.5.0, and still went direct on 24.0.0 and 24.4.1, where only fetch was covered.
Things that tripped the test, so they don't trip you:
- Set the variables before Node starts. Node reads them at startup. A script that assigned
process.env.HTTPS_PROXYitself and then calledfetchwent direct on every version. - Keep the scheme in the proxy URL. With
HTTPS_PROXY=127.0.0.1:3128, the process exited before running any code withTypeError: Invalid URL(ERR_INVALID_URL). With credentials but no scheme, it exited with anInvalid URL protocolerror (UND_ERR_INVALID_ARG). - Set HTTPS_PROXY for https:// URLs. With only
HTTP_PROXYset,fetchstill used it for the HTTPS shop on 22.23.3, 24.21.0 and 26.10.0, buthttps.getwent direct.HTTPS_PROXYcovers both. - Put the opt-in on the command line, not in an
--env-file. Node readHTTPS_PROXYfrom a.envfile, butNODE_USE_ENV_PROXY=1in the same file had no effect on 22.21.0, 22.23.3, 24.5.0, 24.21.0 or 26.10.0. It only worked from the file on 24.0.0 and 24.4.1. With the proxy in.envand the flag on the command line, every release that has the flag used the proxy:
node --env-file=.env --use-env-proxy price-check.mjs- Expect a warning on Node 22. 22.21.0 and 22.23.3 printed
[UNDICI-EHPA] Warning: EnvHttpProxyAgent is experimental, expect them to change at any time.The request still went through the proxy. - A 407 is a credentials problem, not this one. With a wrong password, the error two levels down was
Proxy response (407) !== 200 when HTTP Tunneling. Fix proxy error 407 covers it.
Node 20 and older: route fetch through an undici dispatcher
Node 20 has no built-in opt-in, and it reached end-of-life on 30 April 2026 according to the Node.js release schedule. The same applies to 22.20 and earlier. Upgrading is the real fix. Until then, install undici from npm and set its EnvHttpProxyAgent as the global dispatcher. It reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY, in either case, like the built-in support does:
npm install undici@7// proxy-setup.mjs: send the built-in fetch through HTTP(S)_PROXY on older Node.
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new EnvHttpProxyAgent());Load it before the script, so price-check.mjs stays unchanged:
node --import ./proxy-setup.mjs price-check.mjsWith undici 7.30.0, this used the proxy on all nine tested releases, from 20.20.2 to 26.10.0. If you'd rather keep the proxy to one call, pass the agent as a per-request dispatcher:
import { EnvHttpProxyAgent } from 'undici';
const dispatcher = new EnvHttpProxyAgent();
const res = await fetch('https://shop.example.de/p/espressomuehle-k2', { dispatcher });
console.log(res.status);ProxyAgent takes one proxy URL and sends every request through it. Unlike EnvHttpProxyAgent, it doesn't read NO_PROXY:
import { ProxyAgent } from 'undici';
const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY);
const res = await fetch('https://shop.example.de/p/espressomuehle-k2', { dispatcher });
console.log(res.status);Both printed 200 through the proxy on every tested release, with the credentials taken from the URL.
Pick the undici major with care. undici 7 requires Node 20.18.1 or later, according to its package.json. With undici 6.29.0 instead, all three wirings worked on Node 20, 22 and 24, but not on 26.10.0: the global dispatcher was ignored and the request went direct, and a per-request undici 6 dispatcher failed with UND_ERR_INVALID_ARG. undici 8 dispatchers have the opposite problem on Node 22 and 24, as recorded in the fetch failed guide, which explains that version skew. On Node 22.21 and later, and on 24 and later, NODE_USE_ENV_PROXY=1 needs no dependency at all.
A common wrong turn: https-proxy-agent
Search for a Node proxy and you'll find https-proxy-agent. It is an agent for node:http, node:https and libraries built on them. The built-in fetch has no agent option and silently ignores one:
import { HttpsProxyAgent } from 'https-proxy-agent';
const agent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
const res = await fetch('https://shop.example.de/p/espressomuehle-k2', { agent });
console.log(res.status);There was no warning on any tested version. The request went direct and failed with UND_ERR_CONNECT_TIMEOUT after about 10.6 seconds, exactly as if the agent weren't there. The same agent (https-proxy-agent 9.1.0) works where it belongs. Both of these printed 200 through the proxy on all nine releases:
import https from 'node:https';
import { HttpsProxyAgent } from 'https-proxy-agent';
const agent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
https.get('https://shop.example.de/p/espressomuehle-k2', { agent }, (res) => {
console.log(res.statusCode);
res.resume();
});import fetch from 'node-fetch';
import { HttpsProxyAgent } from 'https-proxy-agent';
const agent = new HttpsProxyAgent(process.env.HTTPS_PROXY);
const res = await fetch('https://shop.example.de/p/espressomuehle-k2', { agent });
console.log(res.status);The second one uses the node-fetch package (3.3.2), not the built-in fetch. If you're on the built-in one, use NODE_USE_ENV_PROXY or an undici dispatcher.
NO_PROXY, and the empty lower-case variable trap
Once the opt-in is on, NO_PROXY lists the hosts that skip the proxy. A price-check host usually runs next to things that shouldn't go through a paid residential proxy, such as a local database API or a health check. In the test, without NO_PROXY, even a request to http://127.0.0.1 went through the proxy.
These are the routes fetch took to https://shop.example.de/ with different NO_PROXY values, on Node 22.23.3, 24.21.0 and 26.10.0 with NODE_USE_ENV_PROXY=1, and on Node 20.20.2 with the undici 7.30.0 setup above:
| NO_PROXY | Route for shop.example.de |
|---|---|
localhost,127.0.0.1 | Proxy |
shop.example.de, .example.de or *.example.de | Direct |
shop.example.de:443 | Direct |
shop.example.de:8443 | Proxy (the port doesn't match) |
* | Direct |
example.de | Direct on 20 (undici 7.30.0), 24.21.0 and 26.10.0; proxy on 22.23.3 |
Node's HTTP docs describe example.com as an exact host match. https.get followed that on 24.21.0 and 26.10.0 and used the proxy, while fetch on the same versions treated example.de as covering shop.example.de. nodejs/node#65616 tracks the difference. Names are compared as written, so NO_PROXY=localhost didn't exempt http://127.0.0.1. List the exact hosts you mean, and include both localhost and 127.0.0.1 when you need both.
Node's docs say the lower-case variable wins when both cases are set. That gets odd when the lower-case one is set but empty, for example by an export https_proxy= left in a shell profile or by an empty value in a CI or container config. nodejs/node#66202 reports that fetch and http.request() read an empty value differently. This script sends the same request both ways:
// which-route.mjs: send the same request with https.get and with fetch.
import https from 'node:https';
const url = 'https://shop.example.de/p/espressomuehle-k2';
const viaGet = await new Promise((resolve) => {
const req = https.get(url, { timeout: 15_000 }, (res) => resolve(res.statusCode));
req.on('timeout', () => req.destroy(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })));
req.on('error', (err) => resolve(err.code));
});
const viaFetch = await fetch(url, { signal: AbortSignal.timeout(15_000) })
.then((res) => res.status, (err) => err.cause?.code ?? err.name);
console.log({ viaGet, viaFetch });In the test network, 200 means the request went through the proxy, and a timeout means it went direct. With NODE_USE_ENV_PROXY=1 on 22.21.0, 22.23.3, 24.5.0, 24.21.0 and 26.10.0:
| Environment | https.get | fetch |
|---|---|---|
HTTPS_PROXY set | Proxy (200) | Proxy (200) |
HTTPS_PROXY set, https_proxy='' | Proxy (200) | Direct (UND_ERR_CONNECT_TIMEOUT) |
HTTPS_PROXY set, no_proxy='', NO_PROXY='*' | Direct (ETIMEDOUT) | Proxy (200) |
fetch treats an empty lower-case variable as set, so https_proxy='' switches the proxy off for it and no_proxy='' cancels NO_PROXY. https.get treats an empty value as unset and falls back to the upper-case one. The undici 7.30.0 EnvHttpProxyAgent on Node 20 behaved like fetch. The plain-HTTP version from the issue reproduced the same way. On 2 October 2026 the issue is open and the proposed fix was closed without merging, so don't rely on either reading. Unset the empty variables instead. This prints which proxy variables a process sees, without their values:
node -e "for (const k of ['https_proxy', 'HTTPS_PROXY', 'http_proxy', 'HTTP_PROXY', 'no_proxy', 'NO_PROXY']) console.log(k, process.env[k] === undefined ? 'unset' : process.env[k] === '' ? 'EMPTY' : 'set')"
unset https_proxy no_proxyRun the check in the same environment as the job, such as the cron line, systemd unit or container, not just your terminal. For how curl, Python and Node each read these variables, see proxy environment variables.
Which fix for which Node version
| Node.js | Bundled undici (tested release) | What to use |
|---|---|---|
| 20.x (end-of-life) | 6.24.1 (20.20.2) | undici 7 EnvHttpProxyAgent via --import ./proxy-setup.mjs, then upgrade |
| 22.0 to 22.20 | 6.21.2 (22.20.0) | The undici 7 dispatcher, or update to the latest 22.x |
| 22.21 and later | 6.22.0 to 6.28.1 (22.21.0, 22.23.3) | NODE_USE_ENV_PROXY=1 or --use-env-proxy |
| 24.0 to 24.4 | 7.8.0 to 7.11.0 (24.0.0, 24.4.1) | NODE_USE_ENV_PROXY=1; the flag doesn't exist yet |
| 24.5 and later | 7.12.0 to 7.29.1 (24.5.0, 24.21.0) | NODE_USE_ENV_PROXY=1 or --use-env-proxy |
| 26.x | 8.10.2 (26.10.0) | NODE_USE_ENV_PROXY=1 or --use-env-proxy |
Node 18 and earlier, and the odd-numbered lines, were not tested. Check your version with node -v, using the same binary the job runs.
The price check, end to end
The script is the same file as at the top. Only the way it starts changes. On Node 22.21 or later, or 24 and later:
export HTTPS_PROXY='http://USERNAME:PASSWORD@proxy.example.net:8080'
export NO_PROXY='localhost,127.0.0.1'
NODE_USE_ENV_PROXY=1 node price-check.mjs
# 2026-10-02T13:20:12.538Z 49.90 EUROn Node 20, with undici@7 installed and proxy-setup.mjs next to the script:
node --import ./proxy-setup.mjs price-check.mjs
# 2026-10-02T13:20:11.398Z 49.90 EURThe process stays running, and setInterval repeats the check every hour. In the test, the proxy logged one authenticated CONNECT shop.example.de:443 for the check, and the shop received the request without the Proxy-Authorization header. A 200 alone doesn't prove the route: confirm it in your provider's dashboard or request log, or point the script once at an endpoint you control that reports the caller's IP address.
How this was tested
On 2 October 2026, on an Ubuntu VPS (linux-x64), with the official Node.js tarballs v20.20.2, v22.20.0, v22.21.0, v22.23.3, v24.0.0, v24.4.1, v24.5.0, v24.21.0 and v26.10.0, each checked against its published SHA-256. npm packages: undici 7.30.0 and 6.29.0, https-proxy-agent 9.1.0 and node-fetch 3.3.2. curl 8.18.0.
Each case ran as its own process inside a separate Linux network namespace. There, shop.example.de resolved to 203.0.113.10, an address reserved for documentation, and every packet to a non-loopback address was dropped, so a direct connection could only time out. The only route to the shop was a loopback forward proxy that required Basic authentication and logged every CONNECT and forwarded request. Behind it, a local HTTPS server answered for shop.example.de with a certificate from a throwaway CA, which the processes trusted through NODE_EXTRA_CA_CERTS and curl through CURL_CA_BUNDLE. The test credentials were invented and the placeholders above replaced them. A route counts as "proxy" only when the proxy logged an authenticated request for that host.
The test didn't cover macOS or Windows (where environment variable names aren't case-sensitive), HTTPS or SOCKS proxies, a real provider's network, Docker, or Node 18, 23 and 25. Timings come from the namespace and say nothing about a real proxy's speed.
Related guides
- Proxy environment variables: HTTP_PROXY and NO_PROXY explains how curl, Python Requests and Node each read the variables.
- Fix proxy error 407 without guessing helps once the proxy is in use but rejects your credentials.
- Fix ERR_TUNNEL_CONNECTION_FAILED covers the same proxy refusals in Playwright and Puppeteer.
- Fix Node.js TypeError: fetch failed behind a proxy decodes every other cause behind
fetch failed.
ipvolt is a proxy service for developers that isn't open yet; join the early-access list for one email when it opens.
Sources & further reading
Technical references used for this guide. Check the documentation for your installed version and your provider’s supported configuration.
- Node.js Learn: Enterprise network configuration
- Node.js CLI: NODE_USE_ENV_PROXY=1
- Node.js CLI: --use-env-proxy
- Node.js HTTP: built-in proxy support and the NO_PROXY format
- nodejs/node PR #57165: support HTTP[S]_PROXY environment variables in fetch
- nodejs/node issue #66202: fetch() and http.request() disagree when a lower-cased proxy variable is empty
- nodejs/node issue #65616: NO_PROXY=example.com and subdomains, http.request() vs fetch()
- undici 7.30.0: EnvHttpProxyAgent
- undici 7.30.0: ProxyAgent
- Node.js release schedule (end-of-life dates)