Making Outbound Requests
Global fetch in Node.js
Section titled “Global fetch in Node.js”Since Node.js 18, the fetch API is available globally — no import, no npm install. It is the same Web Fetch API you use in the browser:
// @skip-verify network: illustrative — api.example.com is not a real API// No import needed in Node 18+const response = await fetch('https://api.example.com/data');const data = await response.json();This is a huge improvement over the older http.request approach which required manually assembling chunks from a readable stream.
Fetching JSON from a public API
Section titled “Fetching JSON from a public API”The fetch function returns a Promise that resolves to a Response object. Call .json() on the response to parse the body as JSON — this also returns a Promise, so you await it too.
Needs the Node.js runtime — open in StackBlitz to run.
Error handling with fetch
Section titled “Error handling with fetch”fetch does not throw on non-2xx HTTP status codes. You must check response.ok (a boolean that is true for status codes 200–299) yourself:
Needs the Node.js runtime — open in StackBlitz to run.
A note on http.request
Section titled “A note on http.request”Before fetch, outbound HTTP requests in Node.js used http.request() from the node:http module. It is a lower-level API based on streams. You may encounter it in older codebases:
// @skip-verify network: illustrative legacy pattern against a public host// Older pattern — collecting chunks manuallyconst http = require('node:http');
http.get('http://example.com', function(res) { let body = ''; res.on('data', function(chunk) { body += chunk; }); res.on('end', function() { console.log(body); });});For new code, prefer the global fetch — it is cleaner, Promise-based, and isomorphic with browser code.
Timeouts and retry with backoff
Section titled “Timeouts and retry with backoff”fetch never times out on its own — a hung upstream leaves your await waiting forever unless you give it a signal. AbortSignal.timeout(ms) creates a signal that fires by itself after the given time, no separate AbortController/setTimeout bookkeeping required. Combine it with a small retry loop and an exponential backoff delay to ride out a flaky upstream:
Output (verified on Node 24)
attempt 1 failed: fetch failed
attempt 2 failed: fetch failed
succeeded: { ok: true, attempt: 3 }AbortSignal.timeout(200) is passed fresh on every attempt — a signal fires once and cannot be reused, so a retry loop needs a new one per attempt, not a single timeout shared across all of them. Combined with response.ok, this covers both failure shapes: a request that never comes back at all (the timeout aborts it) and one that comes back with an error status (!response.ok throws so the same catch handles it).
Bounded concurrency vs. unbounded Promise.all
Section titled “Bounded concurrency vs. unbounded Promise.all”Promise.all(urls.map(fetch)) starts every request at once — for 3 URLs that is fine, for 300 it can overwhelm the upstream (or your own outbound connection limits) with all 300 in flight simultaneously. A small worker-pool pattern bounds how many run concurrently, no library required:
Output (verified on Node 24)
unbounded Promise.all: peak concurrent requests = 10
bounded (limit 3): peak concurrent requests = 3The counting server proves the difference: the unbounded version has all 10 requests in flight at their peak, while the bounded version — a fixed number of worker() loops all pulling from the same shared next index — never exceeds its limit of 3, no matter how many URLs are queued behind it.
CORS: a browser-only concern
Section titled “CORS: a browser-only concern”CORS (Cross-Origin Resource Sharing) headers only matter when a browser script from one origin calls an API on another — fetch/undici calls from server to server are never blocked by CORS at all, since CORS is a browser enforcement mechanism, not a server one. A browser sends a preflight OPTIONS request before certain cross-origin requests, and the server must answer it correctly before the real request is even attempted:
Output (verified on Node 24)
preflight status: 204 Allow-Methods: GET, POST
real request: { status: 200, origin: 'https://example.com', body: { ok: true } }Both the preflight response and the real response carry Access-Control-Allow-Origin — a browser checks it on every cross-origin response, not just the preflight. Miss it on the real response and the browser blocks the script from reading the body, even though the request itself already reached the server and ran.
WebSocket is global too
Section titled “WebSocket is global too”Since Node 22 the browser WebSocket client is available without an import, so the same client code runs in the browser and on the server:
// @skip-verify network: illustrative — connects to a public WebSocket echo hostconst ws = new WebSocket('wss://echo.websocket.org');ws.addEventListener('open', function() { ws.send('hello'); });ws.addEventListener('message', function(event) { console.log(event.data); ws.close();});Mental Model
Section titled “Mental Model”Every outbound fetch call is a request you do not control the far end of: it can be slow (needs a timeout), it can fail outright (needs a retry), it can come back with an error status without rejecting at all (needs response.ok), and doing many of them at once can overwhelm either side (needs a concurrency bound). None of these are things fetch does for you automatically — each one is a deliberate layer you add around the bare call, matched to what could actually go wrong for that specific request.
Trade-offs
Section titled “Trade-offs”Retrying with backoff turns a transient failure into a success, but it also multiplies load on a struggling upstream exactly when it is least able to handle more — and it turns a fast failure into a slow one from the caller’s perspective (three attempts with backoff takes far longer than one attempt that just fails). Bounding concurrency protects both sides from overload, but a limit that is too low leaves throughput on the table when the upstream could actually handle more; there is no universally correct number, only one tuned to the specific upstream and workload.
Common Misconceptions
Section titled “Common Misconceptions”- “fetch will time out on its own if the server is slow.” It will not — without an explicit
signal, a hung connection can leave yourawaitwaiting indefinitely.AbortSignal.timeout(ms)is the one-line fix. - “CORS protects my API from being called by other servers.” CORS is enforced by browsers reading response headers, not by the server refusing connections — a script running in Node.js, a mobile app, or
curlnever goes through CORS enforcement at all. It only constrains browser-based JavaScript. - “
Promise.all(urls.map(fetch))is the fast way to fetch many URLs.” It is fast until the number of URLs is large enough to overwhelm the upstream (or exhaust your own outbound connections) — at that scale, bounded concurrency is not just polite, it is often what makes the batch complete at all instead of timing out or getting rate-limited.
Reading AI-generated code
Section titled “Reading AI-generated code”AI-generated code that fetches “all of them” reaches for Promise.all(urls.map(fetch)) by pure reflex — it is the shortest way to write “fetch these,” and it happens to be exactly wrong once the list is not small and fixed.
🐛 Spot the bug
async function fetchAllUserAvatars(userIds) {const urls = userIds.map(function (id) { return '/avatars/' + id + '.png'; });const responses = await Promise.all(urls.map(function (url) { return fetch(url); }));return responses.map(function (r) { return r.url; });}
// Called with a list of 500 user IDs from a batch jobfetchAllUserAvatars(fiveHundredUserIds);Show the answer
Promise.all(urls.map(fetch)) fires all 500 requests at the exact same moment — no server or outbound connection pool is designed to absorb that as a normal load, and the likely outcomes are the upstream rate-limiting or erroring out, the process running out of available sockets, or just noticeably worse latency for every one of those 500 requests compared to running a bounded number at a time. This code also never checks response.ok, so a batch of 4xx/5xx responses looks identical to a batch of successes until something later tries to use the (error) body as image data. The fix is the pattern from this lesson: a small worker pool with a fixed concurrency limit, and a response.ok check per request.