> ## Content Index
> Fetch the complete content index at: https://roundproxies.com/blog/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to make HTTP requests in Node.js: 11 ways (2026)
- URL: https://roundproxies.com/blog/http-requests-nodejs/
- Published: 2025-09-30T12:28:40.000Z
- Updated: 2026-09-24T13:46:20.000Z
- Description: Make HTTP requests in Node.js with fetch, https, undici, Axios, Got and more. Proxy setup included.
- Author: Marius Bernard
- Tags: #dated-68dafbb64fa498a2c6ee1c99

Node.js has shipped a stable `fetch()` since version 21, so the shortest way to make HTTP requests in Node.js needs no npm install. One global function and one `await`.

The other ten methods earn their place when fetch runs out of road. That means proxies, pooled connections, 2 GB downloads, or matching a real browser's TLS fingerprint.

This guide covers all 11 with code that runs on Node 22 and 24, plus the proxy setup and the exact errors for each.

It also names four libraries worth removing from your `package.json`.

## How do you make HTTP requests in Node.js?

To make HTTP requests in Node.js, call the built-in `fetch()` function and read the body with `await res.json()`. It needs no install on Node 18+ and has been stable since Node 21\. Drop to `node:https` for socket-level control, use undici for pooling and raw throughput, and pick Axios or Got when you want built-in retries.

A complete GET request with a timeout and a status check looks like this:

```javascript
// get.mjs: run with `node get.mjs`
const res = await fetch('https://jsonplaceholder.typicode.com/posts/1', {
  headers: { 'User-Agent': 'my-scraper/1.0' },
  signal: AbortSignal.timeout(10_000), // give up after 10 seconds
});

if (!res.ok) {
  // fetch does NOT reject on 404 or 500, so check yourself
  throw new Error(`HTTP ${res.status} ${res.statusText}`);
}

const post = await res.json();
console.log(post.title);

```

Keep the `.mjs` extension. Top-level `await` only works in ES modules, and it's the first thing that breaks when someone pastes these examples into a CommonJS file.

## All 11 methods at a glance

| #  | Method            | Install          | Throws on 4xx/5xx | Retries built in | Proxy hook                   | Best for                            |
| -- | ----------------- | ---------------- | ----------------- | ---------------- | ---------------------------- | ----------------------------------- |
| 1  | fetch()           | None             | No                | No               | undici dispatcher or env var | Default for new code                |
| 2  | https.request     | None             | No                | No               | agent or env var             | Zero-dependency libraries           |
| 3  | http2.connect     | None             | No                | No               | None built in                | Many calls to one HTTP/2 host       |
| 4  | undici.request    | npm i undici     | No                | Via RetryAgent   | ProxyAgent                   | High-volume scrapers                |
| 5  | undici.stream     | npm i undici     | No                | No               | ProxyAgent                   | Large downloads                     |
| 6  | Axios             | npm i axios      | Yes               | Plugin           | httpsAgent                   | Interceptors, existing codebases    |
| 7  | Got               | npm i got        | Yes               | Yes              | agent                        | Node-only apps that want everything |
| 8  | Ky                | npm i ky         | Yes               | Yes              | Global dispatcher or env var | Small fetch wrapper                 |
| 9  | SuperAgent        | npm i superagent | Yes               | .retry()         | .agent()                     | Chained request builders            |
| 10 | Needle            | npm i needle     | No                | No               | proxy option                 | Lean dependency trees               |
| 11 | curl via execFile | System binary    | With \--fail      | \--retry         | \-x                          | Browser TLS fingerprints            |

The "Proxy hook" column matters more than it looks. Fetch and Ky run on undici, while Axios, Got, SuperAgent and Needle run on `node:http`.

A proxy wired into one stack is invisible to the other, which the diagram at the top shows.

## 1\. fetch (built-in, the default)

Node's global `fetch` sits on undici, the HTTP client maintained by the Node.js team.

It's the same API browsers use, so our guide to [making requests in browser JavaScript](https://roundproxies.com/blog/requests-javascript/) applies almost line for line.

A POST with a JSON body needs the method, a header and a stringified body:

