jsrosetta

I/O

HTTP Client

How Node.js's global fetch() compares to Go's net/http: GET and reading JSON, POST with headers, 4xx/5xx status codes, timeouts, and streaming the body.

By
Minimum versions
Node.js ≥ 18.13Go ≥ 1.13
Verified on
Node.js 24.12.0Go 1.27.1

Node.js code is an ES module: save it as .mjs or set "type": "module" in package.json.

Node.js has a global fetch() (built on undici) — the same API as in the browser, returning a Promise<Response>. Go uses net/http from the standard library: http.Get for the simple case, http.NewRequestWithContext + Client.Do when you need a method, headers, or a context of your own. Both share one commonly misunderstood trait: a 4xx/5xx response is not an error — only network failures (DNS, connection refused, timeouts…) make fetch() reject or give you err != nil in Go. The examples below call JSONPlaceholder, a public fake API that returns fixed data, so the // → output is repeatable; the timeout section spins up a slow server inside the program itself so it doesn't depend on the network.

GET and reading JSON

const res = await fetch("https://jsonplaceholder.typicode.com/todos/1");
console.log(res.status); // → 200
console.log(res.headers.get("content-type")); // → application/json; charset=utf-8
 
const todo = await res.json(); // read the body and parse it as JSON
console.log(todo);
// → { userId: 1, id: 1, title: 'delectus aut autem', completed: false }

POSTing JSON with headers

const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json",
  },
  body: JSON.stringify({ title: "hello", userId: 1 }), // body accepts a string, Buffer/TypedArray/ArrayBuffer, Blob, FormData, URLSearchParams, or a stream; a plain object is coerced to "[object Object]", not JSON
});
 
console.log(res.status); // → 201
console.log(await res.json()); // → { title: 'hello', userId: 1, id: 101 }

4xx/5xx status codes aren't errors

const res = await fetch("https://jsonplaceholder.typicode.com/todos/9999");
console.log(res.ok, res.status); // → false 404 (no throw!)
 
if (!res.ok) {
  console.log(`HTTP ${res.status} ${res.statusText}`); // → HTTP 404 Not Found
}
 
try {
  await fetch("http://does-not-exist.invalid"); // a domain that doesn't exist: a real network error
} catch (err) {
  console.log(err.name, err.message); // → TypeError fetch failed
  console.log(err.cause.code); // → ENOTFOUND
}

Timeouts

import http from "node:http";
import { once } from "node:events";
 
// a deliberately slow server: answers after 2 seconds
const server = http.createServer((req, res) => {
  const timer = setTimeout(() => res.end("late"), 2000);
  res.on("close", () => clearTimeout(timer)); // client went away: cancel the timer so the process exits right away
});
server.listen(0); // port 0: let the OS pick a free port
await once(server, "listening");
const url = `http://127.0.0.1:${server.address().port}`;
 
try {
  await fetch(url, { signal: AbortSignal.timeout(500) }); // abort after 500ms
} catch (err) {
  console.log(err.name); // → TimeoutError
} finally {
  server.close();
}

Streaming the body

import { createWriteStream } from "node:fs";
import { rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
 
const res = await fetch("https://jsonplaceholder.typicode.com/photos");
 
// res.body is a web ReadableStream: write it to a file piece by piece, without holding the whole body in RAM
const path = join(tmpdir(), "jsrosetta-photos.json");
try {
  await pipeline(Readable.fromWeb(res.body), createWriteStream(path));
  const { size } = await stat(path);
  console.log(size); // → 1039898 (bytes, after gzip decompression)
} finally {
  await rm(path, { force: true }); // clean up the temp file
}