# Node.js Fetch Proxy Guide: Use Undici Without Leaking Credentials

[All guides](https://proxylane.dev/blog) 

DEVELOPER GUIDES · 6 MIN READ

Give Node fetch an explicit Undici proxy dispatcher, bound each attempt and response body, classify failures carefully, and close the agent.

**Founder, ProxyLane** Published September 18, 2026

On this page [Match fetch and the dispatcher](https://proxylane.dev/blog/nodejs-fetch-proxy#match-fetch-and-the-dispatcher)  [Bound the response body](https://proxylane.dev/blog/nodejs-fetch-proxy#bound-the-response-body)  [Retry only a safe GET](https://proxylane.dev/blog/nodejs-fetch-proxy#retry-only-a-safe-get)

**Residential proxies from $2.50/GB**

Non-expiring traffic, location targeting and rotating or sticky sessions for your existing tools.

 [Request access](https://proxylane.dev/register?interest=proxies)

Node's global `fetch()` is stable, but it does not have a browser-style `proxy` field. Node documents a custom `dispatcher`, and its fetch implementation is based on Undici. The practical path is to install a compatible Undici version, create a `ProxyAgent`, and pass that dispatcher to the request.

 

This guide makes one `GET` to `https://example.com/`, validates a marker, caps the response body, and closes the agent. Use a target you are authorized to access and replace the marker with the field your worker accepts. A `200` status only says that a response arrived from some HTTP phase; it does not prove that the target returned useful data.

 

## Match fetch and the dispatcher

 

Install the Undici package in the application that owns this code. Node also bundles an Undici version for global fetch, and `process.versions.undici` shows that bundled version. The installed package can expose a newer `ProxyAgent`, so import `fetch` and `ProxyAgent` from the same package in this example:

 

```sh
npm install undici
```

 

Keep `PROXY_URL` in a deployment secret binding. It may contain proxy credentials, but the value never appears in source, command arguments, logs, or error output:

 

```js
import { fetch, ProxyAgent } from "undici";

const TARGET = "https://example.com/";
const proxyUrl = process.env.PROXY_URL;
if (!proxyUrl) {
  throw new Error("PROXY_URL is required");
}

let dispatcher;
try {
  dispatcher = new ProxyAgent(proxyUrl);
} catch {
  throw new Error("proxy configuration is invalid");
}

async function readTextBounded(response, maxBytes = 1_000_000) {
  if (!response.body) {
    return "";
  }
  const reader = response.body.getReader();
  const chunks = [];
  let total = 0;
  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        break;
      }
      total += value.byteLength;
      if (total > maxBytes) {
        await reader.cancel();
        throw new Error("response exceeded the application byte limit");
      }
      chunks.push(Buffer.from(value));
    }
  } finally {
    reader.releaseLock();
  }
  return Buffer.concat(chunks).toString("utf8");
}

try {
  const response = await fetch(TARGET, {
    dispatcher,
    redirect: "manual",
    signal: AbortSignal.timeout(15_000),
  });
  if (response.status !== 200) {
    await response.body?.cancel();
    throw new Error(`target returned HTTP ${response.status}`);
  }
  const body = await readTextBounded(response);
  if (!body.includes("Example Domain")) {
    throw new Error("accepted status, but the expected marker was absent");
  }
  console.log({ status: response.status, responseBodyCharacters: body.length });
} catch (error) {
  if (error instanceof Error && error.message.startsWith("target returned HTTP")) {
    throw error;
  }
  throw new Error("request failed")
} finally {
  await dispatcher.close();
}
```

 

The code deliberately does not print `error`, because its cause chain can include connection details. In a service, catch at the job boundary and record a redacted phase and attempt number. `AbortSignal.timeout()` aborts one attempt; it is not a total job deadline. Put a separate worker deadline around a multi-attempt operation.

 

For an HTTPS target, Undici's `ProxyAgent` establishes an HTTP CONNECT tunnel through the proxy. A proxy authentication response such as HTTP 407 is a proxy decision, not a target status. Do not retry the same credentials as if a 407 were a transient origin error. `ProxyAgent` also documents separate SOCKS5 support in the current Undici project, but this HTTP `ProxyAgent` example makes no assumption about SOCKS authentication.

 

## Bound the response body

 

The first runnable example uses `readTextBounded()` so its accepted body follows the same cap as the retry path. A `Content-Length` header is advisory: chunked responses, compression, and missing headers can make it unsuitable as a hard quota. The helper reads the Web stream and cancels it when the declared application budget is exceeded.

 

This cap reduces unexpected transfer into the application. It is not a proxy-account quota, and it does not promise that the remote server stopped sending bytes before cancellation reached the connection. Enforce hard traffic budgets at the proxy or network layer as well.

 

## Retry only a safe GET

 

Fetch rejects on network failures, but it resolves normally for ordinary target HTTP 407, 429, and 5xx responses. A proxy CONNECT 407 can instead reject during proxy negotiation, so classify that phase from the caught error and do not assume every 407 is a target response. For a safe `GET`, retry only a narrow set of transport codes and temporary gateway statuses. Keep the attempt count and delay cap small, and defer a response whose `Retry-After` value exceeds the worker budget:

 

```js
const RETRY_STATUSES = new Set([502, 503, 504]);
const RETRY_CODES = new Set([
  "ECONNRESET",
  "ECONNREFUSED",
  "ETIMEDOUT",
  "UND_ERR_CONNECT_TIMEOUT",
  "UND_ERR_SOCKET",
]);

function retryableError(error) {
  return error?.name === "TimeoutError" || RETRY_CODES.has(error?.cause?.code);
}

function boundedRetryAfter(value, attempt) {
  if (!value) {
    return Math.min(500 * 2 ** attempt, 2_000);
  }
  const seconds = Number(value);
  return Number.isFinite(seconds) && seconds >= 0 && seconds <= 2
    ? seconds * 1_000
    : null;
}

async function getWithRetries(url, dispatcher, maxAttempts = 3) {
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
    try {
      const response = await fetch(url, {
        dispatcher,
        redirect: "manual",
        signal: AbortSignal.timeout(15_000),
      });
      if (RETRY_STATUSES.has(response.status) && attempt + 1 < maxAttempts) {
        const delay = boundedRetryAfter(response.headers.get("retry-after"), attempt);
        await response.body?.cancel();
        if (delay === null) {
          return { status: response.status, body: "" };
        }
        await new Promise((resolve) => setTimeout(resolve, delay));
        continue;
      }
      return { status: response.status, body: await readTextBounded(response) };
    } catch (error) {
      if (!retryableError(error) || attempt + 1 === maxAttempts) {
        throw new Error("request failed")
      }
      await new Promise((resolve) => setTimeout(resolve, Math.min(500 * 2 ** attempt, 2_000)));
    }
  }
  throw new Error("request exhausted its bounded retries");
}
```

 

The helper does not retry 407 or 429. A target rate limit needs a policy that honors the service's delay and job deadline; changing proxy exits to defeat that limit is not a retry strategy. Treat `getWithRetries()` as the replacement for the single `fetch()` call in the earlier `try` block: call it with the live dispatcher before the `finally` block runs `await dispatcher.close()`. Never reuse that dispatcher after it has been closed. Use the returned status and bounded body as input to the worker's accepted-record check.

 

ProxyLane offers HTTP/SOCKS5 traffic from $2.50/GB, with country, city, ISP, rotating, and sticky options. Traffic does not expire; registration is free and the team follows up about access. Payment is separate.  [Request proxy access](https://proxylane.dev/register?interest=proxies)  when the target and acceptance rule are authorized.

 

For a Python worker, the  [HTTPX proxy guide](https://proxylane.dev/blog/httpx-proxy)  covers explicit environment handling and pool limits. The same operational rule applies here: route selection, proxy phase, target response, body validation, and cleanup are separate evidence.

 

## Sources and further reading

- [https://nodejs.org/api/globals.html#fetch](https://nodejs.org/api/globals.html#fetch)

- [https://nodejs.org/api/globals.html#static-method-abortsignaltimeoutdelay](https://nodejs.org/api/globals.html#static-method-abortsignaltimeoutdelay)

- [https://github.com/nodejs/undici/blob/main/docs/docs/api/ProxyAgent.md](https://github.com/nodejs/undici/blob/main/docs/docs/api/ProxyAgent.md)

- [https://undici.nodejs.org/#/docs/api/Dispatcher.md](https://undici.nodejs.org/#/docs/api/Dispatcher.md)

[Request access](https://proxylane.dev/register?interest=proxies)

## Keep reading

[Use cases · 3 min read

### Amazon Scraper API vs Proxy: Choose a Product Data Source

Distinguish authorized Amazon APIs, licensed product data and proxy-based page checks by record quality and access rights.

 Read guide](https://proxylane.dev/blog/amazon-scraper-api-vs-proxy)   [Integrations · 5 min read

### Apify Custom Proxy Setup: Use Your Own Residential Route

Connect a buyer-owned proxy to an Apify Actor, keep the session boundary clear, and validate records instead of counting requests.

 Read guide](https://proxylane.dev/blog/apify-custom-proxy)   [Proxy fundamentals · 6 min read

### Australia Residential Proxies: Verify the AU Exit and State-Level Result

Separate Australian egress from en-AU content, AUD pricing, GST display, postcode validation and the state delivery context.

 Read guide](https://proxylane.dev/blog/australia-residential-proxies)

Canonical source: https://proxylane.dev/blog/nodejs-fetch-proxy

Documentation index: https://proxylane.dev/llms.txt