```javascript
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }, // fetch won't set this for you
  body: JSON.stringify({ title: 'hello', body: 'world', userId: 1 }),
});

console.log(res.status); // 201
console.log(await res.json()); // { title: 'hello', ..., id: 101 }

```

Forget the `Content-Type` header and plenty of APIs answer with a 415 or read an empty body. Fetch serializes nothing on your behalf.

Two fetch behaviors trip up people coming from Axios:

- A 404 or 500 resolves normally. Your code has to check `res.ok`.
- The body is a one-shot stream. Call `res.json()` and then `res.text()` and you get `TypeError: Body is unusable: Body has already been read`.

There's no `timeout` option either. Pass `signal: AbortSignal.timeout(ms)`, which rejects with an error named `TimeoutError` when time runs out.

Where fetch falls short: no per-request proxy setting in the standard API, no pool tuning, and no retries. All three have fixes further down.

## 2\. https.request and http.request (built-in, low level)

`node:https` is the layer Axios, Got and most older clients are built on. You get the raw response stream and handle status codes, buffering and parsing yourself.

`node:http` has the identical API for plain `http://` URLs. Match the module to the URL scheme or Node throws `ERR_INVALID_PROTOCOL`.

This wrapper turns the callback API into a promise that returns parsed JSON:

```javascript
import https from 'node:https';

function getJSON(url, { timeout = 10_000 } = {}) {
  const headers = { 'User-Agent': 'my-scraper/1.0' };
  return new Promise((resolve, reject) => {
    const req = https.get(url, { headers }, (res) => {
      if (res.statusCode < 200 || res.statusCode >= 300) {
        res.resume(); // drain it so the socket returns to the pool
        return reject(new Error(`HTTP ${res.statusCode}`));
      }
      const chunks = [];
      res.on('data', (c) => chunks.push(c)).on('end', () => {
        try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (e) { reject(e); }
      });
    });
    req.setTimeout(timeout, () => req.destroy(new Error('Request timed out'))).on('error', reject);
  });
}

console.log((await getJSON('https://jsonplaceholder.typicode.com/users/1')).name);

```

Three lines in there do more work than they appear to:

- `Buffer.concat` keeps multi-byte UTF-8 characters intact. The common `data += chunk` pattern can split one across two chunks and corrupt it.
- `res.resume()` discards a rejected body so the keep-alive socket can be reused.
- The timeout callback only notifies you. Without `req.destroy()`, the request keeps hanging.

For a POST, set `method: 'POST'`, call `req.write(body)` before `req.end()`, and set `Content-Length` with `Buffer.byteLength(body)`. `body.length` counts characters, not bytes.

Since Node 19, `http.globalAgent` keeps sockets alive by default, so repeat calls to one host skip the handshake.

For heavier traffic, pass your own `new https.Agent({ keepAlive: true, maxSockets: 50 })`.

Use this method for published libraries that must stay dependency-free, or when you need client certificates, socket events or a custom agent.

## 3\. http2.connect (built-in, multiplexing)

HTTP/2 sends many requests as parallel streams over one TCP connection. Node's fetch speaks HTTP/1.1 unless you opt in through undici's `allowH2` flag, so `node:http2` is the built-in way to multiplex.

This opens one session and fires three requests through it at once:

```javascript
import http2 from 'node:http2';

const session = http2.connect('https://nghttp2.org').on('error', console.error);

function get(path) {
  return new Promise((resolve, reject) => {
    const stream = session.request({ ':path': path });
    let status;
    stream.on('response', (headers) => { status = headers[':status']; });
    const chunks = [];
    stream.on('data', (c) => chunks.push(c));
    stream.on('end', () => resolve({ status, body: Buffer.concat(chunks).toString() }));
    stream.on('error', reject);
    stream.end();
  });
}

const pages = await Promise.all(['/', '/httpbin/get', '/httpbin/ip'].map(get));
console.log(pages.map((p) => p.status)); // three responses, one TCP connection
session.close();

```

Keys starting with a colon (`:path`, `:status`) are HTTP/2 pseudo-headers. Keep one session per host and close it when you finish, or the process won't exit.

