Talking to an HTTP endpoint used to mean reaching for a third-party package. That is no longer true on the server. As of Node.js 18, the global fetch function ships in the runtime, so you can make HTTP requests in Node.js with the Fetch API using the exact same promise-based interface you already know from the browser, with no node-fetch install and no flag to flip.
This guide is a practical tour of fetch on the server: GET and POST, query strings, sending and reading JSON, the error-handling rule that trips up almost everyone, timeouts with AbortController, running requests concurrently, and where Axios still earns its place. It closes with the one scenario where a bare request is not enough, scraping a real website, and how to fetch rendered HTML through an API instead.
Why fetch, and what changed in Node
The Fetch API is promise-based, so it composes cleanly with async/await and avoids the callback nesting of the old http module. It speaks every method you need (GET, POST, PUT, PATCH, DELETE), gives you a real Headers object, and handles JSON with a single method call. The interface is identical across browser and server, which means code and mental models transfer in both directions.
The practical headline is the version cutoff. On Node.js 18 and later, fetch is a global, exactly like setTimeout. You do not import it and you do not install anything. Confirm your runtime before writing code:
node -v # v18.x or higher means global fetch is available
Only on Node 16 or older, where you would install node-fetch and import it. On Node 18+ that package is redundant, and adding it just shadows the faster built-in. If you are on a current LTS release, delete the dependency and use the global.
Making GET requests with the Fetch API
A GET request is a single call. fetch() returns a promise that resolves to a Response object as soon as the headers arrive. The body is read separately, with response.json() for JSON or response.text() for plain text, and each of those returns its own promise.
The detail that matters most: always check response.ok before reading the body. A 404 or 500 is still a successful HTTP exchange as far as fetch is concerned, so the promise resolves normally. response.ok is true only for status codes in the 200 to 299 range.
async function getPost(id) { const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`); if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`); } const data = await response.json(); return data; } getPost(1).then(console.log).catch(console.error);
If the endpoint returns text or HTML rather than JSON, swap the body reader. The rest of the flow is unchanged.
const response = await fetch("https://example.com/"); const html = await response.text(); console.log(html.slice(0, 200));
You can read the body exactly once. Calling response.json() after response.text() on the same response throws, because the stream is already consumed. Pick one reader per response.
Reading and setting response headers
The Response object exposes a headers property that behaves like a Map. Read a single header with get(), or iterate the whole set. Header names are case-insensitive, so "content-type" and "Content-Type" resolve to the same value.
const response = await fetch("https://jsonplaceholder.typicode.com/posts/1"); console.log(response.headers.get("content-type")); for (const [key, value] of response.headers) { console.log(`${key}: ${value}`); }
One advantage of running on the server: the CORS restrictions that hide most headers in the browser do not apply in Node. You get full access to every response header the server sends.
Adding query strings with URLSearchParams
You could concatenate query parameters by hand, but that means escaping spaces, ampersands, and other reserved characters yourself, and it is easy to get wrong. URLSearchParams encodes values correctly and reads cleanly.
const params = new URLSearchParams({ userId: 1, _limit: 5, }); const url = `https://jsonplaceholder.typicode.com/posts?${params}`; const response = await fetch(url); const posts = await response.json(); console.log(`Got ${posts.length} posts`);
Interpolating params into a template literal calls its toString(), which produces a properly encoded query string. Adding or removing a filter later is a one-line change to the object, not a string-surgery exercise.
Sending POST requests and JSON bodies
To send data, pass a second argument: an options object with method, headers, and body. The body must be a string, so JSON payloads go through JSON.stringify(), and you set Content-Type: application/json so the server knows how to parse what it receives.
async function createPost(payload) { const response = await fetch("https://jsonplaceholder.typicode.com/posts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { throw new Error(`POST failed: HTTP ${response.status}`); } return response.json(); } const created = await createPost({ title: "Fetch on the server", body: "Built into Node 18+", userId: 1, }); console.log(created);
The same shape covers PUT and PATCH for updates and DELETE for removals: change method and, where relevant, the body. For form submissions rather than JSON, pass a URLSearchParams or FormData instance as the body and drop the explicit Content-Type; fetch sets the correct header for those automatically.
Error handling: the rule that catches everyone
Here is the single most important thing to internalize about fetch. It only rejects on network-level failures: DNS resolution failed, the connection dropped, the request was aborted. Any HTTP response from the server, including 404 and 500, counts as a resolved promise. A bare try/catch around fetch will happily swallow a 500 and march on as if everything is fine.
The robust pattern combines both checks: a try/catch for the network layer, and an explicit response.ok test for the HTTP layer.
async function getJson(url) { try { const response = await fetch(url); if (!response.ok) { // HTTP error: server replied, but with a 4xx or 5xx throw new Error(`HTTP ${response.status} for ${url}`); } return await response.json(); } catch (error) { // Network failure, abort, or the thrown HTTP error above console.error("Request failed:", error.message); throw error; } }
Throwing on !response.ok routes HTTP errors into the same catch as network errors, so one block handles both classes of failure. Without that explicit check, a 500 slips straight through to response.json(), which then throws a confusing parse error on the HTML error page instead of the real status.
Timeouts and cancellation with AbortController
fetch has no built-in timeout. Left alone, a request can hang for as long as the connection stays open, which is unacceptable for anything user-facing. The standard fix is AbortController: create one, pass its signal to fetch, and call abort() when a timer fires.
async function fetchWithTimeout(url, ms = 5000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ms); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await response.json(); } catch (error) { if (error.name === "AbortError") { throw new Error(`Request to ${url} timed out after ${ms}ms`); } throw error; } finally { clearTimeout(timer); } }
An aborted request rejects with an AbortError, which is why the catch inspects error.name to give a clear timeout message. Clearing the timer in finally stops a successful early response from triggering a stray abort later. The same signal can wire fetch to a user-driven cancel button, not just a timer. On recent Node versions, AbortSignal.timeout(ms) is a shorthand for this exact timer pattern.
Running requests concurrently with Promise.all
When you have several independent requests, awaiting them one after another wastes time: each waits for the previous to finish. Promise.all kicks them all off at once and resolves when the last one lands, so total time is roughly the slowest request rather than the sum of all of them.
const ids = [1, 2, 3, 4, 5]; const posts = await Promise.all( ids.map((id) => getPost(id).catch((error) => ({ id, error: error.message })), ), ); console.log(posts);
One caveat: Promise.all rejects as soon as any single promise rejects, discarding the rest. The inline .catch() above turns a per-request failure into a value, so one bad request does not sink the whole batch. If you would rather inspect every outcome explicitly, Promise.allSettled returns a status-and-value record for each. And do not fire thousands of requests at once: that is how you get rate-limited or run out of sockets. For large jobs, cap concurrency by processing the list in fixed-size chunks.
Fetch API vs Axios: which to reach for
Axios predates server-side fetch and still has a loyal following. The honest comparison is short:
-
Reach for
fetchwhen you want zero dependencies and a standard API. It is built in, it is the same in the browser, and for most GET and POST work it is all you need. - Reach for Axios when you want conveniences it bundles: automatic JSON parsing in both directions, request and response interceptors, built-in timeout config, automatic rejection on non-2xx status, and upload or download progress. On a large codebase those features save real boilerplate.
The gap is smaller than it used to be. Most Axios niceties, timeouts, status checks, JSON handling, are a few lines of helper code on top of fetch, like the wrappers in this article. If you are starting fresh on Node 18+ and do not need interceptors, the built-in is the leaner choice. If a project already standardizes on Axios, there is no urgency to rip it out.
Where bare fetch hits a wall: scraping real sites
Everything above works beautifully against APIs and cooperative servers. Point fetch at a modern commercial website to pull its HTML, though, and you run into two hard limits fast.
First, fetch retrieves only the initial HTML the server sends. It does not run a browser, so it never executes the page's JavaScript. On a site that renders its content client-side, the markup you get back is a near-empty shell, with the data you wanted populated later by scripts that never run. Second, sites that care about scraping fingerprint incoming traffic. A request from a datacenter IP with a default Node user agent and no browser-like headers gets challenged or blocked before it returns anything useful.
You can attack both yourself, with a headless browser to render the page and a pool of rotating residential proxies to look like real visitors, but keeping that stack healthy is most of the work. The cleaner path is to send your fetch call to an API that does the rendering and the IP rotation server-side and hands you finished HTML.
Scraping a real site needs a rendered page behind a trusted IP, in one request. The Crawling API takes your token and a target URL, runs the page in a real browser, rotates residential IPs for you, and returns the finished HTML, so you keep using plain fetch and skip running a headless fleet and a proxy pool. Start on the free tier.
The call is the same fetch you already know. You build the endpoint URL with your token and the URL-encoded target, send a GET, and read the rendered HTML out of the response. From there a parser like cheerio turns that HTML into structured data.
import * as cheerio from "cheerio"; const TOKEN = "YOUR_CRAWLBASE_TOKEN"; async function scrape(targetUrl) { const params = new URLSearchParams({ token: TOKEN, url: targetUrl, }); const response = await fetch(`https://api.crawlbase.com/?${params}`); if (!response.ok) { throw new Error(`Crawl failed: HTTP ${response.status}`); } const html = await response.text(); const $ = cheerio.load(html); return { title: $("title").text().trim(), headings: $("h2").map((_, el) => $(el).text().trim()).get(), }; } scrape("https://www.example.com/").then(console.log);
For pages that render content with JavaScript, add a "&javascript=true" equivalent by passing a JS-rendering token, which runs the target in a real browser before returning HTML. Because the request is just fetch, every technique from this article still applies: wrap it in the timeout helper, run a list of URLs through Promise.all with a sensible concurrency cap, and check response.ok on every call. For a fuller Node scraping walkthrough, see how to build a web scraper with Node.js, and for the rendering problem specifically, how to crawl JavaScript websites.
Key takeaways
-
fetch is built into Node 18+. No
node-fetch, no flag, no import. It is a global, same as in the browser. -
Always check
response.ok.fetchonly rejects on network errors, so a 404 or 500 resolves normally and slips past a plaintry/catch. -
POST is a second argument. Set
method, aContent-Typeheader, and aJSON.stringify'dbody; build query strings withURLSearchParams. -
Add timeouts with
AbortController. There is no built-in timeout; pass asignaland abort on a timer or a cancel action. -
Parallelize with
Promise.all. Fire independent requests together, guard each with.catch(), and cap concurrency for large batches. -
Bare fetch cannot scrape modern sites. It will not render JavaScript and gets blocked; route the same
fetchthrough the Crawling API for rendered HTML, then parse with cheerio.
Frequently Asked Questions (FAQs)
Do I still need node-fetch in Node.js?
Not on Node.js 18 or later. fetch is a global there, so you can call it directly with no install and no import. You only need node-fetch on Node 16 or older. If you are on a current LTS release, removing the dependency is safe and lets you use the faster built-in implementation.
Why does fetch not throw on a 404 or 500?
Because fetch treats any completed HTTP exchange as a success, even when the status code is an error. The promise only rejects on network-level failures like a dropped connection, DNS failure, or an abort. To catch HTTP errors you must check response.ok (true only for 200 to 299) yourself and throw when it is false, then let your catch handle it.
How do I send JSON in a POST request with fetch?
Pass an options object as the second argument with method: "POST", a headers object that sets "Content-Type": "application/json", and a body produced by JSON.stringify(). The body has to be a string, which is why you stringify the object first. Read the server's reply back with await response.json() after confirming response.ok.
How do I add a timeout to a fetch request?
Use an AbortController. Create one, pass its signal into the fetch options, and call controller.abort() from a setTimeout. An aborted request rejects with an AbortError, so check error.name in your catch to report a timeout cleanly. On recent Node versions, AbortSignal.timeout(ms) wraps this pattern in one call.
Should I use fetch or Axios in Node?
Use fetch for zero-dependency, standards-based requests; it covers most GET and POST work out of the box on Node 18+. Choose Axios when you want its built-in conveniences such as interceptors, automatic JSON handling, configured timeouts, and automatic rejection on non-2xx responses. For a fresh project that does not need those, the built-in is the leaner pick; an existing Axios codebase has no urgent reason to switch.
Can I use fetch to scrape a website?
You can fetch a page's raw HTML, but bare fetch has two limits on real sites: it does not run JavaScript, so client-rendered content comes back empty, and requests from datacenter IPs get challenged or blocked. The fix is to send your fetch call to the Crawling API, which renders the page in a real browser behind rotating residential IPs and returns finished HTML you can parse with cheerio.
Crawl any site at scale, without fighting infrastructure.
Crawlbase handles proxies, fingerprints, and CAPTCHAs so your team ships data pipelines instead of maintaining crawl plumbing. 1,000 requests free, no card required.
