urllib: an HTTP client for Node with timeouts, redirects and digest auth built in
Request HTTP(s) URLs in a complex world.
At a glance
- What is it?
- urllib is an ESM-only Node HTTP client built on the undici API that turns the awkward parts of a real request, connect and read timeouts, redirect policies, digest auth, streaming and compressed responses, into named options. It suits services that call other services, and it is a different proposition from fetch because the options are per request rather than per client.
- Who is it for?
- urllib fits a Node service that makes many outbound HTTP calls and needs connect and read timeouts, a redirect cap, digest auth or multipart uploads without configuring a client object, and it does not fit a project that wants a small dependency-free fetch wrapper or that is still on CommonJS or on Node before 22.19.0.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 3 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
A request helper over undici, not a client you configure once
urllib is an HTTP client for Node whose description is Request HTTP(s) URLs in a complex world, and the package manifest puts it more precisely: help in opening URLs, mostly HTTP, in a complex world, with basic and digest authentication, redirections, timeout and more, based on the undici API.
The framing of the public API matters. The entry point is a function, async request(url[, options]), and everything is passed as options to that call. There is no client object you construct once and reuse. That is a different design from a pooled undici Agent, and it is the reason the option list is as long as it is: since there is no shared client, each of these behaviours has to be expressible per request.
The alternative approach, and the one most Node developers reach for first, is to configure those things once on an undici dispatcher or an Agent and call fetch. The difference is where the configuration lives. urllib makes every knob per request and leaves the dispatcher for cases where you need it, which the rejectUnauthorized option documents explicitly, since it applies to the top-level request and curl when neither dispatcher nor socketPath is set, and HttpClient instances have their own connect.rejectUnauthorized, with custom dispatchers controlling their own TLS settings.
The result is a library that covers a lot of ground in one call, at the cost of a fairly wide surface to read before you trust it.
Installation and the ESM-only constraint
The install is the familiar one:
npm install urllibThe constraint that matters is the module system. urllib is ESM-only and requires Node.js 22.19.0 or later, and the instruction is to use import in an ES module, either an .mjs file or a .js file with type module in package.json.
The package manifest matches that. The type field is module, the main entry is ./dist/index.mjs, the types are ./dist/index.d.mts, and the export map exposes the root plus ./package.json. There is no require condition, so a CommonJS consumer cannot load it without a dynamic import or a transpilation step.
Node 22.19.0 as a floor is a real constraint for an organisation still on Node 20 LTS. It is also a floor that says something about the project, since the underlying undici API has been the shape of Node's own fetch implementation for a while now, and building on it means tracking a moving platform API rather than an internal abstraction.
The first call looks like this:
import { request } from 'urllib';
const { data, res } = await request('http://cnodejs.org/');
// result: { data: Buffer, res: Response }
console.log('status: %s, body size: %d, headers: %j', res.status, data.length, res.headers);The return shape is data plus res, and by default data is a Buffer. That is the opposite of fetch, where the body is a stream you have to consume, and it is the reason the timing option exists: with the body already buffered, the client can measure the request phase, the response phase and the total, which is what you need if you are instrumenting outbound calls.
Timeouts: two phases, and a keep-alive that can outlast them
The timeout option is more expressive than a single number, and this is one of the places urllib is genuinely more capable than a thin fetch wrapper.
The timeout option is a number or an array, in milliseconds, covering the connecting phase and the response receiving phase, with a default of 5000. A single value applies the same timeout to both phases, and an array sets them separately, so timeout: [3000, 5000] means a three second connect timeout and a five second response timeout.
That distinction solves a real problem. A common production bug is a single global timeout that is too aggressive for a slow database-backed endpoint but far too generous for a connection that never completes, so a hung TCP handshake blocks a request slot for the full timeout. Splitting the two phases lets you be strict about connecting and relaxed about reading.
There is a third clock, keepAliveTimeout, defaulting to 4000 milliseconds, which is the timeout after which a socket without active requests will time out, monitoring the time between activity on a connected socket. The documentation notes that this value may be overridden by keep-alive hints from the server.
The repository has a dedicated keep-alive test script in the package manifest, run with a keep-alive-header test file and a three minute timeout, plus a TEST_KEEPALIVE_COUNT of 50, so socket reuse is something the project tests explicitly rather than leaving to chance.
Redirects, auth, TLS and the options that change failure behaviour
Several options exist purely to change how a request fails, and those deserve careful reading rather than copying.
followRedirect defaults to true, maxRedirects defaults to 10, and formatRedirectUrl lets you rewrite the redirect target yourself, defaulting to url.resolve(from, to). A redirect cap of ten is a sensible default that most wrappers do not give you at all.
The auth options are separate, which is unusual and useful: auth takes a username:password string for HTTP Basic Authorization, and digestAuth takes a username:password string for HTTP Digest Authorization. Both are in the same call, and the readme lists both under the same option list, so a service that has to talk to a host with digest auth does not need a second library.
Then there is rejectUnauthorized, which defaults to true, meaning server certificates are verified. Setting it to false allows self-signed certificates. This is the single option in the list most likely to be copy-pasted into production by someone debugging a staging box, and it applies only to the top-level request and curl when neither dispatcher nor socketPath is set. socketPath, defaulting to null, lets the request target a unix socket service instead of a TCP host, which is how you would talk to a local service without going through the network stack.
The compressed option, defaulting to false, accepts gzip and br response content and decodes it automatically. Most modern servers send compressed responses, so leaving it off means you are paying bandwidth for content you then cannot read as text.
Request bodies: data, content, stream, files
urllib gives you four different ways to express a body, and the rules between them are worth internalising because they interact.
data is an object that is stringified automatically. For a GET request it becomes a query string, so the example below produces a URL with a query string of a=hello&b=world:
await request('https://example.com', {
method: 'GET',
data: {
a: 'hello',
b: 'world',
},
});For POST, PATCH and PUT, the data is stringified into application/x-www-form-urlencoded by default when the content-type header is not set, and if the content-type header is application/json then the data is JSON.stringify-ed instead.
content is for when you want to build the body yourself, and if it is set then data is ignored. The documentation notes that if you want to send a JSON body you should stringify it yourself, and it sets the Content-Type header explicitly:
await request('https://example.com', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
content: JSON.stringify({
a: 'hello',
bThe stream option takes a stream.Readable to pipe to the remote, and when it is set both data and content are ignored. The writeStream option takes a stream.Writable that the response stream is piped into, with the callback called with data set to null after the write finishes. files is for multipart/form-data uploads, accepting an array or an object of ReadStream, Buffer or String values, based on formstream, and it switches the method to POST when no method is set.
The contentType and dataType options handle the header side of the same problem. contentType is the type of request data and could be json, and the note is that you should not use application/json there, because setting json auto sets the Content-Type: application/json header. dataType is the type of response data, could be text or json, and setting json auto sets the Accept: application/json header and parses the body into an object.
There is one more subtlety worth knowing: fixJSONCtlChars, defaulting to false, fixes control characters from U+0000 through U+001F before a JSON parse of the response. It exists because some servers return slightly invalid JSON, and turning it on is a deliberate looseness rather than a default.
Versioning: a 4.x line and a 2.x line released on the same day
The release history is unusual and worth understanding before you pin a version. The most recent tags are v4.9.1 and v2.44.1, both dated 2026-06-13, and before that v4.9.0 from 2025-12-13.
Two maintained lines on the same day means the 2.x line is still receiving releases while 4.x is the current one. That is a deliberate choice by the maintainers, and it is the usual pattern for a widely depended on library: the older major stays alive for consumers who have not migrated, and new work lands on the newer major. The package manifest in the repository still reads version 4.9.0, with a description that names undici as the base, and the author is fengmk2.
For a new integration, the choice is the 4.x line. For an existing project on 2.x, the important question is what changed in the major version, and the CHANGELOG.md in the repository is where that is written down.
The project is MIT licensed, the repository is not archived, and the last push was on 2026-10-19, so the code is being worked on well past the 4.9.1 tag. The tooling around it is modern: pnpm with a workspace file, renovate for dependency updates, a Vite based build and test runner through the vp commands, vitest for tests, codecov for coverage, and a fallow dead-code check.
Editorial conclusion
urllib fits a Node service that makes many outbound HTTP calls and needs connect and read timeouts, a redirect cap, digest auth or multipart uploads without configuring a client object, and it does not fit a project that wants a small dependency-free fetch wrapper or that is still on CommonJS or on Node before 22.19.0. Before adopting it, read the timeout and rejectUnauthorized options carefully since both change failure behaviour silently, and check the per-request options against the ones you would otherwise set globally on an undici dispatcher.
Frequently asked questions
How do I install and use urllib in Node?
Run npm install urllib, then import the request function with import { request } from 'urllib' and await it. The call returns data and res, with data a Buffer by default. The library is ESM-only and requires Node.js 22.19.0 or later, used from an .mjs file or a .js file with "type": "module".
How do I set separate connect and read timeouts in urllib?
The timeout option takes a number or an array in milliseconds for the connecting phase and the response receiving phase, defaulting to 5000. A single value applies to both phases, and an array such as timeout: [3000, 5000] sets a three second connect timeout and a five second response timeout.
Can urllib do digest authentication and file uploads?
Yes. The auth option takes a username:password string for HTTP Basic Authorization, and digestAuth takes one for HTTP Digest Authorization. The files option sends ReadStream, Buffer or String values as multipart/form-data based on formstream, and uses the POST method if no method is set.
What does urllib do with JSON request and response bodies?
For a request, contentType set to json auto sets the Content-Type: application/json header, and if the content-type header is already application/json the data option is JSON.stringify-ed. For a response, dataType set to json auto sets the Accept: application/json header and parses the body into an object, while the default callbacked data is a Buffer.
How does urllib handle redirects and self-signed certificates?
followRedirect defaults to true and maxRedirects defaults to 10, with formatRedirectUrl letting you rewrite the target. rejectUnauthorized defaults to true so server certificates are verified, and setting it to false allows self-signed certificates, but that option only applies to the top-level request and curl when neither dispatcher nor socketPath is set.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/node-modules-urllib)Was this useful?