Geonode logo
Geonode Team

Geonode Team

Updated: September 2, 2026

Published: 2026-09-02

Setting Headers with node-fetch

Node has had a built-in `fetch` since v18, which means most people searching for `node-fetch` no longer need the package at all. Setting headers is straightforward in either. What is less obvious is when `set` differs from `append`, which headers you are not permitted to set, and why a header you configured does not appear on the wire. This guide covers all of it, plus the proxy behaviour that differs from every other HTTP client in Node.

Our stake is one specific gotcha: we are Geonode and we sell proxies, and Node's built-in fetch ignores the HTTP_PROXY and HTTPS_PROXY environment variables entirely. Every other HTTP client in the ecosystem honours them, so people configure a proxy, see requests succeed, and assume it is working — while the traffic goes directly. There is no warning and no error. The fix is a few lines and it is in the proxy section below. If you are proxying Node fetch traffic and have not explicitly set a dispatcher, your requests are almost certainly not going through your proxy.

Native fetch or the node-fetch Package?

Start here, because it determines what you install.

Node's documentation records fetch as added in v17.5.0 and v16.15.0, out from behind the --experimental-fetch flag as of v18.0.0, and "no longer experimental" as of v21.0.0. It is described as "a browser-compatible implementation of the fetch() function based on undici, an HTTP/1.1 client written from scratch for Node.js". Headers, Request and Response follow the same timeline.

So on any currently supported Node version, fetch is a global and you do not need a dependency.

The node-fetch package remains useful in two cases: maintaining code on an older runtime, and needing one of the small number of behaviours where its API differs. Note that version 3 is ESM-only, which trips up projects still using require.

Everything below applies to both, since the API is deliberately the same.

The Three Ways to Set Headers

A plain object — the common case and the one to use most of the time:

const res = await fetch("https://api.example.com/items", {
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer eyJhbG...",
    "Accept": "application/json",
  },
});

A Headers object — when you build the set up conditionally:

const headers = new Headers({ "Accept": "application/json" });
if (token) headers.set("Authorization", `Bearer ${token}`);
if (locale) headers.set("Accept-Language", locale);

const res = await fetch(url, { headers });

An array of pairs — useful when a header legitimately repeats:

const res = await fetch(url, {
  headers: [
    ["Accept", "application/json"],
    ["X-Trace", "a"],
    ["X-Trace", "b"],
  ],
});

All three are equivalent for the simple case. The Headers object earns its place when you need conditional logic or when you want to inspect what you built before sending.

set Versus append

The distinction that produces surprises.

MDN documents set() as setting "a new value for an existing header" and overwriting existing values, while append() "appends a new value to an existing header or adds it if not present".

const h = new Headers();
h.append("X-Custom", "one");
h.append("X-Custom", "two");
h.get("X-Custom");        // "one, two"

h.set("X-Custom", "three");
h.get("X-Custom");        // "three"

append accumulates; set replaces. For almost every header you send, set is what you want — sending two Authorization values is not a meaningful request. append matters for headers where multiple values are legitimate, and in practice that is a short list.

Header names are case-insensitive. MDN notes they are "matched by case-insensitive byte sequence" across every method, so h.get("content-type") and h.get("Content-Type") return the same value. Pick a convention for readability and stop worrying about it.

There is also has() to test presence, delete() to remove, and getSetCookie(), which returns an array of all Set-Cookie values — necessary because that header is the main case where several values genuinely coexist and a plain get() would concatenate them into something you cannot reliably split.

Headers You Cannot Set

The reason a header you configured does not appear.

MDN describes a "guard" on Headers objects that determines what may be modified. A standalone new Headers() has no restrictions. Headers attached to a Request allow modification of "non-forbidden request headers". And headers on a Response obtained "from Response.error(), Response.redirect(), or fetch()" are immutable — you cannot alter a response's headers after receiving it.

Forbidden request headers are those the runtime controls, and attempts to set them are silently ignored rather than raising an error. The list includes Host, Connection, Content-Length, Transfer-Encoding, Origin, Referer in some contexts, and the Sec- and Proxy- prefixed families.

Two practical consequences.

Silence is the failure mode. No exception, no warning; the header simply is not sent. If a server insists it is not receiving something you set, verify what actually went on the wire rather than re-reading your code.

Node is more permissive than a browser for some of these, because there is no origin to protect. Code that sets a header successfully in Node may find it dropped in a browser, which is a real portability trap for shared code.

