Community Article
Community articles are authored by SitePoint Premium contributors. Content is screened before publication, and SitePoint reserves the right to moderate or remove articles that violate our guidelines. Views expressed are those of the authors and do not necessarily reflect those of SitePoint.
Building a Proxy-Aware HTTP Client in Node.js Without Coupling Your App to a Provider.
SASaifullah AdenwallaPublished inNode.js·
September 2, 2026
·Updated:September 2, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
Adding proxy support to a Node.js application looks simple at first.
You take an HTTP request, add a proxy endpoint, and continue building.
That works until the application grows.
requests that should use a proxy
requests that should connect directly
If proxy configuration leaks directly into application logic, changing providers or testing the system becomes unnecessarily difficult.
A better approach is to treat network routing as infrastructure.
Your application should know what it wants to request.
A separate transport layer should decide how that request reaches the destination.
Let’s build that mental model using Node.js and Undici.
Why Put a Proxy Behind an Abstraction?
Imagine a service that collects public pricing information from several APIs and websites that your organization is authorized to access.
Your first implementation might contain proxy configuration inside the request itself.
That works.
But now imagine having dozens of calls throughout the application.
means touching business logic in multiple places.
That’s an architectural smell.
Fetch the current catalog.
Route this through proxy host X using credentials Y and region Z.
Those concerns belong at different layers.
Application logic → HTTP client → network routing → destination
That separation makes the application easier to test and easier to change.
Node.js Already Has a Strong HTTP Foundation
Modern Node.js includes the Fetch API, making many HTTP requests feel familiar to frontend developers.
Node’s implementation is based on Undici.
If you’ve spent most of your time using browser-side fetch(), SitePoint’s broader JavaScript API coverage is a useful reminder that server-side JavaScript increasingly shares familiar web-platform concepts while still exposing server-specific networking capabilities.
Undici goes further than the standard browser Fetch API by exposing dispatchers and agents.
A dispatcher controls how requests are sent.
That makes it a natural place to implement proxy routing.
import{fetch,ProxyAgent}from"undici";const dispatcher =newProxyAgent(process.env.PROXY_URL);const response =awaitfetch("https://api.your-service.test/data",{dispatcher});The important detail isn’t the few lines of code.
It’s that the proxy is represented by a dispatcher rather than being embedded into the application request itself.
That gives us somewhere to build an abstraction.
Create One HTTP Client Boundary
Instead of allowing every module to import fetch() directly, create a small HTTP client module.
src/├── clients/│ └── http-client.js├── services/│ ├── catalog.js│ └── pricing.js└── config/└── network.jsThe services deal with business behavior.
The HTTP client deals with networking.
That means your catalog service might only need to say:
const result =await httpClient.get(catalogUrl);It doesn’t care whether the request travels:
through another network environment during testing
That decision belongs inside the client.
Represent Routing as Configuration
A useful configuration model might distinguish between direct and proxied traffic.
exportconst networkConfig ={mode:process.env.NETWORK_MODE??"direct",proxyUrl:process.env.PROXY_URL??null};Then the HTTP client can construct its dispatcher once.
import{Agent,ProxyAgent}from"undici";exportfunctioncreateDispatcher(config){if(config.mode==="proxy"){returnnewProxyAgent(config.proxyUrl);}returnnewAgent();}Now infrastructure differences can be controlled through deployment configuration.
NETWORK_MODE=directwhile an authorized remote-testing environment uses:
NETWORK_MODE=proxyNo business code changes.
That’s the real benefit.
Keep Credentials Out of
Those values don’t belong in Git.
Treat them like database passwords or API secrets.
local
.envfiles that aren’t committeddeployment environment variables
const proxy ="http://real-user:real-password@proxy-host:8000";inside committed code.
It’s convenient for about five minutes.
Then it becomes a credential-management problem.
PROXY_HOST=...PROXY_PORT=...PROXY_USER=...PROXY_PASSWORD=...with the final configuration assembled at runtime.
Use Stable Routing When a Workflow Depends on Session Continuity
Not every request should randomly change network identity.
Consider a multi-step browser or HTTP workflow:
Open a session.
Receive cookies.
Select a region.
Request account data.
Submit another authorized request.
If the public IP changes repeatedly during that flow, the remote system may interpret the requests as unrelated sessions or trigger security checks.
For workflows where continuity matters, keeping the same route throughout the session is often more useful than rotating constantly.
This is where static proxy infrastructure can make architectural sense.
For example, an ISP proxy can provide a stable ISP-associated IP for longer-running, authorized workflows. Oxylabs’ current ISP proxy offering supports unlimited-duration sessions as well as HTTP, HTTPS, and SOCKS5 protocols, so it can be used in environments where the same route needs to remain consistent across a sequence of requests.
The application shouldn’t depend on those provider-specific characteristics, though.
Your transport abstraction should simply understand:
this workflow requires a stable routeThe provider implementation remains replaceable.
Don’t Make Every Request Global
Undici lets you install a global dispatcher.
That can be useful.
It can also be too broad.
If you configure a proxy globally, every compatible request in the process may use it.
But perhaps only one integration should use the proxy.
Your internal API may need a normal direct connection.
Your public-data collector may use a routed connection.
Those should not necessarily share networking behavior.
Instead of globally replacing the dispatcher, you can pass one to the individual request.
fetch(targetUrl,{dispatcher:externalDataDispatcher});fetch(internalUrl);This gives you clearer boundaries.
Use global configuration when the whole process genuinely has one networking policy.
Use local dispatchers when different integrations have different requirements.
Add Timeouts Before Adding Retries
Network requests fail.
A dangerous HTTP client behaves like this:
Request failed. Try again forever.
A better system first defines how long it is willing to wait.
Modern JavaScript gives us AbortSignal.timeout().
const response =awaitfetch(url,{dispatcher,signal:AbortSignal.timeout(10_000)});Now a request can’t remain unresolved indefinitely.
This becomes particularly important when an application communicates through additional network infrastructure.
There are more potential places for delay:
Application → proxy → remote server
Timeouts prevent a temporary infrastructure issue from consuming your entire worker pool.
Retry Failures Selectively
After adding timeouts, think about retries.
Don’t retry everything.
A useful retry strategy distinguishes between failures that may be temporary and failures that probably require code or configuration changes.
Potentially retryable
Usually not automatically retryable
A 401 probably won’t improve because you waited two seconds.
A 503 might.
The retry policy should understand that difference.
Add Backoff
If a service is struggling, immediately sending the same failed request repeatedly can make things worse.
Use backoff.
requestfailrequestfailrequestfailrequestfailwaitrequestfailwait longerrequestExponential backoff is a common strategy.
You may also add jitter so multiple workers don’t retry at exactly the same moment.
This becomes especially useful when many tasks share the same upstream dependency.
Respect Rate Limits
Proxies don’t remove the need to behave responsibly toward upstream services.
100 requests per minuteyour client should respect that limit.
Trying to bypass a service’s limits by distributing traffic across IP addresses isn’t a sound application architecture and may violate the service’s terms.
Instead, build throttling into the client.
Your application should understand:
This is especially important for public-data systems and crawlers.
SitePoint’s article on web scraping in Node.js provides useful background on fetching and processing remote web content. Modern production scrapers need additional care around dynamic content, throttling, authorization, and responsible request behavior.
Separate Retry Logic from Business Logic
Avoid this pattern throughout your application:
try{}catch{awaitwait(1000);try{}catch{}}Instead, create a reusable transport policy.
awaitrequestWithPolicy({url,retries:3,timeout:10_000});Then every integration receives consistent behavior.
The benefit isn’t only fewer lines of code.
It’s predictability.
If you decide later that a 429 should respect Retry-After, you fix it once.
Log the Route, Not the Secret
When requests fail, developers need enough information to understand the environment.
request_idtarget_hostnetwork_modeproxy_regionstatusdurationattemptproxy_passwordfull authenticated proxy URLauthorization headersession tokenLogs have a habit of surviving much longer than expected.
Treat them as potentially visible operational data.
request_id=8fa32target=catalog.servicenetwork=proxyregion=DEstatus=503attempt=2duration=1840msThat’s enough to diagnose many issues without leaking credentials.
Give Every Request an ID
Network debugging becomes much easier when each operation has an identifier.
Suppose an application reports:
Catalog refresh failed.
That’s vague.
request_id=b81d3you can reconstruct what actually happened.
This becomes more valuable as network architecture becomes more complex.
Even a simple random UUID can dramatically improve troubleshooting.
Measure Proxy and Destination Failures Separately
Imagine 15% of requests start failing.
Where is the problem?
HTTP request failedyou won’t know.
Instead, classify errors.
PROXY_CONNECTION_ERRORPROXY_AUTH_ERRORTARGET_TIMEOUTTARGET_429TARGET_5XXINVALID_RESPONSEYou don’t need a huge taxonomy.
You just need enough information to distinguish infrastructure failures from application-level responses.
Verify the Network Route During Testing
If your test depends on a particular route or region, verify it before running the expensive workflow.
Expected region: USObserved region: USProceedExpected region: UKObserved region: FRFail environment checkThis is much better than running 40 automated tests through the wrong environment and then investigating dozens of misleading failures.
Fail early.
SitePoint’s cross-browser testing checklist emphasizes establishing a defined testing strategy across environments rather than assuming one successful local run represents every user condition.
The same philosophy applies to network-dependent tests.
Make Your Tests Independent of the Real Proxy
Your normal unit test suite shouldn’t require paid network infrastructure.
Instead, inject the transport.
createCatalogService({httpClient});Production provides the real client.
fakeHttpClient{status:200,body: sampleCatalog}Now business logic can be tested without:
Integration tests can exercise the actual networking layer separately.
This is a classic separation-of-concerns win.
Use Integration Tests for the Routing Layer
Does the application behave correctly given this response?
Can our networking layer actually reach the intended environment?
Keep those separate.
A small proxy integration suite might check:
It doesn’t need to run hundreds of application tests.
Just verify that the infrastructure contract works.
Then application tests can rely on abstractions.
SitePoint’s Selenium WebDriver guide demonstrates a similar testing principle: isolate environment-specific tooling while keeping test intentions understandable.
Don’t Retry Non-Idempotent Requests Blindly
This is easy to overlook.
POST /ordersThe network connection drops before you receive the response.
Did the server create the order?
You don’t know.
Blindly retrying could create it twice.
GET requests are generally easier to retry safely.
Mutation requests require more thought.
server-generated operation IDs
Proxy infrastructure doesn’t change this rule.
It simply makes good HTTP-client design more important.
Keep Connection Policy Configurable
Different services may deserve different settings.
Catalog API:timeout = 5sretries = 2Large public document:timeout = 30sretries = 1Internal service:direct connectionRegional QA:stable proxied connectionTrying to force every request through one universal policy often produces poor results.
Your client abstraction can expose profiles:
defaultinternalexternalregional-testlong-runningThe service selects intent.
The networking layer translates that into implementation.
Gracefully Close Dispatchers
Connection pools and proxy agents hold re
A production application should close them during shutdown.
Unica’s agents expose closing behavior so you can clean up open re
A graceful shutdown might conceptually do this:
stop accepting work↓finish active requests↓close dispatcher↓close database↓exitThis matters particularly for:
Abruptly killing active networking re
Think About Concurrency Separately
A proxy supporting many requests doesn’t mean your application should send unlimited requests.
Concurrency is an application policy.
Suppose you’re processing 10,000 URLs.
Promise.all(10,000 requests)Use a concurrency limit.
10 workers25 workers50 workersdepending on the service and authorization you have.
Measure before increasing it.
The fastest configuration isn’t always the one with the highest concurrency.
Watch Memory When Processing Large Responses
Network performance isn’t only about connection speed.
Suppose your application downloads large HTML documents and stores every response body in memory before processing.
Even a stable network layer can’t protect you from memory pressure.
discard unused payloads quickly
This is especially relevant to crawlers and data-processing workers.
A robust HTTP client must consider the entire lifecycle of the response, not only whether it successfully connected.
Create a Clear Responsibility Boundary
At this point, a useful architecture might look like:
Business Service↓HTTP Client↓Retry / Timeout Policy↓Dispatcher↙ ↘Direct Proxy↓Network↓Remote ServiceEach layer has one job.
Business service
Understands what data is needed.
HTTP client
Creates requests and parses responses.
Policy
Controls timeout, retry, and backoff.
Dispatcher
Controls network routing.
Configuration
Provides environment-specific values.
This structure isn’t specific to proxies.
That’s exactly why it’s useful.
What Should Be Provider-Specific?
Ideally, very little.
Provider-specific information may include:
Keep those values near infrastructure configuration.
Avoid spreading them through services.
If you later change providers, the migration should look like:
update infrastructure adaptersearch the entire repository for provider URLsThat’s a good test of whether your abstraction is doing its job.
A Practical Checklist
Before calling your Node.js HTTP client production-ready, ask:
Architecture
Is network routing separate from business logic?
Can the provider be replaced without rewriting services?
Can requests bypass the proxy when appropriate?
Security
Are proxy credentials stored outside
Are secrets excluded from logs?
Are CI credentials protected?
Reliability
Are requests time-limited?
Are retries selective?
Is backoff implemented?
Are rate limits respected?
Is mutation retrying safe?
Observability
Does every request have an ID?
Can you distinguish proxy failures from target failures?
Are status, timing, and attempts recorded?
Testing
Can business logic run without a real proxy?
Is the networking adapter integration-tested separately?
Is the expected network route verified before regional tests?
Operations
Is concurrency controlled?
Are dispatchers closed gracefully?
Are large responses processed efficiently?
If those answers are clear, proxy support stops being a special-case hack.
It becomes ordinary infrastructure.
Final Thoughts
The difficult part of adding proxy support to a Node.js application isn’t creating a proxied HTTP request.
Libraries can do that in a few lines.
The difficult part is preventing networking details from leaking into the rest of the application.
A maintainable system treats routing as one layer in a broader HTTP architecture:
configuration → dispatcher → request policy → application service
That gives developers room to change:
without rewriting the business logic that depends on them.
Whether the request travels directly or through a stable proxy route should ultimately be an infrastructure decision.
Your catalog service shouldn’t care.
Your scraper shouldn’t care.
Your application tests shouldn’t care.
They should only care that the HTTP client fulfills its contract reliably.
That’s the point where proxy support stops being a networking trick and becomes good software design.


