Node.js · advanced

Node.js ERR_INVALID_URL: trace malformed URL construction

Node.js throws ERR_INVALID_URL when url.parse, WHATWG URL, http.request, https.request, fetch, import, or worker bootstrap code receives a string the engine cannot parse as an absolute URL with a recognized scheme. This guide explains the observable failure boundary, how to trace the malformed URL back to its construction site, and how to verify the fix without changing runtime semantics.

The symptoms

  • Node.js process aborts the operation with `Error: ERR_INVALID_URL` and a message such as "Invalid URL" before any socket connect or module load resolves, with no DNS lookup or TLS handshake initiated.
  • Tests pass locally but fail in CI or production with the same ERR_INVALID_URL, indicating environment-specific URL string assembly rather than a logic bug in the calling code.
  • Stack frames at the top of the trace are inside `node:internal/url/parsers`, `node:internal/url/whatwg-url`, or near `new URL(...)`, `url.parse`, `http.request`, `https.request`, or `fetch` rather than inside application business logic.
  • The URL string logged at the throw site contains obvious malformations: a missing scheme, double slashes after the scheme, unencoded spaces, raw Unicode, a trailing colon, or a `:` followed by digits that Node interprets as port without an authority host.
  • Some calls in the same module succeed while others fail with ERR_INVALID_URL, isolating the defect to a single string-construction path rather than the runtime or import system.

Likely causes

  • An absolute URL is required (e.g., `http.request`, `https.request`, `new URL`, dynamic `import('...')` of an HTTP specifier) but the caller passed a path, relative reference, hostname-only string, or empty string.
  • String interpolation produced a malformed URL: missing scheme after a conditional branch, duplicated slashes (`https:///api`), stray whitespace from a config file or environment variable, or a trailing slash that was added before the path segment that already started with one.
  • An unrecognized or unsupported URL scheme was supplied: custom schemes such as `app+foo:`, `redis-cli://`, or schemes Node's URL parser rejects; custom fetch dispatchers and protocols may also surface this for non-special schemes.
  • Non-ASCII or control characters in a hostname, path, or query were not percent-encoded, causing WHATWG URL parsing to reject the input under the URL spec rules Node implements.
  • A base URL argument was missing or itself invalid in a `new URL(relative, base)` call, so the parser had no absolute reference to resolve against.
  • Worker bootstrap, dynamic import, or loader code assembled a `file`-scheme, `data:`-scheme, or `node:` specifier with a path that was not absolute per the platform's path rules, which Node treats as invalid for those schemes.

First ten minutes

  1. 01Capture the exact ERR_INVALID_URL message and full stack trace from the process stderr or the structured log entry; record the file, line, and column from the top application frame, not just the internal Node frame.
  2. 02Identify which Node API raised the error: `new URL`, `url.parse`, `http.request`, `https.request`, global `fetch`, `import()`, or a loader/worker entrypoint, because each has slightly different URL acceptance rules.
  3. 03Print or log the offending string verbatim with character codes (`Buffer.from(input).toString('hex')` or `JSON.stringify`) so invisible whitespace, BOMs, and encoding artifacts are visible; do not only print the string.
  4. 04Determine whether the input was meant to be absolute or relative, and whether a base URL was intended; cross-check the call site signature and any optional second argument.
  5. 05Reproduce the failure in a minimal Node REPL or scratch script using the exact string and the exact API, to separate a data defect from a code-path defect.
  6. 06Diff the URL string between environments (local vs. CI vs. production) at the same source location to find environment-specific substitution of scheme, host, port, or path.

Evidence to collect

  • The full error object including `code: 'ERR_INVALID_URL'`, `name`, `message`, and `stack`; capture the top three frames and note whether any frame is inside `node:internal/url/*`.
  • The offending URL string, captured verbatim with explicit length, hex representation of the first and last 32 bytes, and detection of any whitespace or non-printable code points.
  • The Node.js major version and the specific API that threw (e.g., `node:http`, `node:url`, `globalThis.fetch`, `vm.Module`, dynamic `import()`).
  • The arguments passed at the call site, including any base URL argument to `new URL(relative, base)`, and whether the second argument was omitted.
  • The configuration source feeding the URL (env var name, config file key, CLI flag) and the resolved value at the moment of failure, to detect silent coercion or trimming.
  • Process working directory and platform, for `file:` and dynamic-import URL forms where relative paths matter.

Where to look

  • The boundary between application code and Node's URL parser: the `new URL(...)`, `url.parse`, `http.request`, `https.request`, global `fetch`, and dynamic `import()` call sites, plus any thin wrapper utility that centralizes URL construction.
  • Configuration and environment variable handling at process start, where scheme, host, port, and path fragments are read from env, files, secret managers, CLI flags, or feature flags and concatenated into the final URL string.
  • String-assembly helpers such as template literals, path joiners, and base/relative combiners, especially conditional branches that may omit the scheme in some environments (e.g., dev vs. prod toggles).
  • Loader, worker, and dynamic-import boundaries, where specifier strings must be valid absolute URLs or platform-correct paths for the scheme used (`file:`, `data:`, `node:`, `http:`).
  • Cross-platform path code, where Windows backslashes or drive letters may appear in a URL that requires forward slashes and a host component.
  • HTTP client wrapper libraries and SDKs that hide the `http.request`/`fetch` call, because the visible caller may be one frame away from the real URL construction site.