To check what you actually sent, aim a request at a service that echoes it back:

const res = await fetch("https://httpbin.org/headers", {
  headers: { "X-Test": "value", "User-Agent": "MyBot/1.0" },
});
console.log(await res.json());

Reading Response Headers

The other half, and there is one behaviour worth knowing.

const res = await fetch(url);

res.headers.get("content-type");
res.headers.has("etag");

for (const [name, value] of res.headers) {
  console.log(name, value);
}

Iteration yields lowercase names, since the header set is normalised.

Set-Cookie needs special handling. Multiple cookies arrive as multiple headers, and a plain get("set-cookie") gives you them joined by commas — which is ambiguous, because cookie values may themselves contain commas in an Expires date. getSetCookie() exists for exactly this and returns an array:

const cookies = res.headers.getSetCookie();

Response headers are immutable. You cannot modify what fetch returned. If you need an altered version, construct a new Response.

And the check that matters more than any header: fetch does not reject on HTTP error statuses. A 404 or a 500 resolves normally, so res.ok must be tested before you interpret the body.

const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);

Skipping that is the direct cause of Unexpected token '<' on a .json() call — you parsed an error page.

Default Headers and What Node Adds

Node sets several headers automatically, and knowing which prevents confusion.

Host — derived from the URL, not settable. Connection — managed by the connection pool. Content-Length — computed from the body. Accept — defaults to */* unless you set it. Accept-Encoding — Node advertises compression support and transparently decompresses the response. User-Agent — Node sends its own by default, typically identifying undici.

That last one matters for anything talking to a third party. A default runtime user agent is an accurate identification and, for automated clients, a poor one — an honest name with a contact URL gets treated better than an anonymous runtime string:

headers: { "User-Agent": "AcmeBot/1.0 (+https://acme.example.com/bot)" }

A note on bodies: when you pass a FormData object, do not set Content-Type yourself. The runtime must generate it, because it contains the multipart boundary, and overriding it produces a request the server cannot parse. This is one of the most common causes of an unexplained 400 or 415.

Setting Headers for Every Request

For anything beyond a script, centralise it.

const DEFAULTS = {
  "Accept": "application/json",
  "User-Agent": "AcmeBot/1.0 (+https://acme.example.com/bot)",
};

async function api(path, options = {}) {
  const res = await fetch(`https://api.example.com${path}`, {
    ...options,
    headers: { ...DEFAULTS, ...options.headers },
  });
  if (!res.ok) {
    const body = await res.text();
    throw new Error(`HTTP ${res.status} ${path}: ${body.slice(0, 200)}`);
  }
  return res;
}

Two details in there earn their place. Spreading defaults first means a caller can override any of them, which is the behaviour you want. And including the first 200 characters of the error body turns an opaque status code into a message you can act on.

Note that the object spread is shallow and matches on the exact key string, so "content-type" in the caller's options will not override "Content-Type" in the defaults — you will send both, and the runtime will pick one. If callers may use arbitrary casing, build a Headers object instead and let its case-insensitive set() handle the merge properly.

Debugging Headers That Are Not Working

A sequence that resolves nearly every header problem in a few minutes, in the order that eliminates the most possibilities.

One — see what actually went on the wire. Nothing else in this list matters until you have done this. A header-echo service is the quickest route:

const res = await fetch("https://httpbin.org/headers", { headers: myHeaders });
console.log(JSON.stringify(await res.json(), null, 2));

If your header is absent here, it never left your process — it is forbidden, misspelled, or overwritten. If it is present here and the target says otherwise, something between you and the target is stripping it.

Two — build the Headers object and inspect it before sending. This separates "I constructed it wrongly" from "the runtime dropped it":

const h = new Headers(myHeaders);
console.log([...h.entries()]);

Constructing a Headers object applies the same normalisation the runtime will, so a name that survives here is a name that will be sent.

Three — check for accidental duplication. The shallow-merge trap: an object spread matches keys by exact string, so {...{"Content-Type": "a"}, ...{"content-type": "b"}} produces both entries. Build a Headers object and use set() if callers may supply arbitrary casing, since its case-insensitive matching does the merge correctly.

Four — reproduce it in curl. If the same request works from a terminal and not from Node, the difference is in your code rather than at the server:

curl -v -H "Authorization: Bearer $TOKEN" https://api.example.com/items 2>&1 | grep '^>'

Comparing the two > blocks side by side usually makes the difference obvious.

Five — read the whole response, not just the status. A 400 or 401 frequently carries a body explaining exactly which header was wrong, and code that discards it is throwing away the answer:

if (!res.ok) console.error(res.status, (await res.text()).slice(0, 300));

And check the redirect. fetch follows redirects by default, and some headers — Authorization in particular — are dropped when a redirect crosses to a different origin. If a request works against the final URL directly but not against the original, that is almost certainly why.

The Proxy Gotcha

The behaviour that differs from every other Node HTTP client, and the reason this section exists.

Node's fetch does not read HTTP_PROXY, HTTPS_PROXY or NO_PROXY. Setting them changes nothing. Requests go directly, they succeed, and nothing tells you the proxy was bypassed.

The fix is undici's ProxyAgent:

import { ProxyAgent, setGlobalDispatcher } from "undici";

setGlobalDispatcher(new ProxyAgent("http://user:pass@proxy.example.com:9000"));

// now every fetch in this process goes through the proxy
const res = await fetch("https://api.example.com/items");

For a single request rather than the whole process, pass a dispatcher per call:

const agent = new ProxyAgent("http://proxy.example.com:9000");
const res = await fetch(url, { dispatcher: agent });

Note that dispatcher is a Node-specific extension rather than part of the standard Fetch API, so code using it is not portable to a browser.

Always verify it took effect. Ask a service what address it sees, with and without the dispatcher:

const res = await fetch("https://api.ipify.org?format=json");
console.log(await res.json());

If the address does not change, the proxy is not in the path — and given that there is no error to alert you, this check is the only thing standing between a working configuration and a silently bypassed one. It is the same class of silent failure we wrote about in why testing proxies matters.

The behavioural differences between HTTP clients here are exactly the kind of thing we compared in axios vs fetch.

People Also Ask

How do I set headers with node-fetch?

Pass a headers object in the options: fetch(url, { headers: { "Authorization": "Bearer ..." } }). You can also pass a Headers instance or an array of name-value pairs. The same syntax works with Node's built-in fetch.

Do I still need the node-fetch package?

Usually not. Node has had a global fetch since v17.5.0, unflagged since v18 and stable since v21. Install the package only for older runtimes or for a specific behavioural difference — and note that version 3 is ESM-only.

What is the difference between headers.set and headers.append?

set replaces any existing value for that header; append adds another value, so two appends produce a comma-joined list. Use set for almost everything — append matters only for headers where multiple values are legitimate.

Why is my header not being sent?

Most likely it is a forbidden header the runtime controls — Host, Connection, Content-Length and the Sec- family among them. These are silently ignored rather than raising an error. Send a request to a header-echo service to see what actually went on the wire.

Are header names case-sensitive in fetch?

No. MDN specifies that header names are matched by case-insensitive byte sequence across every Headers method, so get("content-type") and get("Content-Type") are equivalent. Iterating a Headers object yields lowercase names.

How do I read multiple Set-Cookie headers?

Use res.headers.getSetCookie(), which returns an array. A plain get("set-cookie") joins them with commas, which is ambiguous because cookie values can themselves contain commas in an Expires date.

Why does Node fetch ignore my HTTP_PROXY setting?

Because it does not read those environment variables at all, unlike almost every other Node HTTP client. Use undici's ProxyAgent with setGlobalDispatcher, or pass a dispatcher per request — and then verify the exit address, since a bypassed proxy produces no error.

Should I set Content-Type when sending FormData?

No. The runtime generates it including the multipart boundary, and setting it yourself removes the boundary, producing a request the server cannot parse. This is a common cause of unexplained 400 and 415 responses.

Wrapping Up

Setting headers in Node is a one-line affair whichever API you use, and the built-in fetch means most projects no longer need a package for it at all.

Three behaviours account for nearly all the confusion. set replaces while append accumulates, and getting that backwards produces comma-joined header values that servers reject. Forbidden headers are dropped silently rather than raising an error, so a header the server is not receiving needs verifying on the wire rather than re-reading in your editor. And fetch resolves on HTTP errors, so res.ok has to be checked before the body means anything.

The Node-specific trap is the proxy one, and it is worth repeating because it fails so quietly: the built-in fetch ignores HTTP_PROXY entirely. If you need proxied traffic, set a dispatcher explicitly — and then confirm the exit address, because a configuration that does nothing looks exactly like one that works.

Setting Headers with node-fetch and Native Node fetch | Geonode