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(fromnode: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
| Feature | http.request | fetch (native) | axios |
|---|---|---|---|
| Built in | Yes (node:http) | Yes (Node 18+) | No (npm install) |
| Returns | Streams + callbacks | Promise of Response | Promise |
| JSON parsing | Manual | res.json() | Automatic |
| Stream request body | Native, zero-copy | Limited | Limited |
| Timeouts | AbortController / socket | AbortSignal.timeout() | timeout option |
| Interceptors / retries | DIY | DIY | Built 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
errorhandler to theClientRequest; 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)forContent-Length, neverbody.length. - Prefer
stream/promisespipelineover 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.AgentwithkeepAlive: truewhen making many requests to the same host. - For everyday API consumption, default to native
fetch; savehttp.requestfor streaming and low-level needs.