Geonode logo
Geonode Team

Geonode Team

Updated: September 2, 2026

Published: 2026-09-02

How to Use a Proxy with SuperAgent in Node.js

SuperAgent has no built-in proxy option. You either add an extension or supply an HTTP agent, and both approaches are complicated by a naming collision: SuperAgent's `.agent()` already means something else entirely. That collision produces a specific confusion where people think they have configured a proxy and have configured a cookie jar. This guide covers both routes, the terminology, and the check that tells you which is actually in effect.

We are Geonode and we sell proxies, so this is a guide to using our kind of product with a specific client. The line worth reading even if you skip the rest: verify the exit address after configuring, because a proxy setting that does nothing produces no error at all. SuperAgent will happily send your request directly, return a 200, and give you no indication that the proxy was bypassed. The verification section is four lines and it is the difference between knowing and assuming.

Note also that SuperAgent works in browsers too, where none of this applies — a browser cannot be told to use a proxy from JavaScript, so everything here is Node-only.

The Terminology Problem

Clear this up first, because it causes real mistakes.

In SuperAgent, .agent() with no arguments creates a copy of SuperAgent that persists cookies. The documentation is explicit: "In Node SuperAgent does not save cookies by default, but you can use the .agent() method to create a copy of SuperAgent that saves cookies. Each copy has a separate cookie jar."

const agent = request.agent();
await agent.post("/login").send({ user, pass });
await agent.get("/cookied-page");   // session cookie carried over

That agent also carries defaults: "Regular request methods called on the agent will be used as defaults for all requests made by that agent."

Meanwhile, .agent(httpAgent) with an argument sets Node's http.Agent for the request, which is where proxy support lives.

Same method name, two unrelated jobs, distinguished only by whether you pass something. If you have been reading about SuperAgent agents and about proxy agents in the same session, this is worth pinning down before you write anything.

Route One: A Proxy Agent

The approach to prefer, and the reason is maintenance.

import request from "superagent";
import { HttpsProxyAgent } from "https-proxy-agent";

const agent = new HttpsProxyAgent("http://myuser:mypass@proxy.example.com:9000");

const res = await request
  .get("https://api.example.com/items")
  .agent(agent);

For SOCKS, swap the package:

import { SocksProxyAgent } from "socks-proxy-agent";
const agent = new SocksProxyAgent("socks5h://proxy.example.com:1080");

Note socks5h rather than socks5. The h variant resolves hostnames at the proxy rather than locally, which prevents DNS queries going to your own resolver while your traffic exits somewhere else — a leak that silently undoes the point of using a proxy for geolocation.

And proxy-agent handles whichever protocol the URL specifies, which is useful when the proxy comes from configuration:

import { ProxyAgent } from "proxy-agent";
const agent = new ProxyAgent();   // reads http_proxy / https_proxy / no_proxy

Why these packages rather than the SuperAgent extension: all three are actively maintained. Checked against the npm registry in September 2026, proxy-agent is at 8.0.2, https-proxy-agent at 9.1.0 and socks-proxy-agent at 10.1.0, all published in June 2026.

Route Two: superagent-proxy

The purpose-built extension, and the caveat that comes with it.

import request from "superagent";
import superagentProxy from "superagent-proxy";

superagentProxy(request);

const res = await request
  .get("https://api.example.com/items")
  .proxy("http://myuser:mypass@proxy.example.com:9000");

Its README describes it as extending "superagent's Request class with a .proxy(uri) function", and notes that "it is backed by the proxy-agent module".

The API is nicer than passing an agent. The .proxy(uri) call reads better in a chain, and it accepts "HTTP, HTTPS, or SOCKS" URIs, delegating protocol selection to proxy-agent.

The caveat is the release date. superagent-proxy is at version 3.0.0, published in September 2021 — roughly five years old as of this writing, while its underlying proxy-agent dependency has continued to release. It is not deprecated and it is not broken, but it is a thin wrapper that has not moved while the thing it wraps has.

The practical consequence: if you already use it and it works, there is no urgency. For new code, using a proxy agent directly is one extra line and removes a stale layer from your dependency tree — and since the extension is a wrapper around exactly that agent, you lose nothing but the syntax.

Verify It Actually Worked

The four lines that matter most.

const res = await request
  .get("https://api.ipify.org?format=json")
  .agent(agent);

console.log(res.body);

Run it with the agent and without. If the address does not change, the proxy is not in the path. SuperAgent gives you no error, no warning and no failed request when this happens — the traffic simply goes directly.

Three reasons a configuration commonly does nothing:

You called .agent() with no argument, creating a cookie-persisting copy of SuperAgent rather than setting an HTTP agent. This is the terminology trap, and it produces exactly this symptom.

You applied the agent to the wrong request. SuperAgent's chaining is per request, so an agent set on one call does not apply to the next. For consistent behaviour, wrap request creation in a function.