The catch: `node:http2` has no proxy support. You'd have to open a CONNECT tunnel yourself and hand the socket over, which is rarely worth it for scraping.

## 4\. undici.request (the fastest general-purpose client)

The built-in fetch runs on undici, but undici's own `request()` API is faster. It skips the Web Streams layer that the undici README lists as a cost of fetch.

Install the package with `npm i undici`.

```javascript
import { request } from 'undici';

const { statusCode, body } = await request('https://jsonplaceholder.typicode.com/posts/1');

if (statusCode !== 200) {
  await body.dump(); // throw the body away, but DO consume it
  throw new Error(`HTTP ${statusCode}`);
}

console.log((await body.json()).title);

```

Always consume or dump the body, even on errors. The undici docs call this mandatory for `request()`, because an unread body holds its connection open until the pool stalls.

For many calls to one host, a `Pool` with a fixed number of connections is where undici pulls ahead:

```javascript
import { Pool } from 'undici';

const pool = new Pool('https://jsonplaceholder.typicode.com', { connections: 20 });

const titles = await Promise.all(
  Array.from({ length: 100 }, async (_, i) => {
    const { body } = await pool.request({ path: `/posts/${i + 1}`, method: 'GET' });
    return (await body.json()).title;
  })
);

console.log(titles.length); // 100
await pool.close(); // let the process exit

```

That's 100 requests over 20 connections with no throttling code of your own. The pool queues the overflow and hands requests to sockets as they free up.

## 5\. undici.stream (for large downloads)

`stream()` pipes the response body straight into a writable stream you supply, with no intermediate buffering. It's the fastest way to save large files to disk.

```javascript
import { stream } from 'undici';
import { createWriteStream } from 'node:fs';

await stream(
  'https://nodejs.org/dist/index.json',
  { method: 'GET', opaque: createWriteStream('node-releases.json') },
  ({ statusCode, opaque }) => {
    if (statusCode !== 200) throw new Error(`HTTP ${statusCode}`);
    return opaque; // undici writes the body into this stream
  }
);

```

`opaque` is a pass-through slot. Whatever you put there comes back to the factory function, which returns the destination.

Without the extra dependency, fetch plus `pipeline` does the same job with flat memory use, at lower throughput:

```javascript
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

const res = await fetch('https://nodejs.org/dist/index.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`);

// res.body is a web stream; convert it before piping to a Node stream
await pipeline(Readable.fromWeb(res.body), createWriteStream('node-releases.json'));

```

`pipeline` also destroys both streams if either side errors, which the older `.pipe()` doesn't do.

## 6\. Axios

Axios is still the most-installed HTTP client on npm, at roughly 100 million weekly downloads.

It parses JSON for you, rejects on 4xx/5xx, and has interceptors, which are the main reason teams keep it.

```javascript
import axios from 'axios';

const api = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com',
  timeout: 10_000, // Axios defaults to 0, meaning no timeout at all
  headers: { 'User-Agent': 'my-scraper/1.0' },
});

api.interceptors.response.use(
  (res) => res,
  (err) => {
    // err.response exists only if the server answered
    console.error(err.response?.status ?? err.code, err.config?.url);
    return Promise.reject(err);
  }
);

const { data } = await api.get('/posts/1');
console.log(data.title);

```

Network failures and timeouts arrive with `err.code` set (`ECONNABORTED`, `ECONNREFUSED` and so on) and no `err.response`. Code that reads `err.response.status` without the `?.` crashes on the first DNS failure.

Axios also carries a lesson about dependencies. On March 31, 2026, an attacker took over the lead maintainer's npm account.

They [published axios 1.14.1 and 0.30.4](https://github.com/axios/axios/issues/10636) with an extra dependency that installed a remote access trojan. The bad versions were live for about three hours.

Every package you add for convenience is also attack surface. Commit your lockfile, install with `npm ci` in CI, and consider `npm ci --ignore-scripts`, since that attack ran through a postinstall hook.

I'd keep Axios in codebases that already use it, or where interceptors for auth and error mapping save real code.

For a new scraper, fetch plus the retry helper later in this post covers the same ground with zero dependencies.

## 7\. Got

Got is Node-only and has the deepest feature set in this list: retries with backoff, hooks, pagination, HTTP/2 and per-phase timeouts. It has been ESM-only since version 12.

```javascript
import got from 'got';

