All guides
DEVELOPER GUIDES · 6 MIN READ

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

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

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:

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:

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:

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 when the target and acceptance rule are authorized.

For a Python worker, the HTTPX proxy guide 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

Request access

Keep reading

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

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

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