{"id":979,"date":"2026-10-02T22:16:10","date_gmt":"2026-10-02T19:16:10","guid":{"rendered":"https:\/\/mexela.com\/blog\/nodejs-fetch-undici-proxy\/"},"modified":"2026-10-02T22:16:10","modified_gmt":"2026-10-02T19:16:10","slug":"nodejs-fetch-undici-proxy","status":"publish","type":"post","link":"https:\/\/mexela.com\/blog\/nodejs-fetch-undici-proxy\/","title":{"rendered":"Node.js Fetch Proxy with Undici ProxyAgent"},"content":{"rendered":"<p class=\"mexela-answer\">Route Node.js Fetch through a proxy by creating an Undici <code>ProxyAgent<\/code> and passing it as the request&#8217;s <code>dispatcher<\/code>, or by registering it with <code>setGlobalDispatcher<\/code> when every compatible request in the process should share that policy. Keep proxy credentials outside source code, use an <code>AbortSignal<\/code> deadline, verify one neutral route, and close the agent during shutdown.<\/p>\n<p class=\"mexela-scope\"><strong>Scope:<\/strong> this guide covers the maintained Undici package and its Fetch implementation, including explicit and environment-owned proxy dispatchers. It does not describe Axios proxy fields, browser proxy settings, transparent operating-system interception, or a promise that every Node.js version exposes identical bundled Undici behavior. Use the <a href=\"\/blog\/proxy-setup-developer-guides\/\">Proxy Setup and Developer Guides hub<\/a> for adjacent clients.<\/p>\n<h2 id=\"dispatcher-model\">Understand the Undici dispatcher model<\/h2>\n<p>Undici sends work through a dispatcher. A <code>ProxyAgent<\/code> implements that interface and routes each dispatched request through one HTTP, HTTPS, or SOCKS5 proxy. This ownership model differs from libraries that accept a <code>proxy<\/code> object directly in every request. The important question is not whether a machine has a proxy configured; it is which dispatcher the Fetch call actually uses.<\/p>\n<p>The current <a href=\"https:\/\/github.com\/nodejs\/undici\/blob\/main\/docs\/docs\/api\/ProxyAgent.md\" rel=\"noopener\">Undici ProxyAgent documentation<\/a> marks the class stable, allows a string, URL, or options object, and shows both global and per-request usage. Record the installed Node.js and Undici versions before copying a sample. The package can move faster than the Undici version bundled with a Node.js release.<\/p>\n<p>Create one long-lived agent for requests that share a route rather than constructing a new dispatcher for every call. The agent lazily creates per-origin dispatchers and can reuse connections. Unbounded agent creation wastes sockets and makes shutdown behavior harder to reason about.<\/p>\n<h2 id=\"local-dispatcher\">Prefer a local dispatcher for the first acceptance test<\/h2>\n<p>A local dispatcher limits the proxy policy to the request that names it. That makes the data path visible in code and avoids changing unrelated callers in the process. Use a fictional proxy host, load the real endpoint from protected configuration, and fail startup when the required value is absent.<\/p>\n<pre><code>import { ProxyAgent, fetch } from 'undici';\n\nconst proxyUri = process.env.PROXY_URI;\nif (!proxyUri) throw new Error('PROXY_URI is required');\n\nconst dispatcher = new ProxyAgent({ uri: proxyUri });\nconst signal = AbortSignal.timeout(10_000);\n\ntry {\n  const response = await fetch('https:\/\/api.example.invalid\/route-check', {\n    dispatcher,\n    signal,\n    headers: { accept: 'application\/json' }\n  });\n  try {\n    console.log({ status: response.status });\n  } finally {\n    await response.body?.cancel();\n  }\n} finally {\n  await dispatcher.close();\n}<\/code><\/pre>\n<p>The reserved <code>.invalid<\/code> destination is illustrative and will not resolve. Replace it only with an owned or approved diagnostic endpoint. A useful production function should receive the dispatcher as a dependency so tests and callers can state route ownership explicitly. Do not log <code>proxyUri<\/code>, because it may contain sensitive user information even when credentials are supplied separately.<\/p>\n<p><a href=\"https:\/\/github.com\/nodejs\/undici#garbage-collection\" rel=\"noopener\">Undici requires consuming or canceling response bodies<\/a> to release connection resources. The status-only examples cancel the body explicitly; status alone does not establish exit-route evidence. For a route check, consume a small documented JSON response, validate its observed address against the approved assignment, and retain only the necessary evidence. Complete body handling before closing a local dispatcher, and do not rely on garbage collection to free a busy connection.<\/p>\n<h2 id=\"global-dispatcher\">Use a global dispatcher only for process-wide policy<\/h2>\n<p><code>setGlobalDispatcher<\/code> changes the default dispatcher used by compatible Undici operations in the process. It is appropriate when a service has one intentional egress policy and application startup owns that policy. It is risky in a library, test suite, or mixed-route worker because a seemingly local initialization can reroute unrelated requests.<\/p>\n<pre><code>import { ProxyAgent, setGlobalDispatcher } from 'undici';\n\nconst dispatcher = new ProxyAgent({ uri: process.env.PROXY_URI });\nsetGlobalDispatcher(dispatcher);\n\nconst response = await fetch('https:\/\/api.example.invalid\/health', {\n  signal: AbortSignal.timeout(10_000)\n});\nawait response.body?.cancel();<\/code><\/pre>\n<p>Register the global dispatcher once, before work starts, and expose a controlled shutdown path that awaits <code>close()<\/code>. Do not switch global dispatchers per tenant or per request. Concurrent operations could observe the wrong route. If only one integration requires a proxy, pass a local dispatcher instead.<\/p>\n<p>Native-looking global <code>fetch<\/code> and <code>fetch<\/code> imported from Undici are related but version behavior must be verified in the runtime you deploy. An explicit import and package version make the example&#8217;s contract easier to audit. The <a href=\"\/blog\/use-proxies-curl-python-nodejs\/\">multi-client proxy overview<\/a> explains why Axios fields, Fetch dispatchers, and shell variables are not interchangeable.<\/p>\n<h2 id=\"environment-agent\">Use EnvHttpProxyAgent when deployment owns the route<\/h2>\n<p><code>EnvHttpProxyAgent<\/code> reads <code>http_proxy<\/code>, <code>https_proxy<\/code>, and <code>no_proxy<\/code>, including uppercase variants. According to the maintained <a href=\"https:\/\/github.com\/nodejs\/undici\/blob\/main\/docs\/docs\/api\/EnvHttpProxyAgent.md\" rel=\"noopener\">EnvHttpProxyAgent documentation<\/a>, lowercase values take precedence when both cases exist. If only <code>http_proxy<\/code> is present, it is used for both HTTP and HTTPS requests; an isolated <code>https_proxy<\/code> value applies only to HTTPS.<\/p>\n<p>This agent is useful in containers and managed services where the deployment manifest owns egress. Construction alone changes nothing: it must still be installed globally or supplied through the request&#8217;s dispatcher option. That explicit registration is valuable because it prevents an environment variable from silently changing a process that never opted into the agent.<\/p>\n<p>Treat <code>no_proxy<\/code> as direct-routing policy. Entries can match a host, subdomain, optional port, or every request when set to <code>*<\/code>. Review every bypass, test proxied and bypassed controls separately, and avoid broad suffixes copied from unrelated systems. The documentation notes that environment-based <code>no_proxy<\/code> can be reread for later requests, while an explicit <code>noProxy<\/code> option is fixed.<\/p>\n<h2 id=\"proxy-authentication\">Supply proxy authentication without exposing secrets<\/h2>\n<p>Proxy authentication belongs to the gateway, not the destination API. Current ProxyAgent options accept a preformatted <code>token<\/code> for the Proxy-Authorization header. The older <code>auth<\/code> option is documented as deprecated, and the constructor rejects simultaneous <code>auth<\/code> and <code>token<\/code>. Prefer the maintained token path and build it from secrets obtained at runtime.<\/p>\n<pre><code>const proxyUser = process.env.PROXY_USER;\nconst proxySecret = process.env.PROXY_PASSWORD;\nif (!proxyUser || !proxySecret) throw new Error('Proxy credentials missing');\n\nconst token = 'Basic ' + Buffer\n  .from(proxyUser + ':' + proxySecret, 'utf8')\n  .toString('base64');\n\nconst dispatcher = new ProxyAgent({\n  uri: process.env.PROXY_URI,\n  token\n});<\/code><\/pre>\n<p>Base64 is an encoding, not encryption. Keep the inputs and derived token out of logs, errors, metrics, tracing attributes, crash reports, and configuration dumps. A 407 response indicates a proxy authentication problem. A destination 401 or 403 concerns a different identity or policy boundary. Use the <a href=\"\/blog\/proxy-authentication-username-password-vs-ip-auth\/\">proxy authentication guide<\/a> to design a denial test before scaling.<\/p>\n<h2 id=\"connect-tunneling\">Interpret HTTPS and CONNECT tunneling correctly<\/h2>\n<p>For an HTTPS destination, ProxyAgent establishes a CONNECT tunnel through an HTTP proxy. If the proxy URI itself uses HTTPS, the client first establishes TLS to the proxy and then asks it to open the destination tunnel. Destination TLS runs through that tunnel. These are separate trust boundaries, represented by <code>proxyTls<\/code> and <code>requestTls<\/code> options when customization is genuinely required.<\/p>\n<p>Keep certificate and hostname verification enabled on both boundaries. Do not turn a certificate error into apparent success by accepting an unknown issuer. Inspect whether the failure happened while reaching an HTTPS proxy, after CONNECT while negotiating with the destination, or under an approved inspection layer. Record the issuer and hostname expectation without copying private traffic.<\/p>\n<p>For a plain HTTP destination, current Undici behavior uses an absolute-form request through an HTTP proxy by default; <code>proxyTunnel<\/code> can force tunneling. Do not force it unless the proxy and workflow require it. A proxy that accepts HTTPS CONNECT can still reject a plain HTTP forwarding policy, and the reverse is also possible.<\/p>\n<h2 id=\"route-verification\">Perform a controlled route verification<\/h2>\n<p>Begin with one safe GET to an endpoint you control that returns the source address it observed. Add a direct control from the same runtime when policy permits. Use a finite deadline and no automatic retry so the first failure remains visible.<\/p>\n<ol>\n<li>Record Node.js and Undici versions, dispatcher scope, proxy protocol, and a redacted endpoint label.<\/li>\n<li>Send one direct request and save status, observed source, and total duration.<\/li>\n<li>Create one ProxyAgent and send the same request with the local dispatcher option.<\/li>\n<li>Confirm the observed source differs from the direct control and matches the expected assignment.<\/li>\n<li>Repeat once using the same dispatcher to exercise normal connection reuse.<\/li>\n<li>Test one authorized application destination at conservative volume.<\/li>\n<li>Classify any failure before adding retries or concurrency.<\/li>\n<li>Close the agent and confirm the process can exit cleanly.<\/li>\n<\/ol>\n<p class=\"mexela-expected\"><strong>Expected observation:<\/strong> the bounded Fetch call uses the intended exit, preserves destination TLS, returns a native status or exception within the deadline, and a second call through the same dispatcher produces comparable routing evidence. Shutdown should await the dispatcher without leaving the process open on idle sockets.<\/p>\n<p>If the same service also has a PHP worker, compare its routing contract with the <a href=\"\/blog\/php-guzzle-proxy\/\">Guzzle proxy guide<\/a>. Matching the intended exit does not mean the two clients share environment precedence, connection timeouts, or bypass behavior.<\/p>\n<h2 id=\"errors\">Troubleshoot the failing layer, not the symptom<\/h2>\n<table>\n<thead>\n<tr>\n<th>Observation<\/th>\n<th>Boundary<\/th>\n<th>First check<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Missing proxy URI error<\/td>\n<td>Startup configuration<\/td>\n<td>Fail before creating the agent and report only the missing key name<\/td>\n<\/tr>\n<tr>\n<td>Name resolution or refusal<\/td>\n<td>Proxy reachability<\/td>\n<td>Check the redacted host, port, protocol, DNS, and firewall<\/td>\n<\/tr>\n<tr>\n<td>HTTP 407<\/td>\n<td>Proxy authentication<\/td>\n<td>Check token construction, account state, and secret rotation<\/td>\n<\/tr>\n<tr>\n<td>CONNECT or certificate error<\/td>\n<td>Tunnel or TLS<\/td>\n<td>Identify proxy TLS versus destination TLS and retain verification<\/td>\n<\/tr>\n<tr>\n<td>HTTP 401, 403, or 429<\/td>\n<td>Destination<\/td>\n<td>Review authorization, policy, terms, and pacing<\/td>\n<\/tr>\n<tr>\n<td>Direct route despite agent<\/td>\n<td>Dispatcher ownership or bypass<\/td>\n<td>Confirm the request received the intended dispatcher and inspect no_proxy<\/td>\n<\/tr>\n<tr>\n<td>Process will not exit<\/td>\n<td>Lifecycle<\/td>\n<td>Close the long-lived dispatcher during controlled shutdown<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>Preserve the original exception name and causal chain in protected diagnostics, but redact URLs and request headers before sharing. A bounded retry may be appropriate for a safe, transient operation after classification. Cap attempts, use backoff, retain the first error, and never retry an authorization denial as an invitation to rotate identity.<\/p>\n<p>Production observability should identify the dispatcher scope, attempt number, destination category, status class, elapsed time, and a non-secret route label. Keep full URLs, proxy authorization, destination authorization, cookies, query values, and response bodies out of general logs. Correlate protected diagnostics with an opaque request identifier when deeper investigation is required.<\/p>\n<p>After an upgrade, rerun the direct and proxied controls before normal traffic resumes. A package change can affect option validation, environment precedence, connector behavior, or lifecycle timing. Pinning a version does not replace the acceptance test; it makes the evidence reproducible and gives a failed rollout a known comparison point.<\/p>\n<p class=\"mexela-limits\"><strong>Operational limits:<\/strong> a successful Fetch request proves one runtime, dispatcher, route, destination, and time. It does not prove that another Node.js version, library, process, or destination follows the same path. A proxy does not grant permission, guarantee availability, hide application identity, or make unbounded collection safe.<\/p>\n<h2 id=\"next-step\">Choose capacity after the dispatcher passes<\/h2>\n<p>Document the required stable-session behavior, locations, authentication model, concurrent tasks, transfer estimate, lifecycle, and acceptance endpoint. When a service needs an endpoint assigned only to its team for repeatable API calls or allowlisting, compare those measured requirements with current <a href=\"\/private-proxies\/\">Mexela private proxy options<\/a> and confirm live inventory before ordering.<\/p>\n<h2 id=\"faq\">Frequently asked questions<\/h2>\n<div class=\"mexela-faq\">\n<h3>Does Node.js Fetch automatically use HTTP_PROXY?<\/h3>\n<p>Do not assume it. EnvHttpProxyAgent explicitly reads proxy environment variables, but it must be registered globally or passed as a dispatcher. Verify the installed runtime and package.<\/p>\n<h3>Should I use a local or global dispatcher?<\/h3>\n<p>Use a local dispatcher for one integration or an acceptance test. Use a global dispatcher only when application startup owns a deliberate process-wide egress policy.<\/p>\n<h3>How should ProxyAgent credentials be supplied?<\/h3>\n<p>Use protected runtime inputs and the maintained token option. Keep the source values and derived authorization token out of logs and source control.<\/p>\n<h3>Does HTTPS use CONNECT?<\/h3>\n<p>Yes. Current ProxyAgent documentation states that secure destinations are reached through an HTTP CONNECT tunnel. TLS to an HTTPS proxy and TLS to the destination remain distinct boundaries.<\/p>\n<h3>Why should the agent be closed?<\/h3>\n<p>The agent owns internal clients and connections. Awaiting close during controlled shutdown releases those resources and helps the process exit predictably.<\/p>\n<h3>Can a successful route check predict destination acceptance?<\/h3>\n<p>No. It demonstrates routing for one request. Destination authorization, policy, account state, and rate limits remain separate.<\/p>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Route Node.js Fetch through Undici ProxyAgent, choose local or global dispatcher scope, handle credentials safely, verify CONNECT, and close resources cleanly.<\/p>\n","protected":false},"author":0,"featured_media":980,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[189],"tags":[],"_links":{"self":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/979"}],"collection":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/comments?post=979"}],"version-history":[{"count":0,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/posts\/979\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/media\/980"}],"wp:attachment":[{"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/media?parent=979"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/categories?post=979"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mexela.com\/blog\/wp-json\/wp\/v2\/tags?post=979"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}