The agent type does not match the target. An HttpsProxyAgent handles HTTPS targets; a plain HTTP target may need the HTTP variant. proxy-agent sidesteps this by choosing for you.

For geo-targeted proxies, address checking is not enough. Verify by outcome — request something that genuinely differs by region and confirm the response changed. A lookup service reporting the right country while your API returns your home region's data means the targeting is not landing where it matters, which is the silent-failure pattern we described in why testing proxies matters.

Timeouts Through a Proxy

SuperAgent's timeout model is unusually good, and worth using properly when a proxy adds latency.

The documentation describes two settings. req.timeout({deadline: ms}) — or req.timeout(ms) — "sets a deadline for the entire request (including all uploads, redirects, server processing time) to complete. If the response isn't fully downloaded within that time, the request will be aborted." And req.timeout({response: ms}) "sets maximum time to wait for the first byte to arrive from the server, but it does not limit how long the entire download can take."

The documentation's own advice on sizing is directly relevant to proxied requests: "Response timeout should be at least few seconds longer than just the time it takes the server to respond, because it also includes time to make DNS lookup, TCP/IP and TLS connections, and time to upload request data."

Through a proxy, every one of those phases costs more. A residential exit adds real per-request latency, and that is distance rather than a fault.

const res = await request
  .get("https://api.example.com/items")
  .agent(agent)
  .timeout({ response: 15000, deadline: 60000 });

The documentation recommends using both, and the reason is the same as everywhere else: a response timeout catches a server that never answers, while a deadline catches one that answers and then trickles. Neither alone covers both cases.

Set the values from measurement through the proxy rather than from habit, or you will produce failures that look like a broken proxy and are simply latency you did not budget for.

Error Handling

SuperAgent's default behaviour differs from most clients and it matters here.

The documentation is emphatic: "superagent considers 4xx and 5xx responses (as well as unhandled 3xx responses) errors by default". It adds that "this status information will be available via err.status", and that such errors "also contain an err.response field".

So a proxy authentication failure arrives as a rejection rather than a response:

try {
  const res = await request.get(url).agent(agent).timeout({ deadline: 30000 });
  return res.body;
} catch (err) {
  if (err.status === 407) throw new Error("Proxy rejected credentials");
  if (err.status === 401) throw new Error("Target requires authentication");
  if (!err.status) throw new Error(`Network error: ${err.code} ${err.message}`);
  throw err;
}

The distinction between 407 and 401 is the one worth building in. A 407 means the proxy stopped you and the target was never reached; a 401 means the proxy worked and the target wants credentials. Different parties, different fixes, and they are trivially confusable when both appear as thrown errors.

An error with no err.status means no HTTP response arrived at all, which points at the connection rather than at anyone's authentication. ECONNREFUSED means nothing is listening at the proxy address; ETIMEDOUT means packets are disappearing.

To treat some error statuses as successes — reading a 404 as data rather than a failure — use .ok():

.ok(res => res.status < 500)

Retries, Carefully

SuperAgent has built-in retry, with a documented restriction worth honouring.

const res = await request.get(url).agent(agent).retry(2);

The documentation explains that .retry() "will automatically retry requests, if they fail in a way that is transient or could be due to a flaky Internet connection", taking an optional retry count (default 1) and a callback invoked "before each retry". The callback "may return true/false to control whether the request should be retried (but the maximum number of retries is always applied)".

And the restriction, stated plainly in the documentation: use .retry() "only with requests that are idempotent".

Through a proxy this matters more than usual, for a specific reason. A timeout is not proof of failure — the request may have reached the target and succeeded while the response was lost on the way back. There is an extra hop where that can happen. Retrying a POST in that situation can duplicate a write, and no amount of retry configuration makes that safe. Where the operation matters, use an idempotency key if the API offers one.

The callback is also the right place to avoid retrying an authentication failure, since a 407 with wrong credentials will produce a 407 on every attempt:

.retry(3, (err, res) => {
  if (res?.status === 407 || res?.status === 401) return false;
  return true;
})

A Wrapper Worth Writing

SuperAgent's chaining is per request, which means proxy configuration is easy to forget on the one call that matters. Wrapping request creation solves that and gives you somewhere to put the rest of the defaults.

import request from "superagent";
import { ProxyAgent } from "proxy-agent";

const agent = process.env.PROXY_URL ? new ProxyAgent(process.env.PROXY_URL) : undefined;

const UA = "AcmeBot/1.0 (+https://acme.example.com/bot)";

function req(method, url) {
  const r = request[method](url)
    .set("User-Agent", UA)
    .timeout({ response: 15000, deadline: 60000 })
    .retry(2, (err, res) => {
      if (res?.status === 407 || res?.status === 401) return false;
      if (res?.status === 429) return false;   // honour the rate limit instead
      return true;
    });
  return agent ? r.agent(agent) : r;
}

export const get = url => req("get", url);
export const post = url => req("post", url);

export async function verifyExit() {
  const res = await get("https://api.ipify.org?format=json");
  console.log(`Exit address: ${res.body.ip}`);
  return res.body.ip;
}