const client = got.extend({
  prefixUrl: 'https://jsonplaceholder.typicode.com',
  timeout: { request: 10_000 },
  retry: { limit: 3 }, // backoff on 408, 413, 429, most 5xx, network errors
  headers: { 'user-agent': 'my-scraper/1.0' },
});

const post = await client.get('posts/1').json(); // no leading slash with prefixUrl
console.log(post.title);

```

`prefixUrl` rejects paths that start with a slash, so `client.get('/posts/1')` throws. Got's default retries cover GET and other idempotent methods; POST isn't retried unless you add it to `retry.methods`.

## 8\. Ky

Ky is a small wrapper around fetch, from the same author as Got. You keep fetch underneath and gain retries, timeouts, errors on bad statuses and `.json()` shortcuts. It's ESM-only too.

```javascript
import ky from 'ky';

const post = await ky
  .get('https://jsonplaceholder.typicode.com/posts/1', {
    timeout: 10_000,
    retry: { limit: 3 },
  })
  .json();

console.log(post.title);

```

Ky throws an `HTTPError` on non-2xx responses and respects the `Retry-After` header on 413, 429 and 503 responses.

Because it calls the global fetch, proxies go through undici's `setGlobalDispatcher` or the env-var route below.

## 9\. SuperAgent

SuperAgent predates promises in Node, and the API shows it: you build a request by chaining methods. It runs in browsers too.

```javascript
import superagent from 'superagent';

const res = await superagent
  .get('https://jsonplaceholder.typicode.com/posts/1')
  .set('User-Agent', 'my-scraper/1.0')
  .timeout({ response: 5_000, deadline: 15_000 })
  .retry(2);

console.log(res.body.title);

```

`.timeout()` splits the wait for the first byte (`response`) from the total budget (`deadline`). That helps with endpoints that answer fast but stream slowly.

Errors on 4xx/5xx carry the response on `err.response`.

## 10\. Needle

Needle keeps its dependency tree small, handles multipart uploads without extra packages, and offers both callbacks and promises.

```javascript
import needle from 'needle';

// 3rd argument is request DATA, even for GET. Pass null to skip it.
const res = await needle('get', 'https://jsonplaceholder.typicode.com/posts/1', null, {
  follow_max: 3, // follow up to 3 redirects
  open_timeout: 5_000,
  response_timeout: 10_000,
});

if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`);
console.log(res.body.title); // JSON is parsed automatically

```

Watch that third argument. Needle turns GET data into a query string, so options passed there end up as `?follow_max=3` in the URL.

Needle also resolves on 4xx/5xx, so check `statusCode` yourself.

## 11\. curl via execFile (for browser TLS fingerprints)

Shelling out to curl sounds like a hack. For scraping it has one real advantage: builds such as curl-impersonate reproduce Chrome's or Firefox's TLS handshake.

Node's TLS stack runs on OpenSSL and can't match Chrome's handshake byte for byte.

