Node fetch fails with relative URLs – why and how to fix it

Summary

A Node.js script that works perfectly when called from a browser fails with

TypeError: Failed to parse URL from /time

when run standalone. The culprit is a relative URL ('/time') passed to fetch().
Node’s fetch (via undici) requires an absolute URL because there is no document base to resolve against.


Root Cause

  • fetch('/time', options) is a relative URL.
  • In a browser the base URL is the page’s origin, so the request resolves to http://<host>/time.
  • In Node there is no implicit base; the URL must be fully qualified (http://localhost:80/time).
  • The underlying new Request('/time') throws ERR_INVALID_URL because the string cannot be parsed as a valid absolute URL.

Why This Happens in Real Systems

  • Runtime differences – Browsers have a document location; Node runs scripts without a document context.
  • Fetch specification – The WHATWG fetch spec expects a request input that is either a Request object or an absolute URL string. Relative URLs are only allowed when a base URL is provided (via RequestInit.baseUrl in some polyfills, not in native Node).
  • Environment defaults – Node’s undici implementation does not infer a base from process.cwd() or __dirname; it strictly validates the URL.

Real-World Impact

  • Cron jobs or scheduled scripts that call internal APIs will fail silently or crash, leading to missed data updates.
  • CI/CD pipelines that run Node‑based integration tests may report spurious failures.
  • Debugging time is wasted chasing “network” issues when the problem is simply a malformed URL.
  • Error propagation – Unhandled TypeError can terminate the entire process if not caught.

Example or Code (if necessary and relevant)

// ❌ Broken – relative URL, works only in a browser
const response = await fetch('/time', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ key: 'value' })
});

// ✅ Fix – absolute URL
const response = await fetch('http://localhost:80/time', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ key: 'value' })
});

How Senior Engineers Fix It

  • Always use a fully qualified URL when calling fetch from Node.

  • Centralise base URL in a config constant:

    const BASE_URL = process.env.API_BASE_URL || 'http://localhost:80';
    const response = await fetch(`${BASE_URL}/time`, options);
  • Validate URLs early with a helper:

    function buildUrl(path) {
      const base = process.env.API_BASE_URL || 'http://localhost:80';
      return new URL(path, base).toString();
    }
  • Add error handling around fetch calls:

    try {
      const res = await fetch(buildUrl('/time'), options);
      // …
    } catch (err) {
      console.error('Fetch failed:', err.message);
      // retry / alert logic
    }
  • Document the assumption that the runtime has no implicit base URL.


Why Juniors Miss It

  • Browser‑centric mental model – They assume the same environment as a web page, where relative URLs resolve automatically.
  • Lack of runtime awareness – Not yet familiar with the differences between Node’s globalThis.fetch and the browser’s implementation.
  • Copy‑paste habits – Reusing frontend snippets without adapting them to a server‑side context.
  • Insufficient debugging – The error message mentions “Failed to parse URL”, but without understanding that a base URL is missing, they chase network or server issues instead.

Leave a Comment