Diagnostic steps

  1. 01From the stack trace, identify the topmost application frame and the argument it passed to a Node URL/HTTP API; record the variable name and the line number.
  2. 02Reconstruct the string at that frame with the same inputs the running process used (env values, config values, request values) and parse it with `new URL(candidate)` in a minimal Node script using the same Node version to confirm the parser rejection.
  3. 03If the URL is meant to be relative, decide whether a base URL should be supplied; without a valid base, `new URL` will throw ERR_INVALID_URL because relative references cannot resolve to an absolute URL.
  4. 04If the URL is meant to be absolute, validate the scheme against Node's recognized set for the target API: `http:` and `https:` for HTTP clients, `file:`, `data:`, `node:` for import/loader APIs, plus any custom schemes registered via dispatcher hooks.
  5. 05Inspect the string byte-by-byte to detect leading/trailing whitespace, BOMs, embedded nulls, smart quotes, or non-ASCII characters that were not percent-encoded; each is a documented rejection cause in the URL spec Node implements.
  6. 06Compare the malformed input to a known-good URL from the same code path in a passing environment to localize the divergence to scheme, host, port, path, or query.
  7. 07If the failure is intermittent, correlate with feature flags, env overrides, and config reload events to identify the configuration branch that produces the malformed string.

Common mistakes

  • Assuming `url.parse` and `new URL` accept the same inputs; `url.parse` is a legacy API with different acceptance rules and does not throw ERR_INVALID_URL for relative strings the way `new URL` does.
  • Logging only the human-readable URL string and not its raw bytes, which hides trailing newlines, BOMs, and stray whitespace introduced by config files or shell-quoted env values.
  • Wrapping every URL construction in a try/catch that swallows ERR_INVALID_URL and retries with a guessed scheme, which masks the root cause and can produce silently wrong requests.
  • Switching from `new URL` to `url.parse` to "make it stop throwing," trading a clear error for downstream bugs because the legacy parser accepts strings `new URL` correctly rejects.
  • Adding a base URL argument unconditionally as a workaround, which can change the resolved host and path and turn a parse-time error into a silent routing bug.
  • Diagnosing the error as a network or DNS issue; ERR_INVALID_URL is raised before any socket is opened, so no DNS, TCP, or TLS evidence will appear for that call.

Safe fixes

  • If the input is meant to be relative, pass an explicit, validated base URL to `new URL(relative, base)` and assert at construction time that `result.origin` and `result.protocol` are the expected values.
  • If the input is meant to be absolute, ensure a scheme is always present and that scheme is one Node recognizes for the target API; reject unknown schemes at the construction helper rather than at the call site.
  • Normalize inputs before parsing: trim surrounding whitespace, reject empty strings, strip BOMs, and percent-encode any non-ASCII characters in path and query segments using `encodeURI` or `encodeURIComponent` as appropriate to the segment.
  • Centralize URL construction in a single helper that returns the parsed `URL` object plus its `origin` and `href`, and throws a domain-specific error with the offending string and the configuration source that produced it.
  • Validate configuration values for scheme, host, and port at process start; fail fast with a descriptive error rather than letting a malformed config string reach a URL parser at request time.
  • When port is included as a numeric literal, ensure a host precedes the colon; strings like `:443` are parsed as scheme-relative references and rejected by `new URL` without a base.

Prove the fix

  1. 01Re-run the exact code path that previously raised ERR_INVALID_URL and confirm the same input now produces a valid `URL` object with `protocol`, `host`, and `origin` fields populated as expected; the Node API returns normally without throwing.
  2. 02Execute a regression test that exercises the construction helper with the previously failing input and asserts both successful parse and a specific `origin` value, so the fix is locked in at the unit-test boundary.
  3. 03Run the integration test for the failing call (HTTP request, dynamic import, worker bootstrap) end-to-end and confirm the operation proceeds past URL parsing, with at least one successful socket connect or module load recorded in logs.
  4. 04Add a negative test that feeds known malformed strings (empty, whitespace-only, scheme-less, bad scheme, unencoded space) into the helper and asserts the helper throws a domain-specific error rather than allowing ERR_INVALID_URL to surface from Node internals.
  5. 05Confirm in the process logs that no further ERR_INVALID_URL entries appear for the same call site across a representative sample of requests or imports after the fix is deployed.

Prevention and next steps

  • Adopt a single URL-construction helper per service that takes typed inputs (scheme, host, port, path, query) and returns a parsed `URL`, eliminating ad-hoc string concatenation in request paths.
  • Validate configuration at startup: require explicit scheme and host for every external endpoint, and reject empty or whitespace-only values before they reach runtime code.
  • Encode at boundaries, not deep inside business logic: percent-encode path and query segments where user or external input enters the URL, using the encoder appropriate to the segment.
  • Pin the Node.js major version in CI and document the URL-acceptance rules your code relies on, so upgrades that change URL parser behavior are caught by tests rather than production traffic.
  • Add unit tests that mirror the real-world failure modes: missing scheme, double slashes, trailing whitespace, BOM-prefixed strings, unencoded spaces, and relative references without a base URL.

Safe commands and checks

node -e "try { new URL(process.argv[1]); console.log('ok'); } catch (e) { console.log(e.code, e.message); }" "<candidate-url>"
node -e "const s = process.argv[1]; console.log(JSON.stringify({ len: s.length, hex: Buffer.from(s).toString('hex') }));" "<candidate-url>"
node --version
node -e "console.log(process.versions.node)"