url.parse() is deprecated in Node.js as DEP0169, and since Node 24.0.0 calling it prints a runtime warning. The catch is that the warning only fires when the call comes from your own code, never from node_modules. The replacement is the WHATWG URL class: new URL(input, base) throws on bad input, and URL.parse() returns null instead.
That second sentence means a service can call url.parse() on every request without a single line in the logs. We ran the same calls through Node 24.12.0, 24.22.2 and 26.13.0 (macOS, arm64) to see where the warning appears, where it stays quiet, and what breaks when you swap in new URL(). Node 22 was not run; its docs still list DEP0169 as documentation-only, so expect no warning there by default.
What does the DEP0169 warning say, and what does it cover?
This is the exact text all three versions printed:
(node:26263) [DEP0169] DeprecationWarning: `url.parse()` behavior is not standardized and prone to errors that have security implications. Use the WHATWG URL API instead. CVEs are not issued for `url.parse()` vulnerabilities.
The deprecations page gives the history. Documentation-only from v19.0.0 and v18.13.0, then an “Application (non-node_modules code only)” deprecation from v24.0.0. The same v24.0.0 change pulled in url.resolve() and url.format(urlString), because both call url.parse() internally. url.format() with an object is still only “Legacy”, and in our runs it printed nothing.
Two behaviours make the warning less useful than it looks. It fires once per process, so node --trace-deprecation points at the first call site and nothing after it; a file with two calls produced one stack, for line 2. And --throw-deprecation turned the warning into an exit code of 1, but only after the synchronous code around the call had already run.
Why is there no url.parse warning for some calls?
The docs define an Application deprecation as one that warns “the first time the deprecated API is used in code that’s not loaded from node_modules”. What counts as “loaded from node_modules” changed inside the 24.x line, and that is where the surprise is.
| Call | 24.12.0 | 24.22.2 | 26.13.0 |
|---|---|---|---|
url.parse() in your own file | warns | warns | warns |
url.parse() inside a dependency | silent | silent | silent |
Dependency call, with --pending-deprecation | silent | silent | silent |
Your Express 5.3.0 route handler calls url.parse(req.url, true) | silent | warns | warns |
The last row is the one to worry about. In 24.12.0, lib/url.js calls isInsideNodeModules(100, true), and in our tests any node_modules frame further up the stack was enough to silence the warning. Express calling your handler counts. The same handler with an await before the call did warn on 24.12.0, because the framework frames were gone from the stack by then. Node changed the check in PR #60991, “isInsideNodeModules should test on the first non-internal frame”, which the v24 changelog lists under 24.13.1. So a service on 24.12.0 can have url.parse() in every route handler and never log it.
The --pending-deprecation row departs from the docs. They say that flag makes Application deprecations warn for node_modules code too. It did not for DEP0169 on any version we ran, and the 26.13.0 source explains why: the url.parse() check reads !urlParseWarned && !isInsideNodeModules(4) and never looks at the flag.
How do you find url.parse calls in dependencies?
Since Node will not tell you, wrap the functions yourself before anything else loads. This preload logs one stack per call site, including from node_modules:
// Preload: node --require ./trace-legacy-url.cjs server.js
// Prints one stack per call site of url.parse(), url.resolve() and
// url.format(string), including calls from node_modules.
const url = require('node:url');
const { syncBuiltinESMExports } = require('node:module');
const seen = new Set();
function wrap(name, shouldLog = () => true) {
const original = url[name];
url[name] = function traced(...args) {
if (shouldLog(args)) {
const limit = Error.stackTraceLimit;
Error.stackTraceLimit = 3;
const site = {};
Error.captureStackTrace(site, traced); // start at the caller
Error.stackTraceLimit = limit;
if (!seen.has(name + site.stack)) {
seen.add(name + site.stack);
console.error(`[legacy url.${name}]`, site.stack);
}
}
return original.apply(this, args);
};
}
wrap('parse');
wrap('resolve');
wrap('format', (args) => typeof args[0] === 'string');
// Point `import { parse } from 'node:url'` at the wrappers too.
syncBuiltinESMExports();
url.resolve() gets its own wrapper because it calls the internal parser directly, so wrapping url.parse alone misses it. Error.captureStackTrace(site, traced) drops the wrapper’s own frame, so each stack starts at the code that made the call, under a bare Error header. syncBuiltinESMExports() is what makes ES module named imports see the change. We tested three fake dependencies: a CommonJS url.parse(), an ESM import { parse } from 'node:url', and a destructured const { resolve } = require('url'). The tracer caught all three on all three versions.
Two limits. It only sees code paths that actually run, so boot the app and run the test suite or a staging smoke test through it. And on 24.22.2 and 26.13.0 the tracer itself trips one DEP0169 warning, because the first non-internal frame is now the tracer file. Ignore that one.
How do you replace url.parse with new URL?
Most call sites are one of two shapes: a request path from req.url, or a string from outside the process. These helpers cover both, plus the two legacy fields people most often depend on:
// Replacements for the common url.parse() call sites.
// url.parse(req.url, true) in an HTTP handler. req.url is a path,
// so it needs a base. The base only fills in protocol and host;
// read pathname and searchParams from the result, never origin.
export function requestUrl(req) {
return new URL(req.url ?? '/', 'http://localhost');
}
// url.parse(input) on a string from outside (a form, a webhook,
// a CSV). URL.parse() returns null instead of throwing.
export function parseHttpUrl(input) {
const parsed = URL.parse(String(input).trim());
if (!parsed) return null;
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null;
return parsed;
}
// The legacy `auth` field: decoded, and null when absent.
export function legacyAuth(u) {
if (!u.username && !u.password) return null;
const user = decodeURIComponent(u.username);
return u.password ? `${user}:${decodeURIComponent(u.password)}` : user;
}
// The legacy `query` object from url.parse(s, true): repeated keys
// become arrays. Object.fromEntries(searchParams) keeps only the last.
export function legacyQuery(searchParams) {
const out = Object.create(null);
for (const key of new Set(searchParams.keys())) {
const all = searchParams.getAll(key);
out[key] = all.length > 1 ? all : all[0];
}
return out;
}
URL.parse() was added in v22.1.0 according to the URL docs; we ran it on 24 and 26 only. A node:test file comparing these helpers against url.parse() passed 4 of 4 on all three versions. These are the differences it pins down, each one taken from that run.
Relative and schemeless input throws
new URL('/products?id=1&id=2') throws TypeError with code ERR_INVALID_URL and the message Invalid URL. So does shop.example/products, which url.parse() quietly treats as a pathname. Pass a base for request paths. The Node docs show a version that builds the base from x-forwarded-host, and warn it assumes a reverse proxy you trust; if you only need the path and query, a fixed base avoids trusting the Host header at all.
Empty means empty string, not null
Where url.parse() returned null for search, hash and port, URL returns ''. Any if (parsed.search === null) check flips. The default port goes too: HTTPS://Shop.Example:443/a gave port: '443' from the legacy parser and port: '' with host: 'shop.example' from URL.
Credentials stay encoded
For https://user:p%[email protected]/, legacy auth was the decoded user:p@ss. URL gives username: 'user' and password: 'p%40ss', still percent-encoded.
Repeated query keys
url.parse('/p?id=1&id=2', true).query.id is ['1', '2'] on a null-prototype object. Object.fromEntries(searchParams) gives '2' and drops the first value without complaint. Use getAll(), or the helper above if downstream code expects the old shape.
IPv6 hosts keep their brackets
url.parse('http://[::1]:3000/').hostname is ::1; new URL() returns [::1].
javascript:alert(1) parses successfully in both APIs, which is why parseHttpUrl checks the protocol rather than trusting a non-null result.
What changes when you move from Node 24 to Node 26?
Bad ports stop being a warning. On both 24.x builds, url.parse('http://example.com:80abc/') returned host example.com and pathname /:80abc/, and printed DEP0170: “The URL http://example.com:80abc/ is invalid. Future versions of Node.js will throw an error.” On 26.13.0 the same call threw TypeError with code ERR_INVALID_ARG_VALUE. The deprecations page marks DEP0170 End-of-Life from v25.0.0.
If that string arrives in a request, the upgrade turns a log line into an exception in your handler. Port 99999, meanwhile, was accepted by url.parse() on all three versions and rejected by new URL().
Should you run the Node codemod?
The docs offer npx codemod@latest @nodejs/node-url-to-whatwg-url. We did not run it: the CLI pulls a native binary that npm lists at about 135 MB unpacked for macOS arm64, which was more than this check needed. Its README shows .auth rewritten to `${myURL.username}:${myURL.password}`. Going by the comparison above, that string stays percent-encoded and comes out as : when there are no credentials, where the legacy field was null. It also shows url.parse(x) becoming new URL(x), which throws for a path like req.url. If you use it, review every rewritten call that receives a path or reads auth.
What we would do
Run the tracer against your test suite on the Node version you deploy, fix your own calls first, then upgrade or replace any dependency it names. Do that before moving to Node 26, because of the DEP0170 change, and do not read a quiet 24.12.0 log as proof the code is clean. If a dependency you cannot replace still calls url.parse(), keep it, but send it hostile input with a bad port in a test on 26 before you upgrade. Calls to url.format() with an object can wait; they are legacy, not deprecated, and do not warn.
For more on testing the replacement helpers, our guide to the Node built-in test runner covers the setup we used, and our URLPattern routing write-up covers matching paths once you have a parsed URL.
We build and upgrade Node.js services, including the dull but risky work of moving them between LTS lines without surprises in production. If you have a 24.x service heading for 26, our Node.js development work covers that.