cross-fetch
cross-fetch is a switchboard: import it and you get a fetch function that works the same whether the code runs in Node, a browser, React Native, or a worker. It does not implement anything itself. Package.json fields point each environment at a different file, so Node resolves to a wrapper around node-fetch 2.x, browsers resolve to a bundle of GitHub's whatwg-fetch polyfill, and React Native gets its own entry. You can use it as a ponyfill (import fetch from 'cross-fetch' and nothing touches globals) or as a polyfill (import 'cross-fetch/polyfill' to assign global.fetch when it is missing). It became ubiquitous in the years when Node had no fetch at all, which is why it still shows up in tens of millions of installs a week, mostly through SDKs and GraphQL clients rather than direct use.
Reasonable only if you still support Node 16 or older, or ship a library that must run identically on React Native. On Node 18+ it is a legacy dependency that routes your requests through a 2023 build of node-fetch instead of the runtime's own client, and removing it is usually a delete-the-import change.
Use it if
- You maintain a library that must run unchanged on Node, browsers, and React Native, and you do not want to write the environment detection yourself
- You support Node versions older than 18, where there is no global fetch and a shim is the only option
- You need the ponyfill shape specifically: a fetch you import, with no global mutation, so consumers who already polyfilled are unaffected
- You are patching an existing codebase whose tests or SSR path breaks on 'fetch is not defined' and you want the one-line fix that works everywhere
- You target Node 18 or newer: fetch, Request, Response, Headers, and FormData are all global, and cross-fetch is a dependency that buys you nothing
- You need modern stream behavior: the Node path is node-fetch 2.x, where response.body is a Node Readable rather than a WHATWG ReadableStream, so res.body.getReader() and duplex request streaming do not work
- You expect it to defer to native fetch on Node: it does not, the ponyfill always calls node-fetch, so you silently opt out of undici's connection pooling, HTTP keep-alive defaults, and ongoing fixes
- Freshness matters to you: 4.1.0 shipped in December 2024, the repo's last push was April 2025, and the pinned node-fetch 2.7.0 dates from August 2023
- You ship to modern browsers only: the browser build inlines the whatwg-fetch polyfill, so every visitor downloads a shim for an API their browser has had for years
Setup reality
npm install cross-fetch and import it, which really is the whole install: no peer dependencies, no config, no native build, and TypeScript definitions in the package. The friction is subtler. The types are declared with a reference to the DOM lib, so a Node-only tsconfig without 'dom' in lib can produce type errors for Request and Response. There is no exports map and the package is CommonJS, so bundlers pick the entry through the browser and react-native fields, and any tool that ignores those fields (some SSR and edge runtimes, some jest transforms) can hand a browser build to Node or the reverse. Choosing between the ponyfill and the polyfill import matters for tests: mocking libraries that patch globalThis.fetch will not see calls made through the imported ponyfill.
Patterns
Import fetch without touching globalsponyfill-import
import fetch from 'cross-fetch';
const res = await fetch('https://api.example.com/users/1');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const user = await res.json();This is the recommended form: nothing is assigned to globalThis, so consumers who already have fetch are unaffected. Remember fetch does not reject on 404, so check res.ok yourself.
Install fetch globally as a side effectpolyfill-import
// once, at the top of your entry file
import 'cross-fetch/polyfill';
// anywhere after that
const res = await fetch('/api/health');The polyfill only assigns when global.fetch is missing, so on Node 18+ it is a no-op and your calls go to the native client instead of node-fetch.
Use it from CommonJScommonjs-require
const fetch = require('cross-fetch');
// or with named parts:
const { Headers, Request, Response } = require('cross-fetch');
require('cross-fetch/polyfill'); // global formThe package is CommonJS with no exports map, so require works directly and default interop is handled inside the wrapper.
POST a JSON bodypost-json
const res = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: 'A-100', qty: 2 }),
});
const data = await res.json();No automatic serialization: stringify the body and set Content-Type yourself, the same as with native fetch.
Separate HTTP errors from transport errorserror-handling
try {
const res = await fetch(url);
if (!res.ok) {
const body = await res.text();
throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`);
}
return await res.json();
} catch (error) {
// node-fetch wraps network problems in FetchError with a .code
if (error.code === 'ENOTFOUND') console.error('DNS failure');
throw error;
}On Node the rejection is a node-fetch FetchError, not the TypeError native fetch throws, so error-type checks written against native fetch do not match.
Cancel a request with AbortControllerabort-and-timeout
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(url, { signal: controller.signal });
return await res.json();
} finally {
clearTimeout(timer);
}node-fetch 2.x has no timeout option that the spec recognizes and does not accept AbortSignal.timeout on older Node; the explicit controller plus setTimeout works everywhere.
Build headers with the exported Headers classcustom-headers
import { Headers } from 'cross-fetch';
const headers = new Headers();
headers.append('Authorization', `Bearer ${token}`);
headers.append('Accept', 'application/json');
const res = await fetch(url, { headers });Import Headers, Request, and Response from cross-fetch rather than assuming globals, otherwise Node without a polyfill throws ReferenceError.
Read a large response as a stream on Nodestream-response-body
import fs from 'node:fs';
import fetch from 'cross-fetch';
const res = await fetch('https://example.com/big.csv');
// res.body is a Node Readable here, not a WHATWG ReadableStream
res.body.pipe(fs.createWriteStream('big.csv'));This is the biggest portability trap: the same code in a browser needs res.body.getReader(). If you need one streaming API across environments, use undici or native fetch instead.
Send multipart form dataupload-formdata
// browser / React Native: FormData is global
const form = new FormData();
form.append('file', fileOrBlob, 'invoice.pdf');
await fetch('/upload', { method: 'POST', body: form });
// Node with cross-fetch: install form-data and use its headers
// const FormData = require('form-data');
// const form = new FormData();
// form.append('file', fs.createReadStream('invoice.pdf'));
// await fetch(url, { method: 'POST', body: form, headers: form.getHeaders() });node-fetch 2.x predates the spec FormData, so on Node you need the form-data package and must pass its getHeaders() to set the boundary.
Mock requests in testsmock-in-tests
// jest.setup.js
require('cross-fetch/polyfill');
// test file
import fetchMock from 'jest-fetch-mock';
fetchMock.enableMocks();
test('loads user', async () => {
fetchMock.mockResponseOnce(JSON.stringify({ id: 1 }));
await expect(loadUser()).resolves.toEqual({ id: 1 });
});Global mocks only intercept code that calls globalThis.fetch. Modules that import the ponyfill hold their own reference and bypass the mock entirely, which is the usual reason a mock 'does nothing'.
Fix Request and Response type errors on a Node projecttypescript-config
// tsconfig.json
{
"compilerOptions": {
"lib": ["ES2022", "DOM"],
"types": ["node"]
}
}index.d.ts starts with a reference to the DOM lib, so a Node-only lib list makes the exported types resolve to nothing. Adding DOM is the cheap fix.
Remove it on Node 18 and newermigrate-off
// before
import fetch from 'cross-fetch';
const res = await fetch(url);
// after: delete the dependency, fetch is global
const res = await fetch(url);
// need a Node-only client with real streams and test interceptors?
// import { request, MockAgent } from 'undici';Check for res.body.pipe() calls before deleting: native fetch returns a WHATWG stream, so piping to a file becomes Readable.fromWeb(res.body).pipe(...).
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| undici | npm | You are Node-only and want the implementation behind native fetch, with real streams, pooling, and mock interceptors for tests |
| node-fetch | npm | You want the Node shim directly, without the cross-environment indirection, and can use its ESM v3 line |
| ky | npm | You want a small wrapper over whatever fetch already exists, adding retries, timeouts, and hooks instead of replacing the API |
| axios | npm | You want one HTTP client with interceptors and progress events across Node and browsers, and do not need the fetch API shape |