That matters because anti-bot systems read your [TLS fingerprint](https://roundproxies.com/blog/what-is-tls-fingerprint/) before a single header arrives.

```javascript
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile);

// args go in as an array: no shell, so no injection
const { stdout } = await run('curl', [
  '-sS', '--fail', '--max-time', '15',
  '-H', 'Accept: application/json',
  'https://jsonplaceholder.typicode.com/posts/1',
]);

console.log(JSON.parse(stdout).title);

```

Use `execFile`, never `exec` with a template string. With `execFile`, a URL containing `; rm -rf ~` stays a URL. `--fail` makes curl exit non-zero on 4xx/5xx, which rejects the promise.

To impersonate a browser, swap `'curl'` for the curl-impersonate wrapper that matches your target. Binary names differ between builds, so check what yours installed.

Each call spawns a process, which is fine for thousands of requests and wasteful for millions.

## Libraries to stop using in 2026

Plenty of tutorials still recommend these four. Each has a better replacement:

- **request** has been deprecated since February 2020\. It gets no fixes, security or otherwise.
- **node-fetch** made sense before Node 18\. Version 3 is ESM-only, the global fetch does the same job, and it came last in the undici benchmark.
- **phin** is marked deprecated on npm by its author, and its version 4 was republished as a thin wrapper around Axios.
- **bent** was last published to npm in October 2020\. It still works; nobody is maintaining it.

Raw `net` sockets deserve a mention too. Writing `GET / HTTP/1.1\r\n` by hand teaches you the protocol well. For shipping code it's a poor choice: no TLS, no chunked decoding, no redirects.

## HTTP requests in Node.js through a proxy

Node ignores `HTTPS_PROXY` unless you opt in, and each HTTP stack has its own proxy hook. Three setups cover every client in this guide.

### Option A: environment variables

Newer Node releases read the standard proxy variables once you set `NODE_USE_ENV_PROXY`:

```bash
NODE_USE_ENV_PROXY=1 \
HTTPS_PROXY=http://user:pass@203.0.113.10:8080 \
NO_PROXY=localhost,127.0.0.1 \
node app.mjs

```

Per the [Node.js enterprise network docs](https://nodejs.org/en/learn/http/enterprise-network-configuration), this covers fetch on v22.21.0+ and v24.0.0+, and `node:http`/`node:https` on v22.21.0+ and v24.5.0+.

Any client that passes its own agent bypasses it, and so do hosts listed in `NO_PROXY`.

### Option B: fetch and undici with ProxyAgent

For per-request control, and for rotating through a list, give each request a `dispatcher`:

```javascript
import { fetch, ProxyAgent } from 'undici';

// Build each agent ONCE: every ProxyAgent keeps its own connection pool
const proxies = [
  'http://user:pass@203.0.113.10:8080',
  'http://user:pass@203.0.113.11:8080',
].map((uri) => new ProxyAgent(uri));

let i = 0;
const nextProxy = () => proxies[i++ % proxies.length];

for (let n = 0; n < 4; n++) {
  const res = await fetch('https://httpbin.org/ip', { dispatcher: nextProxy() });
  console.log((await res.json()).origin); // should alternate between the two IPs
}

```

Creating a `ProxyAgent` per request throws its pool away, so you pay a fresh TLS handshake every time.

Importing `fetch` from the same undici package as `ProxyAgent` also avoids version mismatches with the copy bundled inside Node.

### Option C: https, Axios and Got with an agent

The `node:http` clients take an `http.Agent`. The `https-proxy-agent` package provides one that tunnels through an HTTP proxy with CONNECT:

```javascript
import https from 'node:https';
import axios from 'axios';
import got from 'got';
import { HttpsProxyAgent } from 'https-proxy-agent';

const agent = new HttpsProxyAgent('http://user:pass@203.0.113.10:8080');

// node:https
https.get('https://httpbin.org/ip', { agent }, (res) => res.pipe(process.stdout));

// Axios: turn off its own proxy logic and use the agent instead
await axios.get('https://httpbin.org/ip', { httpsAgent: agent, proxy: false });

// Got: agents are keyed by protocol
await got('https://httpbin.org/ip', { agent: { https: agent } });

```

Set `proxy: false` with Axios. Its built-in `proxy` option has had long-running issues tunneling HTTPS through HTTP proxies, and it reads env vars you might not expect.

Needle takes a `proxy: 'http://user:pass@host:port'` option directly, and curl takes `-x`.

Test the setup before trusting it. Hit an IP echo endpoint through the proxy and again without it; if both return your own IP, the proxy isn't in the path.

The code is identical whether the IPs come from Roundproxies' residential pool or a box you run yourself. Only the proxy URL changes.

## Timeouts and retries for Node.js HTTP requests

Default timeouts are longer than most people assume. Axios and Got wait forever unless configured, `node:http` has no timeout, and undici's header and body timeouts default to 300 seconds each.

This helper adds a per-attempt timeout, exponential backoff with jitter, and retries only where retrying can help:

```javascript
async function fetchWithRetry(url, opts = {}, retries = 3) {
  for (let attempt = 0; ; attempt++) {
    try {
      const res = await fetch(url, { ...opts, signal: AbortSignal.timeout(10_000) });
      // 4xx (except 429) means the request itself is wrong: don't retry
      if (res.status !== 429 && res.status < 500) return res;
      if (attempt >= retries) return res;
      await res.body?.cancel(); // free the connection before retrying
    } catch (err) {
      if (attempt >= retries) throw err; // network error or timeout
    }
    const delay = 2 ** attempt * 500 + Math.random() * 250; // 0.5s, 1s, 2s + jitter
    await new Promise((r) => setTimeout(r, delay));
  }
}

```

The jitter spreads retries out so a batch of failed workers doesn't hammer the server in lockstep.

For 429s, read the `Retry-After` header and wait at least that long. Our guide to [HTTP 429 errors](https://roundproxies.com/blog/http-error-429/) covers both header formats.

Don't retry POSTs blindly. A retried payment or order creates a duplicate unless the API supports idempotency keys.

`Error: socket hang up` and `ECONNRESET` usually mean the server or proxy closed a keep-alive socket as you reused it.

Retrying an idempotent GET fixes it. Lowering `maxSockets` helps with proxies that drop idle connections.

## Benchmark: how fast is each client?

The undici project publishes benchmarks against a local server, last run on Node 24.14.1 over HTTP/1.1.

The [undici README](https://github.com/nodejs/undici) reports `undici.request` at about 16,850 requests per second, `node:http` with keep-alive at 9,340, Axios at 5,450, and fetch at 5,440.

So `undici.request` roughly triples fetch on raw throughput. That gap is real, and it's also mostly irrelevant for scraping.

Those runs have near-zero network latency. At 5,000 requests per second, fetch spends about 0.2 ms of client overhead per request.

Against a 150 ms round trip to a real server, that's roughly 0.1% of the total.

Pick on features first and switch to undici when a profiler tells you the client is the bottleneck. To check your own machine, this script compares `node:http` and fetch with no dependencies:

```javascript
// bench.mjs: node:http (keep-alive) vs built-in fetch against a local server
import http from 'node:http';

const server = http.createServer((req, res) => res.end('{"ok":true}'));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
const agent = new http.Agent({ keepAlive: true, maxSockets: 50 });
const N = 10_000;

const viaHttp = () => new Promise((resolve, reject) => {
  http.get(url, { agent }, (res) => res.resume().on('end', resolve)).on('error', reject);
});
const viaFetch = async () => { await (await fetch(url)).arrayBuffer(); };
for (const [name, fn] of [['node:http', viaHttp], ['fetch', viaFetch]]) {
  const t0 = performance.now();
  for (let i = 0; i < N; i += 50) await Promise.all(Array.from({ length: 50 }, fn));
  console.log(name.padEnd(10), Math.round(N / ((performance.now() - t0) / 1000)), 'req/s');
}
server.close(); agent.destroy();

```

It sends 10,000 requests in batches of 50\. Run it twice and trust the second result; the first includes JIT warm-up.

## Troubleshooting

### "TypeError: fetch failed"

**Why:** fetch wraps every network-level failure in one generic `TypeError`. The message tells you nothing.

**Fix:** log `err.cause`. That's where the real error lives: `ECONNREFUSED`, `ENOTFOUND`, a certificate problem, or `UND_ERR_CONNECT_TIMEOUT`.

```javascript
try {
  await fetch(url);
} catch (err) {
  console.error(err.message, '->', err.cause?.code, err.cause?.message);
  // fetch failed -> ECONNREFUSED connect ECONNREFUSED 127.0.0.1:3000
}

```

If you see `UND_ERR_CONNECT_TIMEOUT` and the host has an IPv6 address, your network may not route IPv6\. Run with `node --dns-result-order=ipv4first app.mjs` to confirm.

### "Error \[ERR\_REQUIRE\_ESM\]" or "ERR\_REQUIRE\_ASYNC\_MODULE"

**Why:** you called `require()` on an ESM-only package such as Got, Ky or node-fetch v3.

**Fix:** switch to `import` in a `.mjs` file or set `"type": "module"` in `package.json`. Node 20.19+ and 22.12+ can `require()` most ESM packages, but you'll still get `ERR_REQUIRE_ASYNC_MODULE` if the package uses top-level `await`.

### "SyntaxError: await is only valid in async functions and the top level bodies of modules"

**Why:** top-level `await` in a CommonJS file. Every snippet in this guide assumes ES modules.

**Fix:** rename the file to `.mjs`, or wrap the code in `(async () => { ... })()`.

### "TypeError: Body is unusable: Body has already been read"

**Why:** you read a fetch response body twice, often `res.json()` inside a try block and `res.text()` in the catch for logging.

**Fix:** read once with `const raw = await res.text()`, then `JSON.parse(raw)` inside the try. The raw string stays available for your error log.

### "SyntaxError: Unexpected token '<', "<html>..." is not valid JSON"

**Why:** the server sent HTML. With scrapers that's usually a 403 block page, a login wall, or a CAPTCHA.

**Fix:** check `res.ok` and the `content-type` header before calling `.json()`, and log the first 200 characters of the body. If it's a block page, our [Axios 403 guide](https://roundproxies.com/blog/axios-403/) walks through the header and fingerprint fixes, and they apply to every client here.

### "Error: self-signed certificate in certificate chain"

**Why:** the server, or a corporate proxy that inspects TLS, presents a certificate from a CA that Node doesn't trust.

**Fix:** teach Node the CA instead of turning verification off. Start the process with `NODE_EXTRA_CA_CERTS=./company-ca.pem`, or pass `ca: fs.readFileSync('ca.pem')` to an `https.Agent`.

The `--use-system-ca` flag makes Node trust the operating system's certificate store, which usually already holds your company's CA.

Keep `rejectUnauthorized: false` and `NODE_TLS_REJECT_UNAUTHORIZED=0` out of production. The second one disables certificate checks for every request the process makes.

## Which method should you use?

| Your situation                                         | Use                               |
| ------------------------------------------------------ | --------------------------------- |
| Calling an API from a new project                      | fetch()                           |
| Publishing a library with zero dependencies            | node:https                        |
| Thousands of requests to a few hosts                   | undici.request with a Pool        |
| Downloading files larger than your RAM                 | undici.stream or fetch + pipeline |
| Existing codebase built on interceptors                | Axios, pinned via lockfile        |
| Node-only app that wants retries, hooks and pagination | Got                               |
| Want fetch with retries and nothing else               | Ky                                |
| Many calls to one HTTP/2 API                           | node:http2                        |
| Target fingerprints your TLS handshake                 | curl-impersonate via execFile     |

My default for new code is fetch plus the retry helper above. I add undici when a profile shows the client in the hot path.

Curl-impersonate comes out the day a target starts blocking on TLS.

## FAQ

### Does Node.js have fetch built in?

Yes. `fetch` has been a global in Node since version 18 and has been stable, with no experimental warning, since version 21\. You don't import it or install anything.

### Is Axios still worth using in 2026?

For existing projects, yes; its interceptors and error handling still save code. For new projects, fetch covers most of what Axios does without a dependency.

Either way, the March 2026 account takeover is a reason to pin versions with a lockfile.

### What is the fastest HTTP client for Node.js?

undici's low-level APIs (`request`, `stream`, `dispatch`) are the fastest, at roughly three times the throughput of fetch in the undici benchmark.

Over a real network, latency dwarfs that difference for most workloads.

### How do I make an HTTP request in Node.js without a library?

Use the global `fetch()` for most cases, or `node:https` when you need socket-level control. Both ship with Node, and both examples above run with zero installs.

## Wrapping up

To make HTTP requests in Node.js today, start with fetch and add a dependency only when you can name what it gives you.

undici buys throughput, Got buys retries and hooks, and curl-impersonate buys a browser's TLS fingerprint.

Whatever you pick, wire up timeouts and the right proxy hook for its stack before you ship.

When requests start coming back as HTML instead of JSON, the next step is our guide to [web scraping with JavaScript](https://roundproxies.com/blog/how-to-web-scrape-with-javascript/).