Five choices in there are deliberate.

The proxy is optional and comes from the environment. With no PROXY_URL the agent is undefined and requests go directly, which makes local development and production behave predictably without branching in your application code. No credential appears in source.

ProxyAgent with no constructor argument would read http_proxy and friends if you preferred environment-driven configuration; passing the URL explicitly makes the source of truth obvious, which is usually worth more.

The user agent is honest and carries a contact URL. It costs nothing and it changes what happens when a site operator notices you.

Retries exclude the statuses where retrying is pointless or rude. A 407 with bad credentials will be a 407 every time; a 429 is an instruction to slow down, and retrying into it converts a temporary limit into a longer one.

verifyExit() is exported and called at start-up. Six lines that turn a silently bypassed proxy into a line in the logs — which is the single recurring theme of proxy work in every client, and the one thing no library will do for you.

.connect() Is Not a Proxy

Worth flagging because it looks like one and is not.

SuperAgent offers a .connect() method that, per the documentation, makes it possible "to ignore DNS resolution and direct all requests to a specific IP address". It supports a mapping, including a * fallback:

const res = await request.get("http://redir.example.com:555")
  .connect({
    "redir.example.com": "127.0.0.1",
    "www.example.com": false,
    "mapped.example.com": { host: "127.0.0.1", port: 8080 },
    "*": "proxy.example.com",
  });

The documentation notes that "the requests will keep their Host header with the original value", and that .connect(undefined) turns the feature off.

This is host redirection, not proxying. It changes which address the connection is made to while leaving the request unchanged — there is no CONNECT tunnel, no proxy protocol and no proxy authentication. It exists for testing, and the documentation places it under "Testing on localhost" for good reason.

The "*": "proxy.example.com" line in the official example is the source of the confusion. Use .connect() to point requests at a local test server; use an agent for an actual proxy.

People Also Ask

How do I use a proxy with SuperAgent?

Pass a proxy agent to .agent(): create an HttpsProxyAgent or SocksProxyAgent with your proxy URL and hand it to the request. Alternatively use the superagent-proxy extension, which adds a .proxy(uri) method — though that package has not been released since 2021.

What is the difference between .agent() with and without arguments?

With no argument it creates a cookie-persisting copy of SuperAgent with its own jar and default options. With an argument it sets Node's http.Agent for that request, which is how proxy support is applied. The shared name causes genuine confusion.

Is superagent-proxy still maintained?

It is not deprecated, but version 3.0.0 dates from September 2021 while its underlying proxy-agent dependency has continued releasing — most recently in June 2026. For new code, using a proxy agent directly avoids a stale wrapper for the cost of one extra line.

Why is my SuperAgent proxy not working?

Most often because .agent() was called with no argument, which creates a cookie jar rather than setting a proxy. Also check that the agent was applied to the right request, since SuperAgent chaining is per call. Verify by requesting a service that reports your address — a bypassed proxy produces no error.

Does SuperAgent respect HTTP_PROXY environment variables?

Not on its own. The proxy-agent package does read http_proxy, https_proxy and no_proxy, so constructing a ProxyAgent() with no arguments and passing it to .agent() gives you environment-driven behaviour.

How do I set timeouts for proxied requests?

Use both settings: .timeout({ response: 15000, deadline: 60000 }). The response timeout limits waiting for the first byte, the deadline limits the whole request. Size them from measurements taken through the proxy, since a residential exit adds real latency to DNS, connection and TLS phases alike.

How do I tell a proxy error from a target error?

By status. SuperAgent treats 4xx and 5xx as errors, so catch and read err.status — 407 means the proxy rejected you and the target was never reached, while 401 means the proxy worked and the target wants credentials. No err.status at all means no HTTP response arrived.

Can I use .connect() as a proxy?

No. It redirects requests to a specific IP while keeping the original Host header, which is host mapping for testing rather than proxying. There is no tunnel, no proxy protocol and no authentication. Use an agent for a real proxy.

Wrapping Up

SuperAgent has no proxy option of its own, so the choice is an agent or an extension — and the agent is the better default, because the maintained packages are the ones doing the actual work in either case.

The terminology is the main trap. .agent() with no argument gives you a cookie jar; .agent(something) sets an HTTP agent. People configure the first, see requests succeed, and conclude the proxy is working. Nothing corrects them, because a bypassed proxy fails silently by definition.

Which makes verification the habit worth building. Request a service that reports your address, with and without the agent, and confirm the answer changes. For geo-targeted work, go further and confirm that regionally distinct content actually differs — the address is the easy part and the least informative.

Then set both timeouts, branch on err.status so a 407 and a 401 lead to different messages, and keep .retry() away from anything that is not idempotent. Through a proxy there is an extra hop where a successful request can lose its response, and a retry in that situation is a duplicate rather than a recovery.

SuperAgent Proxy Guide: Agents superagent-proxy and Verification | Geonode