node-fetch-h2
node-fetch-h2 is a fork of node-fetch 2.3.0 with one change: where node-fetch calls http.request or https.request, this build calls http2-client's request, which negotiates HTTP/2 and falls back to HTTP/1.1. Everything else is node-fetch verbatim, including the README, which still describes node-fetch and links to bitinn/node-fetch. Two versions have ever been published, both in November 2018, and the npm latest tag points at a prerelease, 2.3.1-0. It reaches 4.4 million weekly installs almost entirely through OpenAPI tooling that depends on it, not through people choosing it.
A one-line fork of node-fetch 2.3.0 that swaps in an HTTP/2-capable transport, then stopped in November 2018 and never picked up the security fixes upstream shipped afterwards. Treat 4.4 million weekly downloads as a transitive dependency to override, not as a recommendation.
Use it if
- You already depend on OpenAPI tooling that pulls this in and you need to know what it is before deciding whether to override it
- You are on a Node version old enough to lack global fetch and you need HTTP/2 with automatic HTTP/1.1 fallback behind the familiar fetch signature
- You have code written against node-fetch 2 and want the same Response, Headers and Request classes while an upstream server speaks HTTP/2
- You are auditing a lockfile and need to establish which package is responsible for the http2-client dependency you did not expect
- You are on Node 18 or newer, where fetch is a global backed by undici and no package is needed for the common case
- You care about redirect security: this fork predates node-fetch 2.6.7, and the shipped code copies every header, Authorization and Cookie included, into the redirected request with no same-origin check
- You rely on the size limit or timeout options, because the redirect path here builds the follow-up request without them, the bug node-fetch fixed in 2.6.1
- You want a package anyone is watching: one GitHub star, no forks, no issues, last push November 2018, and no publish since
- You want reliable docs, since the bundled README is node-fetch's own file with nothing about HTTP/2, the one feature this fork exists for
- You need real HTTP/2 control such as stream multiplexing, server push or per-connection settings, which the fetch surface does not expose at all
Setup reality
Installing is one command and the API is node-fetch 2, so a working example takes a minute. The problems start with the version number. The npm latest tag is 2.3.1-0, a prerelease, so npm install node-fetch-h2 with no range gives you a prerelease build, while a written range of ^2.3.0 skips it and resolves to 2.3.0 instead, because semver ranges exclude prereleases unless the range names one. Two different install paths, two different versions, and no changelog entry explaining the difference. Next, read the README knowing it is not about this package: it is node-fetch's file, copied unchanged, still asking for v2 maintainers and still pointing at bitinn/node-fetch. The word HTTP/2 does not appear in the usage docs, so the only description of the fork's actual behaviour is the one-line npm description and the source, where h2.request from http2-client replaces the node core request call. That indirection is where surprises live: connection pooling, ALPN negotiation and fallback are http2-client's behaviour, not node-fetch's, and the agent option you pass through means something different once a request is upgraded. Then there is what did not get forked. This is node-fetch 2.3.0 from 2018, so the fixes node-fetch shipped afterwards are absent, and two of them are visible in the shipped file. The redirect handler builds its next request with headers: new Headers(request.headers) and no check on whether the redirect target is the same host, so credentials follow you anywhere; node-fetch added that check in 2.6.7. The same redirect options object omits size and timeout, so a response-size cap you set is not enforced after a redirect; node-fetch fixed that in 2.6.1. The Bundlephobia figure of roughly 0.3 KB gzipped is not a useful number either, because it measures the browser field, which is a small shim that hands you the global fetch. On the server you are loading a 39 KB rolled-up file plus http2-client.
Patterns
Fetch JSON with the node-fetch 2 APIbasic-request
const fetch = require('node-fetch-h2')
const res = await fetch('https://api.example.com/users')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const users = await res.json()Identical to node-fetch 2. A non-2xx status is not an error here, so the res.ok check is yours to write.
Decide which of the two published builds you getpin-the-version
// npm install node-fetch-h2 -> 2.3.1-0 (latest tag is a prerelease)
// "node-fetch-h2": "^2.3.0" -> 2.3.0 (caret ranges skip prereleases)
// "node-fetch-h2": "2.3.1-0" -> the prerelease, explicitly
{
"dependencies": {
"node-fetch-h2": "2.3.0"
}
}Pin exactly. Otherwise a fresh install and a lockfile install can disagree about which build is in the tree.
Send a JSON bodypost-json
const fetch = require('node-fetch-h2')
const res = await fetch('https://api.example.com/users', {
method: 'POST',
body: JSON.stringify({ name: 'ada' }),
headers: { 'Content-Type': 'application/json' },
})Content-Type is not inferred from a string body. Omit it and the server sees text/plain.
Stop credentials following a cross-host redirectguard-credentialed-redirects
const fetch = require('node-fetch-h2')
const { URL } = require('url')
let res = await fetch(url, { redirect: 'manual', headers: { Authorization: token } })
while (res.status >= 300 && res.status < 400) {
const next = new URL(res.headers.get('location'), url)
const headers = next.host === new URL(url).host ? { Authorization: token } : {}
res = await fetch(next.href, { redirect: 'manual', headers })
}The shipped redirect path copies all headers to the new request with no host comparison. Handle redirects yourself, or move to a client that has the 2.6.7 fix.
Keep a size cap that redirects would dropenforce-response-size
const fetch = require('node-fetch-h2')
const res = await fetch(url, { size: 5 * 1024 * 1024, redirect: 'manual' })
// after a manual hop, pass size again on the next call
const len = Number(res.headers.get('content-length') || 0)
if (len > 5 * 1024 * 1024) throw new Error('response too large')The redirect handler builds its follow-up request without size or timeout, so a cap set on the first call is not carried across the hop.
Cancel with an AbortSignalabort-a-request
const fetch = require('node-fetch-h2')
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 5000)
try {
const res = await fetch(url, { signal: controller.signal })
return await res.json()
} catch (err) {
if (err.name === 'AbortError') return null
throw err
} finally {
clearTimeout(timer)
}AbortController is global from Node 15 on; on the Node 6 to 14 range this package targets you need the abort-controller polyfill.
Pipe a response body to diskstream-to-file
const fetch = require('node-fetch-h2')
const fs = require('node:fs')
const { pipeline } = require('node:stream/promises')
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await pipeline(res.body, fs.createWriteStream('out.bin'))res.body is a Node Readable, not a WHATWG ReadableStream. Code written for global fetch expects the latter and will not work unchanged.
See which protocol actually answeredinspect-the-transport
const fetch = require('node-fetch-h2')
const res = await fetch('https://http2.example.com/')
console.log(res.status, res.headers.raw())
// http2-client negotiates h2 via ALPN and falls back to HTTP/1.1;
// the fetch surface does not report which one was usedThere is no exposed flag for the negotiated protocol. Confirm on the server side or with a packet capture if it matters.
Separate network failures from HTTP errorshandle-errors
const fetch = require('node-fetch-h2')
try {
const res = await fetch(url)
if (!res.ok) return { kind: 'http', status: res.status }
return { kind: 'ok', body: await res.json() }
} catch (err) {
return { kind: 'network', code: err.code, type: err.type }
}FetchError carries type and, for socket-level failures, code. A 500 response never throws; only transport and parse failures do.
Replace it where a dependency pulled it inoverride-transitive-dep
// package.json, npm 8.3+
{
"overrides": {
"node-fetch-h2": "npm:node-fetch@^2.7.0"
}
}
// pnpm
// "pnpm": { "overrides": { "node-fetch-h2": "npm:node-fetch@^2.7.0" } }The API is the same, so aliasing usually just works and you gain the redirect fixes. You lose HTTP/2 negotiation, so verify against any upstream that requires it.
Drop the package on Node 18 or newermigrate-to-global-fetch
-const fetch = require('node-fetch-h2')
const res = await fetch(url)
-await pipeline(res.body, fs.createWriteStream('out.bin'))
+const { Readable } = require('node:stream')
+await pipeline(Readable.fromWeb(res.body), fs.createWriteStream('out.bin'))Call sites mostly stay the same. The real work is body handling, since global fetch gives a WHATWG stream where this package gives a Node Readable.
Get HTTP/2 from a maintained client insteadhttp2-with-undici
const { Client } = require('undici')
const client = new Client('https://http2.example.com', { allowH2: true })
const { statusCode, body } = await client.request({ path: '/users', method: 'GET' })
console.log(statusCode, await body.json())
await client.close()undici's HTTP/2 support is opt-in per client rather than automatic. In exchange you get current releases and explicit connection control.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| undici | npm | You want the HTTP client that backs Node's own global fetch, with maintained releases and opt-in HTTP/2 support |
| fetch-h2 | npm | You specifically want a fetch-shaped API designed for HTTP/2 from the start rather than a 2018 fork of an HTTP/1 client |
| node-fetch | npm | You want the upstream this was forked from, with the redirect and size fixes it has received since 2018, and HTTP/1.1 is enough |
| got | npm | You want retries, hooks, pagination and timeouts as first-class features instead of a minimal fetch surface |