1Step-by-Step Breakdown
From node-fetch to Built-in fetch. For over a decade, making an outbound HTTP request from Node required an external dependency — request, axios, or node-fetch. Since Node 18, a spec-compliant fetch() implementation (backed by the Undici HTTP client) ships built in and is stable by default, meaning a huge class of applications can drop an entire dependency and its transitive supply-chain surface.
Undici: The Engine Behind It. Node's native fetch is not a reimplementation from scratch — it is powered by Undici, a high-performance HTTP/1.1 client also written by the Node.js core team, designed specifically to outperform the legacy http module's connection handling under high concurrency via aggressive connection pooling and pipelining.
Basic GET and JSON Parsing. The fetch() Response object mirrors the browser API exactly: it exposes .ok, .status, and body-parsing methods like .json() and .text() that return Promises. Critically, a 404 or 500 response does NOT reject the fetch Promise — only network-level failures do — so checking response.ok before parsing is mandatory, not optional.
POST Requests with JSON Bodies. Sending JSON requires three matching pieces: the HTTP method, a Content-Type: application/json header telling the server how to interpret the payload, and a JSON.stringify()-serialized body — fetch never serializes objects for you automatically, unlike some higher-level HTTP client libraries.
Timeouts with AbortSignal. Unlike axios, fetch has no built-in timeout option — an outbound call to a slow or hanging upstream API can block indefinitely by default. Node's fetch supports the standard AbortSignal.timeout(ms) helper, which automatically aborts the request and rejects the Promise with an AbortError after the given duration, which is essential for protecting your service from a slow downstream dependency.
Manual Cancellation with AbortController. Beyond fixed timeouts, an AbortController gives you a manually-triggerable cancellation token — useful for canceling an in-flight request when, for example, the client that originally asked for it has already disconnected. Calling .abort() immediately rejects the pending fetch Promise and frees the underlying socket.
When to Still Reach for Axios. Native fetch covers the vast majority of outbound-HTTP needs with zero dependencies, but it deliberately stays close to the browser spec and omits conveniences like automatic JSON body serialization, built-in retries, or request/response interceptors for cross-cutting logging. For a large service making hundreds of distinct API integrations, a thin wrapper around fetch — or axios when interceptor-based instrumentation is a hard requirement — is a reasonable tradeoff.
You call const res = await fetch(url) against an endpoint that returns HTTP 404. Does the await fetch(...) line throw an exception?
- →Yes, fetch always throws on non-2xx status codes
- →No — only network failures reject; you must check res.ok yourself
Level Up 🚀
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Unbounded Outbound Timeouts Cascade Into Frozen UIs
When a backend fetch() call to an upstream dependency has no timeout, a slow upstream can leave the client-facing request spinning indefinitely — which is especially disorienting for screen-reader users who receive no auditory feedback that anything is happening at all, unlike a sighted user who might at least see a spinner.
SEO Implications
- 1
Faster, Timeout-Protected Backend Calls Improve Time-to-First-Byte
Native fetch backed by Undici's connection pooling reduces the overhead of outbound API calls a page's server-side rendering may depend on, and explicit timeouts prevent a single slow dependency from stalling the entire response — both directly improve TTFB, a documented Core Web Vitals-adjacent ranking factor.
Best Practices
Always pair a production fetch() call with AbortSignal.timeout()
A request handler awaiting an unbounded fetch call is one slow upstream dependency away from exhausting your server's available connections under load. A timeout bounds the worst case.
Wrap fetch in a small typed helper instead of reaching for axios by default
A ~15-line wrapper handling JSON parsing, error-checking, and timeouts covers most needs with zero added dependencies — reserve a full HTTP client library for cases that genuinely need interceptors or automatic retries.
Frequent Bugs
A POST request built with fetch() reaches the server with an empty or malformed body, and the API returns a 400 validation error.
This almost always means either the Content-Type header is missing (so the server's body parser doesn't know to parse it as JSON) or the body was passed as a raw object instead of a JSON.stringify()-serialized string — fetch never serializes the body automatically.
Real-World Examples
Dropping node-fetch and Axios From a Microservice
A service making calls to three internal APIs depended on both node-fetch (legacy code) and axios (newer code), doubling the HTTP-client surface in its dependency tree and its supply-chain audit scope. Migrating both call sites to native fetch with a shared 20-line wrapper eliminated two dependencies (and their transitive sub-dependencies) with no behavior change, and shaved measurable weight off the container image.
// Shared wrapper replacing both node-fetch and axios call sites
async function apiCall(url, opts = {}) {
const res = await fetch(url, { ...opts, signal: AbortSignal.timeout(5000) });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
return res.json();
}