Skip to content
Node.js nd http 4 min read

Making HTTP Requests

Servers rarely live in isolation — they call other APIs, fetch remote files, and forward webhooks. Node.js ships a low-level client in the core node:http and node:https modules that gives you complete control over connection reuse, headers, streaming, and timeouts. While most application code today reaches for the higher-level fetch or axios, understanding http.request is essential for performance-sensitive code, proxies, and anything that streams large payloads without buffering them in memory.

A first request with http.get

The http.get (and https.get) helper is a thin convenience wrapper around http.request that sets the method to GET and calls .end() for you, since a GET has no body to write. The response arrives as a readable stream, so you collect its chunks and concatenate them.

import https from "node:https";

https.get("https://api.github.com/repos/nodejs/node", {
  headers: { "User-Agent": "devcraftly-demo" },
}, (res) => {
  const chunks = [];
  res.on("data", (chunk) => chunks.push(chunk));
  res.on("end", () => {
    const body = Buffer.concat(chunks).toString("utf8");
    const repo = JSON.parse(body);
    console.log(`${repo.full_name} has ${repo.stargazers_count} stars`);
  });
}).on("error", (err) => console.error("Request failed:", err.message));

Output:

nodejs/node has 108243 stars

The callback receives the response before the body has been read. You must consume or destroy the response stream — leaving it unconsumed keeps the socket open and can stall the connection pool.

Full control with http.request

For anything other than a simple GET — custom methods, request bodies, fine-grained options — use http.request / https.request. It returns a writable ClientRequest stream. You write the body to it (if any) and must call req.end() to send the request.

import https from "node:https";

function postJson(url, payload) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(payload);
    const { hostname, pathname, search } = new URL(url);

    const req = https.request(
      {
        hostname,
        path: pathname + search,
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Content-Length": Buffer.byteLength(data),
        },
      },
      (res) => {
        const chunks = [];
        res.on("data", (c) => chunks.push(c));
        res.on("end", () => {
          const body = Buffer.concat(chunks).toString("utf8");
          resolve({ status: res.statusCode, body });
        });
      },
    );

    req.on("error", reject);
    req.write(data);
    req.end();
  });
}

const { status, body } = await postJson("https://httpbin.org/post", { hello: "world" });
console.log("Status:", status);
console.log("Echoed:", JSON.parse(body).json);

Output:

Status: 200
Echoed: { hello: 'world' }

Setting Content-Length with Buffer.byteLength (not data.length, which counts characters, not bytes) lets the server read the body correctly. Omit it and Node falls back to chunked transfer encoding, which is fine for streaming but unnecessary when the size is known.

Streaming bodies and responses

Because both the request and response are streams, you can pipe data through without ever holding the whole payload in memory. This is the key advantage of the low-level API over fetch for large transfers.

import https from "node:https";
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";

// Upload a file as the request body
const req = https.request("https://httpbin.org/post", { method: "POST" });
await pipeline(createReadStream("./large.bin"), req);

// Download a response straight to disk
https.get("https://nodejs.org/dist/index.json", (res) => {
  pipeline(res, createWriteStream("./versions.json"))
    .then(() => console.log("Saved"))
    .catch(console.error);
});

pipeline (from node:stream/promises) propagates errors and destroys both streams on failure, avoiding the leaked sockets and unhandled-error crashes that manual .pipe() chains are prone to.

Timeouts

A request with no timeout can hang forever if the peer stops responding. There are two distinct knobs: a socket inactivity timeout, and an explicit abort via AbortController.

import https from "node:https";

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

const req = https.request(
  "https://httpbin.org/delay/10",
  { signal: controller.signal },
  (res) => {
    res.resume(); // drain
    res.on("end", () => clearTimeout(timer));
  },
);

req.on("error", (err) => {
  clearTimeout(timer);
  if (err.name === "AbortError") console.error("Timed out after 5s");
  else console.error(err.message);
});

req.end();

Output:

Timed out after 5s

The older req.setTimeout(ms, cb) only fires when the socket is idle and does not abort the request — you still have to call req.destroy() yourself. The AbortController approach is cleaner and works identically with fetch.

How it compares to fetch and axios

Featurehttp.requestfetch (native)axios
Built inYes (node:http)Yes (Node 18+)No (npm install)
ReturnsStreams + callbacksPromise of ResponsePromise
JSON parsingManualres.json()Automatic
Stream request bodyNative, zero-copyLimitedLimited
TimeoutsAbortController / socketAbortSignal.timeout()timeout option
Interceptors / retriesDIYDIYBuilt in

For ordinary API calls, prefer fetch — it is standard, promise-based, and parses JSON in one line: const data = await (await fetch(url)).json(). Reach for axios when you want interceptors, automatic retries, and progress events out of the box. Drop down to http.request when you need byte-level control, connection-pool tuning via a custom Agent, or true streaming of multi-gigabyte payloads.

Best Practices

  • Always attach an error handler to the ClientRequest; an unhandled socket error crashes the process.
  • Consume or res.resume() every response stream so sockets return to the pool instead of leaking.
  • Use Buffer.byteLength(body) for Content-Length, never body.length.
  • Prefer stream/promises pipeline over manual .pipe() for correct error and cleanup handling.
  • Set a timeout (via AbortController) on every outbound request so a slow peer cannot stall your service.
  • Reuse a configured http.Agent with keepAlive: true when making many requests to the same host.
  • For everyday API consumption, default to native fetch; save http.request for streaming and low-level needs.
Last updated June 14, 2026
Was this helpful?