NodeJSMaking HTTP Requests (http/https)

Making HTTP Requests (http/https)

Node isn't only a server — it's frequently a client, calling other APIs, fetching resources, talking to microservices. There are three layers of API: the low-level http.request/https.request, and the modern global fetch (built into Node 18+). For most code today, fetch is the answer — but knowing the lower-level module clarifies what fetch and libraries like axios are doing underneath.

The modern default: fetch

JS
// fetch is global in Node 18+ — no import needed
const res = await fetch('https://api.example.com/users/42')

if (!res.ok) {                          // ⚠ fetch does NOT throw on 4xx/5xx
  throw new Error(`HTTP ${res.status}`)
}
const user = await res.json()           // parse the JSON body
console.log(user)
`fetch` only rejects on network failure — NOT on HTTP error status
This trips up everyone coming from libraries like axios. `fetch` resolves successfully for **any** response that arrives, including `404` and `500` — it rejects only when the request can't complete at all (DNS failure, connection refused, timeout). You **must** check `res.ok` (or `res.status`) yourself and throw/branch on errors. Assuming a resolved promise means success is a pervasive bug.
POST with a JSON body

JS
const res = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Ada' }),     // stringify yourself
})

const created = await res.json()
`fetch` does not auto-serialize JSON
Unlike some client libraries, `fetch` won't `JSON.stringify` your body or set `Content-Type` for you — you do both explicitly. The body can be a string, `Buffer`/typed array, `FormData`, `URLSearchParams`, or a stream. Match the `Content-Type` to what you send.
Reading the response

Method

Gives you

res.json()

Parsed JSON (throws on invalid JSON)

res.text()

Body as a string

res.arrayBuffer()

Binary as an ArrayBuffer

res.body

A ReadableStream — stream large bodies

res.headers.get(name)

A response header

res.status / res.ok

Status code / status in 200–299

The body can be consumed only once
A response body is a stream — calling `res.json()` then `res.text()` on the same response throws "body already read." Pick one. If you need the raw text *and* to parse it, read `res.text()` once and `JSON.parse` it yourself. To consume a body twice you'd `res.clone()` first.
Timeouts and cancellation

fetch has no default timeout — a hung server can leave a request pending forever. Use an AbortSignal to enforce one:

JS
// Convenient helper (Node 17.3+):
const res = await fetch('https://slow.example.com', {
  signal: AbortSignal.timeout(5000),     // abort after 5s
})

// Or manual control, e.g. to cancel on user action:
const ac = new AbortController()
setTimeout(() => ac.abort(), 5000)
try {
  await fetch(url, { signal: ac.signal })
} catch (err) {
  if (err.name === 'AbortError') console.log('timed out / cancelled')
  else throw err
}
Always set a timeout on outbound requests
Without a timeout, one slow upstream dependency can pile up pending requests and exhaust your server's resources (sockets, memory) — a cascading failure. Every outbound `fetch` in a server should carry an `AbortSignal.timeout(...)`. This is operational hygiene, not an optional nicety.
The low-level http.request

Before fetch, this was the way — and it's still what fetch and axios build on. It's stream-based and more verbose: you write the body to the request stream and read the response as a readable stream:

JS
import { request } from 'node:https'

const req = request('https://api.example.com/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
}, (res) => {
  let body = ''
  res.on('data', (chunk) => body += chunk)      // res is a readable stream
  res.on('end', () => console.log(res.statusCode, JSON.parse(body)))
})

req.on('error', (err) => console.error(err))    // network errors
req.write(JSON.stringify({ name: 'Ada' }))      // write the body
req.end()                                        // finish the request
Use `https.request` for https:// URLs — `http.request` won't do TLS
The `http` and `https` modules are separate. `http.request` speaks plain HTTP and will fail against an `https://` URL; `https.request` handles the TLS handshake. `fetch` picks the right one automatically based on the URL scheme — one more reason it's the simpler default.
fetch vs http.request vs axios

Option

When to use

fetch (built-in)

Default for almost everything — standard, no deps

http/https.request

Need stream-level control, or supporting old Node

axios / got / undici

Want interceptors, auto-retry, richer ergonomics

`fetch` in Node is powered by undici
Node's global `fetch` is implemented on top of **undici**, a fast HTTP/1.1 client. If you need more control than `fetch` exposes (connection pooling tuning, pipelining), you can use `undici` directly. But for typical API calls, the standard `fetch` — with an explicit timeout and an `res.ok` check — is the right, dependency-free choice.
Connection reuse with keep-alive
Reuse connections for repeated requests to the same host
Opening a new TCP (and TLS) connection per request is expensive. HTTP keep-alive reuses connections across requests, dramatically cutting latency when you call the same host repeatedly. `fetch`/undici pool connections by default; with the raw modules you'd configure an `http.Agent({ keepAlive: true })`. For high-volume clients, connection reuse is one of the biggest performance wins.
Next
Serve your own traffic securely over TLS: [Creating an HTTPS Server](/nodejs/https-server).