Skip to content
Home JavaScript Fetch API and AJAX in JavaScript: How to Load Data Without Reloading the Page

Fetch API and AJAX in JavaScript: How to Load Data Without Reloading the Page

Where developers are forged. · Structured learning · Free forever.
📍 Part of: DOM → Topic 4 of 9
Master the Fetch API and AJAX in JavaScript.
⚙️ Intermediate — basic JavaScript knowledge assumed
In this tutorial, you'll learn
Master the Fetch API and AJAX in JavaScript.
  • fetch() resolves on any server response — always check response.ok or response.status before parsing the body. A 404 or 500 response that is parsed as valid data is a silent failure, and silent failures are worse than visible errors.
  • The two-step parse exists because the response body is a ReadableStream — always await response.json(). Missing await is silent at runtime and produces a Promise where downstream code expects an object.
  • Separate data-fetching and DOM-rendering into independent functions — the separation is a testing strategy, not just code organization. Functions that both fetch and render require a live DOM and a live server to test.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • Fetch API is the modern Promise-based replacement for XMLHttpRequest — cleaner syntax, native async/await support
  • fetch() resolves its Promise on ANY server response including 404/500 — only rejects on network failure
  • You must check response.ok (true for 200-299) manually — this is the #1 production gotcha
  • response.json() returns a Promise — always await it, never assign without await
  • POST requests require Content-Type: application/json header or the server returns 400/415
  • Biggest mistake: assuming fetch() throws on HTTP errors — it does not, you must throw yourself
🚨 START HERE
Fetch API Debug Quick Reference
Fast diagnostic commands for fetch-related issues in the browser — ordered by frequency in production
🟡Fetch call fails silently — no error in console, no error state in UI, data looks wrong or stale
Immediate ActionCheck if response.ok is being evaluated before any body parsing
Commands
console.log(response.status, response.statusText, response.ok)
const body = await response.text(); console.log(body)
Fix NowAdd if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`) immediately after the fetch call, before any response.json() call. Using response.text() instead of response.json() for diagnosis prevents a double-parse error if the body is not valid JSON.
🟡POST request returns 400 Bad Request or 415 Unsupported Media Type
Immediate ActionVerify the Content-Type header is present and the body is correctly serialized
Commands
console.log(JSON.stringify(postData)) // Confirm it produces a JSON string, not undefined
console.log(requestOptions.headers) // Confirm 'Content-Type': 'application/json' is present
Fix NowAdd headers: { 'Content-Type': 'application/json' } and body: JSON.stringify(data) to the fetch options. If Content-Type is present but 415 persists, check for a charset suffix or casing issue — some servers are strict about the exact value.
🟡response.json() returns a Promise object instead of parsed data — TypeError on property access
Immediate ActionVerify await is used on response.json() — not on the fetch call alone
Commands
const raw = await response.text(); console.log(typeof raw, raw.slice(0, 200))
const data = JSON.parse(raw); console.log(typeof data, Object.keys(data))
Fix NowUse const data = await response.json() — not const data = response.json(). The two-step nature of fetch means both the initial fetch and the body parse are async. Missing either await produces a Promise where you expected a value.
🟡Race condition — user triggers multiple requests and responses arrive out of order, UI shows wrong data
Immediate ActionImplement AbortController to cancel the previous in-flight request before starting a new one
Commands
const controller = new AbortController(); fetch(url, { signal: controller.signal })
controller.abort() // Call this before starting the replacement request
Fix NowAbort the previous request before starting the new one. Catch AbortError separately and return null — it is an expected cancellation, not a real error. Only throw on non-abort errors so the catch block does not conflate cancellations with failures.
Production IncidentMissing response.ok Check Caused Silent Data Loss in DashboardA production analytics dashboard displayed stale data for 6 hours because fetch() resolved successfully on 503 responses from the upstream API, and the code never checked response.ok before rendering.
SymptomUsers reported that the analytics dashboard showed outdated metrics — data that appeared to be from 6 hours prior. No error messages appeared in the UI. The dashboard looked completely normal. Charts were populated, numbers were present, the loading spinner had cleared. Everything looked operational. Nothing was.
AssumptionThe upstream analytics API was caching responses aggressively and needed a cache-buster header added to requests. The team spent the first hour adding cache-control headers and investigating CDN configuration before anyone looked at the actual response status codes in the network tab.
Root causeThe fetch call to the analytics API returned a 503 Service Unavailable during a deployment window for the upstream service. The fetch() Promise resolved — not rejected — because the server did respond. It just responded with a 503. The code had no response.ok check. It called response.json() on the 503 response body, which was a JSON error object the upstream API returns during maintenance: { "status": "unavailable", "message": "Service temporarily unavailable" }. There was no 'data' field in this error object. The rendering code checked for the presence of a 'data' field and treated its absence as 'no new data available since last fetch' — a valid state that caused it to silently retain the previous render. The dashboard showed 6-hour-old metrics with no visual indicator of staleness until someone manually opened the browser DevTools and noticed every API call was returning 503.
FixAdded response.ok check immediately after every fetch call: if (!response.ok) throw new Error(HTTP ${response.status}: ${response.statusText}). Built a centralized fetch wrapper that enforces the ok check and logs all non-2xx responses to the error tracking service with the full response body. Added a 'last successfully updated' timestamp to the dashboard UI rendered below every data visualization — users can now detect stale data visually without opening DevTools. Added a lightweight health check that polls the upstream API every 30 seconds and displays a banner when the upstream is unhealthy.
Key Lesson
fetch() resolves on any server response — 404, 500, and 503 all resolve the Promise, never reject itAlways check response.ok before parsing the body — this check must happen on every single fetch call, not just the ones you expect might failSilent failures are categorically worse than visible errors — a user who sees an error message can act on it; a user looking at stale data makes wrong decisions confidentlyWrap fetch in a single utility function that centralizes the ok check, error logging, and authentication header injection — do not scatter these concerns across every call site
Production Debug GuideCommon symptoms of fetch misuse in production — with diagnosis steps, not just observations
Dashboard shows stale or empty data but no error appears in the UI — the page looks healthy but the data is wrongThe fetch resolved on a non-2xx response and the code proceeded to parse the error body as valid data. Open the browser Network tab and check the actual status codes on every API call. Add a response.ok check that throws before any body parsing. Surface a visible error state to the user — never let a failed fetch silently retain the previous UI state.
POST request returns 400 Bad Request or 415 Unsupported Media Type from the serverMissing Content-Type header or the body is not properly serialized. Add 'Content-Type': 'application/json' to the headers object. Verify the body is JSON.stringify(data) — passing a raw JavaScript object to the body field sends the string '[object Object]', which is not JSON and will fail every time.
response.json() returns a Promise object instead of parsed data — downstream code receives undefined on property accessMissing await on response.json(). The body parsing step is also asynchronous. Change const data = response.json() to const data = await response.json(). If you are using ESLint, add the no-floating-promises rule to catch this at lint time before it reaches production.
Duplicate records created in the database from rapid form submissions — only reproducible under slow network conditionsDouble-submission race condition. The user clicked submit twice before the first request completed. Disable the submit button immediately when the fetch starts and re-enable it in the finally block — not in the try block — so it re-enables on both success and failure paths.
Page load takes significantly longer than expected — DevTools shows API requests completing fast but the page still waitsSequential awaits on independent fetch calls. Each await blocks until the previous one finishes even when there is no data dependency between them. Replace sequential awaits with Promise.all() for independent requests. Only use sequential await when one request genuinely depends on the result of the previous.

Every time you scroll Twitter and new posts appear without a page refresh, search Google and see suggestions drop as you type, or add something to your Amazon cart without being yanked to a new page — that is AJAX at work. It is one of the most visible features of the modern web, and understanding it deeply separates developers who build reactive, professional applications from those who are still forcing full page reloads for every user interaction.

Before AJAX, every action that needed server data meant a full round-trip: the browser requested a new HTML page, the server built it from scratch, and the user stared at a blank screen for a second or two. AJAX (Asynchronous JavaScript and XML — though JSON replaced XML years ago in practice) solved this by letting JavaScript make HTTP requests in the background while the rest of the page kept running. The Fetch API is the modern, Promise-based way to do exactly that — cleaner, more readable, and far more composable than its predecessor, XMLHttpRequest.

In this guide we cover why the Fetch API exists and what it replaced, how to make GET and POST requests with production-grade error handling, the streaming architecture that explains why response parsing is asynchronous, and how to build a working mini-app that fetches live data without the subtle async mistakes that silently break production code at scale.

Why XMLHttpRequest Existed — and Why Fetch Replaced It

To appreciate why Fetch exists, you need to feel the pain it solved. XMLHttpRequest was introduced by Microsoft in Internet Explorer 5 around 1999 and later standardized by the W3C. It gave JavaScript the ability to talk to a server without forcing a full page reload — genuinely revolutionary at the time. Gmail, Google Maps, and the first wave of dynamic web applications were built on it.

But XHR's API design reflects the era it came from. You create an object, attach separate event handlers for different request states, open a connection, and then send — all before you receive a single byte back. Error handling requires checking both readyState and status in the same callback. Nesting multiple XHR calls produces deeply indented callback chains that are nearly impossible to read, test, or debug six months later when something breaks at 2am.

The Fetch API, shipped in Chrome 42 in 2015 and now supported in every modern browser and Node.js 18+, uses Promises natively. This unlocks the entire Promise composition ecosystem: async/await for readable linear code, Promise.all for parallel requests, Promise.race for timeout patterns, and AbortController for cancellation. None of these work cleanly with XHR without substantial custom wrapper code.

In production, the migration from XHR to Fetch is not just about cleaner syntax. It is about composability and testability. XHR callbacks cannot be chained without nesting, cannot be raced with Promise.race without wrapping, and cannot be aborted cleanly without a non-trivial implementation. Fetch supports all of these natively, which means less custom infrastructure code to maintain and fewer places for subtle bugs to hide.

io/thecodeforge/fetch/xhr-vs-fetch-comparison.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
/**
 * io.thecodeforge: Comparing Legacy XHR vs Modern Fetch
 *
 * Both examples fetch the same user from the same endpoint.
 * Compare the error handling surface area — XHR requires
 * manual readyState + status checks in every callback.
 * Fetch centralizes the check with response.ok.
 */

// ─── THE OLD WAY: XMLHttpRequest ─────────────────────────────────────────────
// Verbose, event-driven, impossible to compose with Promise.all or async/await
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1');

xhr.onreadystatechange = function () {
  // readyState 4 = DONE, status 200 = OK
  // You must check both — readyState 4 includes error states
  if (xhr.readyState === 4) {
    if (xhr.status === 200) {
      const user = JSON.parse(xhr.responseText);
      console.log('XHR result:', user.name);
    } else {
      // HTTP errors require manual status checking here
      console.error('XHR HTTP error:', xhr.status, xhr.statusText);
    }
  }
};

xhr.onerror = function () {
  // Network error — separate handler required
  console.error('XHR network error — no connection to server');
};

xhr.send();

// ─── THE MODERN WAY: Fetch API ────────────────────────────────────────────────
// Promise-based, composable with async/await, AbortController, Promise.all
async function fetchUser(userId) {
  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${userId}`
    );

    // response.ok is true for 200-299 only
    // fetch() does NOT throw on 404 or 500 — you must check
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    // Body parsing is also async — always await
    const user = await response.json();
    console.log('Fetch result:', user.name);
    return user;
  } catch (error) {
    // Catches both network errors AND the manual throw above
    console.error('Fetch error:', error.message);
    return null;
  }
}

fetchUser(1);
⚠ Watch Out: XMLHttpRequest Is Not Dead
You will still encounter XHR in legacy codebases, older browser extensions, and third-party libraries that predate Fetch — including older versions of jQuery's $.ajax. Knowing how XHR works is not optional knowledge; it is what lets you debug issues in systems you did not write. But write all new code using Fetch or a library like Axios that wraps it with additional production features. Never start a new project with raw XHR in 2026.
📊 Production Insight
XHR callbacks cannot be chained, raced, or cancelled cleanly without significant custom infrastructure code.
Fetch composes natively with Promise.all, Promise.race, async/await, and AbortController — the full modern async toolkit.
Rule: use Fetch for all new code. Maintain XHR only in legacy systems where a rewrite is not justified. If you need interceptors, automatic retries, or request/response transformation middleware on top of Fetch, reach for Axios.
🎯 Key Takeaway
XHR is callback-heavy, error-prone to compose, and cannot participate in the Promise ecosystem without wrappers. Fetch replaced it with native Promises — chainable, raceable, abortable. Use Fetch for new code. Use Axios when you need interceptors or retry logic. Touch XHR only when the cost of migrating outweighs the pain of maintaining it.
XHR vs Fetch Decision Tree
IfNew project or new feature in an existing codebase
UseUse Fetch API — native Promise support, clean async/await syntax, AbortController for cancellation
IfMaintaining legacy code that uses XHR throughout
UseKeep XHR and migrate incrementally — do not rewrite working production code for aesthetic reasons alone
IfNeed request interceptors, automatic retries, or response transformation middleware
UseUse Axios — it wraps Fetch/XHR and adds enterprise-grade features without you building the infrastructure yourself
IfNeed upload progress tracking — showing a progress bar during file upload
UseUse XHR — Fetch does not expose upload progress events. This is a documented, intentional limitation of the Fetch spec that has not been resolved as of 2026.

How Fetch Really Works — Promises, Response Objects, and the Two-Step Parse

Here is the thing that trips up almost every developer the first time, and trips up experienced developers when they are moving fast: fetch() resolves its Promise as soon as the server responds with headers — even if the status code is 404 or 500. The Promise only rejects on a true network failure: DNS lookup failure, no internet connection, CORS rejection before the server is reached, or the connection being dropped mid-flight.

When fetch() resolves, you get a Response object — an envelope. The envelope has arrived, but you have not opened it yet. You call .json() on that envelope to read and parse the contents, and that parsing step also returns a Promise because reading the body stream takes time. Using async/await flattens this two-step process into readable linear code. Skipping await on either step gives you a Promise where you expected a value.

The two-step architecture exists because the Response body is a ReadableStream. The browser does not buffer the entire response into memory when fetch() resolves — it streams chunks as they arrive from the network. Calling response.json() reads the stream to completion and parses the accumulated bytes as JSON. This streaming design is what enables large file downloads without consuming proportional RAM, and it is why response.text(), response.blob(), and response.arrayBuffer() are all also asynchronous — they all read the same underlying stream.

One important consequence: you can only read the body stream once. If you call response.json() and then try to call response.text() on the same response, the second call fails because the stream has already been consumed. If you need the raw body and the parsed version, call response.text() first, then JSON.parse() the result manually. This is useful during debugging when you want to log the raw response before parsing it.

io/thecodeforge/fetch/fetch-with-proper-error-handling.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
/**
 * io.thecodeforge: Production-grade Fetch wrapper pattern
 *
 * This wrapper centralizes four concerns that should never
 * be scattered across individual call sites:
 * 1. response.ok check (HTTP error detection)
 * 2. Body parsing (always async)
 * 3. Error logging (route to monitoring service)
 * 4. Request timeout (AbortController + setTimeout)
 */

const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds

/**
 * Centralized fetch wrapper.
 * Use this instead of raw fetch() throughout the application.
 * Throws on both network errors and HTTP errors.
 */
async function apiFetch(url, options = {}) {
  const controller = new AbortController();
  const timeoutId = setTimeout(
    () => controller.abort(),
    options.timeout ?? DEFAULT_TIMEOUT_MS
  );

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });

    // CRITICAL: fetch() does NOT throw on 404/500/503
    // response.ok is true for status codes 200-299 only
    if (!response.ok) {
      // Read the error body for additional context
      // response.text() avoids a double-parse failure if body is not valid JSON
      const errorBody = await response.text();
      const error = new Error(
        `HTTP ${response.status}: ${response.statusText}`
      );
      error.status = response.status;
      error.body = errorBody;
      throw error;
    }

    // Body parsing is the second async step — the stream must be read
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      // Could be our timeout or an external abort signal
      throw new Error(`Request timed out after ${options.timeout ?? DEFAULT_TIMEOUT_MS}ms`);
    }
    // Re-throw with context — let the caller decide how to handle
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

/**
 * Application-level function using the wrapper.
 * Business logic is clean — no fetch internals visible here.
 */
async function fetchUserProfile(userId) {
  try {
    const user = await apiFetch(
      `https://jsonplaceholder.typicode.com/users/${userId}`
    );
    return {
      name: user.name,
      email: user.email,
      city: user.address.city,
    };
  } catch (error) {
    // error.status is available for HTTP errors from apiFetch
    console.error(
      `[fetchUserProfile] Failed for userId=${userId}:`,
      error.message
    );
    // In production: report to Sentry or Datadog here
    return null;
  }
}

fetchUserProfile(1).then(data => console.log(data));
💡Build a Wrapper Once, Use It Everywhere
Wrap fetch() in a single apiFetch(url, options) utility for your entire application. Put the response.ok check, body parsing, error logging, and timeout logic inside it. Every call site gets correct behavior automatically — no one can accidentally omit the ok check because the wrapper enforces it structurally. This is the single highest-leverage change you can make to a codebase that uses raw fetch() calls scattered across components.
📊 Production Insight
fetch() resolves on 404/500/503 — the Promise rejects only on network-level failures where no HTTP response was received.
response.json() returns a Promise — always await it. Missing await on body parsing is silent and produces a Promise where downstream code expects an object.
The body stream can only be read once — if you need the raw text for debugging before parsing, call response.text() and then JSON.parse() manually.
Rule: wrap fetch in a utility that enforces response.ok checking, timeout, and error logging on every call — never let these concerns scatter across call sites.
🎯 Key Takeaway
fetch() resolves on any server response — 404 and 500 do not reject the Promise. The two-step parse (resolve, then json()) exists because the body is a ReadableStream that must be consumed asynchronously. Always check response.ok before parsing. Always await response.json(). And always wrap fetch in a utility that enforces both — structural enforcement beats disciplinary enforcement every time.
Fetch Response Handling Decision Tree
IfResponse status is 200-299 and body is JSON
UseCheck response.ok (true), then await response.json() — standard happy path
IfResponse status is 4xx or 5xx
UseThrow an error with the status code and any available error body — do not attempt to parse the error body as application data
IfResponse body may not be JSON — file download, plain text API, HTML error page
UseUse response.text() or response.blob() instead of response.json() — calling response.json() on a non-JSON body throws a SyntaxError
IfNeed to inspect response headers before committing to body parsing
UseAccess response.headers before calling response.json() — headers are available as soon as fetch() resolves, before the body stream is read

Making POST Requests — Sending Data to a Server

GET requests retrieve data. POST requests send it. With Fetch, a POST request uses an options object that specifies the method, headers, and body. The body must be a string — Fetch does not serialize JavaScript objects automatically — so you serialize with JSON.stringify() before passing it.

You must set the Content-Type header to application/json when sending JSON. Without it, most modern backends — Express, FastAPI, Spring Boot, Rails — cannot determine the body format and return a 400 Bad Request or 415 Unsupported Media Type. The request fails, and the error message from the server often does not clearly say 'missing Content-Type', which sends developers chasing the wrong cause.

This is the most common POST bug in production, and it happens with a specific pattern: developers familiar with Axios switch to Fetch and forget that Axios automatically sets Content-Type to application/json when the body is an object. Fetch does not. This single behavioral difference between the two libraries accounts for a disproportionate number of API integration failures. The fix is always the same — add the header explicitly — but the debugging path is not always obvious.

A second concern unique to POST requests is double-submission. When a user submits a form and the network is slow, the button remains active and a second click fires a second identical request. Both requests reach the server, both create records, and the user has two orders, two accounts, or two payments. The prevention is straightforward: disable the submit button the moment the fetch starts, and re-enable it in the finally block — not the try block — so it re-enables regardless of whether the request succeeded or failed.

io/thecodeforge/fetch/create-blog-post-fetch.js · JAVASCRIPT
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
/**
 * io.thecodeforge: POST request with full production safety
 *
 * Three things this example enforces that most tutorials omit:
 * 1. Content-Type header — required, not auto-set by Fetch
 * 2. response.ok check — POST can return 400/422/500 silently
 * 3. Double-submission prevention — button disabled during in-flight request
 */

async function createBlogPost(postData, submitButton = null) {
  // Prevent double-submission: disable button before the request starts
  if (submitButton) submitButton.disabled = true;

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',
      headers: {
        // Required — Fetch does NOT auto-set this like Axios does
        // Without it: server returns 400 Bad Request or 415 Unsupported Media Type
        'Content-Type': 'application/json',

        // Authorization header — replace with your actual token mechanism
        // In production: read from a token store, not a hardcoded string
        'Authorization': `Bearer ${getAuthToken()}`,
      },
      // body must be a string — passing a raw object sends '[object Object]'
      body: JSON.stringify(postData),
    });

    // POST requests can return 400/422/500 — check.ok here too
    if (!response.ok) {
      // Attempt to read server error body for better error messages
      let serverMessage = response.statusText;
      try {
        const errorData = await response.json();
        serverMessage = errorData.message ?? errorData.error ?? serverMessage;
      } catch {
        // Server returned non-JSON error body — use statusText
      }
      throw new Error(`Failed to create post: HTTP ${response.status} — ${serverMessage}`);
    }

    const result = await response.json();
    console.log('Resource created with ID:', result.id);
    return result;
  } catch (error) {
    console.error('[createBlogPost] POST error:', error.message);
    // In production: report to Sentry with postData context (redact PII first)
    return null;
  } finally {
    // Re-enable in finally — not try — so it re-enables on both success and failure
    if (submitButton) submitButton.disabled = false;
  }
}

function getAuthToken() {
  // In production: read from secure storage, not a hardcoded literal
  return sessionStorage.getItem('auth_token') ?? '';
}

// Usage — pass the button reference for double-submission prevention
const submitBtn = document.querySelector('#submit-post');
createPostButton.addEventListener('click', () => {
  createBlogPost(
    { title: 'Async JS in 2026', body: 'Fetch is the baseline.', userId: 1 },
    submitBtn
  );
});
🔥Interview Gold: Why Do We Disable the Button?
Disabling the submit button while a fetch is in-flight prevents double-submission race conditions. If a user clicks twice before the first request finishes, you could end up with duplicate records in your database — two orders, two accounts, two payment charges. The button re-enable goes in the finally block, not the try block, so it works on both success and error paths. If you put it only in try and the request fails, the button stays disabled forever and the user cannot retry without refreshing the page.
📊 Production Insight
Missing Content-Type header is the single most common cause of 400/415 errors on POST requests from JavaScript clients.
Fetch does not auto-set Content-Type when the body is an object — unlike Axios. This catches developers switching between the two.
Passing a raw JavaScript object to the body field silently sends the string '[object Object]' — which is not JSON and will fail every server validation.
Rule: always include Content-Type: application/json, always JSON.stringify the body, and always disable the submit button for the duration of any write request.
🎯 Key Takeaway
POST requires an explicit Content-Type header — Fetch does not auto-set it the way Axios does. Always JSON.stringify the body — passing a raw object sends '[object Object]', not JSON. Disable submit buttons during fetch and re-enable in finally — the finally block is the only path that covers both success and failure without duplicating code.
POST Request Configuration Decision Tree
IfSending JSON data to a REST API
UseSet Content-Type: application/json and body: JSON.stringify(data) — both are required, neither is optional
IfUploading a file — multipart form data
UseUse FormData as the body — do NOT set Content-Type manually. The browser sets it automatically with the correct multipart boundary string. Setting it manually breaks the boundary.
IfSending form-encoded data to a legacy API
UseSet Content-Type: application/x-www-form-urlencoded and use URLSearchParams as the body — it serializes correctly for this format
IfPOST returns 415 despite Content-Type being set
UseVerify the exact header value — some servers reject 'application/json; charset=utf-8' when they expect exactly 'application/json'. Check for typos, extra whitespace, or charset suffix issues.

Fetching Data and Updating the DOM — A Real Mini-App

Theory only gets you so far. The real test is wiring everything together into a working pattern: a page that fetches a list of posts and renders them into the DOM, handles loading states, and recovers gracefully from errors without leaving the UI in a broken state.

The three-layer pattern — Data Layer, Render Layer, and Controller — is not organizational aesthetic. It is a testing strategy. You can unit-test the data layer with a mocked fetch that returns controlled payloads. You can unit-test the render layer with sample data without needing a network. You can integration-test the controller with both. If these concerns are interleaved — if a single function both fetches data and manipulates the DOM — testing requires a live DOM and a live server. That means slow, flaky, environment-dependent tests that developers stop trusting and eventually stop running.

Two common mistakes appear in almost every tutorial implementation. First: the loading spinner hide is placed inside the try block, which means a failed request leaves the spinner spinning indefinitely. Users see a loading indicator with no data and no error message — the worst possible state. The fix is the finally block. Second: multiple independent fetches are awaited sequentially when they could run in parallel. If your page needs user data, product data, and recommendation data on load, awaiting them one at a time means total load time is the sum of all three. Promise.all fires all three simultaneously and waits for the slowest one — total time is the maximum, not the sum.

io/thecodeforge/fetch/post-list-mini-app.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
/**
 * io.thecodeforge: Three-layer fetch + DOM pattern
 *
 * Data Layer:   fetchPosts() — knows how to talk to the API
 * Render Layer: renderPosts() — knows how to update the DOM
 * Controller:   initApp() — orchestrates them, manages UI state
 *
 * Each layer is independently testable.
 * fetchPosts can be tested with a mocked fetch.
 * renderPosts can be tested with sample data and a real DOM node.
 * initApp integration test uses both.
 */

// ─── DATA LAYER ───────────────────────────────────────────────────────────────
async function fetchPosts(limit = 3) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_limit=${limit}`
  );
  if (!response.ok) {
    throw new Error(`Failed to load posts: HTTP ${response.status}`);
  }
  return response.json();
}

// ─── RENDER LAYER ────────────────────────────────────────────────────────────
function renderPosts(posts, container) {
  if (!posts.length) {
    container.innerHTML = '<p class="empty-state">No posts available.</p>';
    return;
  }
  // Template literals — sanitize user-generated content in production
  // Use DOMPurify or textContent assignment for untrusted data
  container.innerHTML = posts
    .map(
      post => `
        <article class="card" data-post-id="${post.id}">
          <h4 class="card__title">${post.title}</h4>
          <p class="card__body">${post.body}</p>
          <footer class="card__meta">Post #${post.id}</footer>
        </article>
      `
    )
    .join('');
}

function renderError(message, container) {
  container.innerHTML = `
    <div class="error-state" role="alert">
      <p>⚠ ${message}</p>
      <button onclick="initApp()">Retry</button>
    </div>
  `;
}

function setLoadingState(isLoading, container, spinner) {
  // Spinner and container managed separately for accessibility
  spinner.hidden = !isLoading;
  spinner.setAttribute('aria-busy', String(isLoading));
  if (isLoading) container.innerHTML = '';
}

// ─── CONTROLLER ───────────────────────────────────────────────────────────────
async function initApp() {
  // In a real browser environment:
  // const container = document.getElementById('post-list');
  // const spinner   = document.getElementById('spinner');
  // const timestamp = document.getElementById('last-updated');

  // Simulated DOM nodes for demonstration
  const container = { innerHTML: '', setAttribute: () => {} };
  const spinner   = { hidden: false, setAttribute: () => {} };

  setLoadingState(true, container, spinner);

  try {
    const posts = await fetchPosts(3);
    renderPosts(posts, container);

    // Show last-updated timestamp — lets users detect stale data
    // timestamp.textContent = `Last updated: ${new Date().toLocaleTimeString()}`;
    console.log('[App] Posts loaded at', new Date().toLocaleTimeString());
  } catch (error) {
    renderError(error.message, container);
    console.error('[App] Failed to load posts:', error.message);
    // In production: report to Sentry with request context
  } finally {
    // CRITICAL: always in finally — not try
    // A failed request must hide the spinner too
    setLoadingState(false, container, spinner);
  }
}

initApp();

// ─── PARALLEL FETCH PATTERN ───────────────────────────────────────────────────
// When a page needs multiple independent data sources on load,
// use Promise.all — not sequential awaits.
//
// Sequential (slow — total time = sum of all requests):
// const users    = await fetchUsers();
// const posts    = await fetchPosts();
// const comments = await fetchComments();
//
// Parallel (fast — total time = slowest single request):
async function initDashboard() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch('https://jsonplaceholder.typicode.com/users?_limit=5').then(r => {
        if (!r.ok) throw new Error(`Users: HTTP ${r.status}`);
        return r.json();
      }),
      fetch('https://jsonplaceholder.typicode.com/posts?_limit=5').then(r => {
        if (!r.ok) throw new Error(`Posts: HTTP ${r.status}`);
        return r.json();
      }),
      fetch('https://jsonplaceholder.typicode.com/comments?_limit=5').then(r => {
        if (!r.ok) throw new Error(`Comments: HTTP ${r.status}`);
        return r.json();
      }),
    ]);

    console.log('Dashboard data loaded:', {
      users: users.length,
      posts: posts.length,
      comments: comments.length,
    });
  } catch (error) {
    // Promise.all rejects on the first failure
    // Use Promise.allSettled() if you want partial success
    console.error('[Dashboard] Parallel fetch failed:', error.message);
  }
}

initDashboard();
💡Always Use a finally Block for UI State
If you hide your loading spinner only in the try block, a failed request leaves it spinning indefinitely. The user sees an infinite loading indicator with no data and no error — the worst UI state possible. The finally block runs regardless of success or failure, which means loading state, spinner visibility, and button re-enable always belong there. This is one of those rules that feels obvious once you have watched a production spinner spin forever during an incident.
📊 Production Insight
Interleaving fetch and render logic in the same function makes unit testing require both a live DOM and a live server — slow, flaky, and brittle.
Separate Data, Render, and Controller layers — each testable independently with mocks or sample data.
Sequential awaits on independent fetches add latency equal to the sum of all requests — Promise.all makes it the maximum of one.
Rule: if a function both fetches and updates the DOM, split it. If multiple data sources are independent, fetch them in parallel.
🎯 Key Takeaway
Separate Data, Render, and Controller layers — the separation is a testing strategy, not just code organization. Use finally for all UI state changes — spinners and buttons that reset only on success leave users in broken states on failure. Use Promise.all for independent fetches — sequential awaits multiply latency unnecessarily. Use AbortController for user-triggered fetches — stale responses overwriting fresh results is a real, user-visible bug.
DOM Update Pattern Decision Tree
IfSingle fetch on page load — standard content page
UseUse the three-layer pattern: Data -> Render -> Controller with finally block for UI state
IfMultiple independent fetches on page load — dashboard with user, product, and metric data
UseUse Promise.all() for parallel fetches. Use Promise.allSettled() if partial success is acceptable and you want to render whatever succeeded
IfUser-triggered fetches — search input, filter changes, pagination
UseAbort previous in-flight request with AbortController before starting the new one — prevents stale response from overwriting fresher results
IfReal-time data updates — chat messages, live prices, collaborative editing
UseUse WebSocket or Server-Sent Events — repeated fetch polling is wasteful, adds latency, and does not scale to high-frequency updates
🗂 XMLHttpRequest vs Fetch API: Complete Comparison
Every meaningful difference between the two approaches — reference this before deciding which to use
Feature / AspectXMLHttpRequest (XHR)Fetch API
Syntax styleCallback-based, event-driven, verbose — attach handlers before sendingPromise-based, async/await compatible, linear code structure
Error handlingManual readyState + status check in onreadystatechange, separate onerror handler for network failuresChecks response.ok for HTTP errors; Promise only rejects on network failure — must check ok manually
Response parsingManual JSON.parse(xhr.responseText) — synchronous, available when readyState === 4Built-in response.json() — asynchronous, returns a Promise, must be awaited
Request cancellationxhr.abort() — simple but not composable with async/await patternsAbortController signal — reusable, composable, works cleanly with async/await
ComposabilityCannot participate in Promise.all, Promise.race, or async/await without custom wrappersNative Promise — composes directly with all Promise combinators and async/await
Upload progressSupported via xhr.upload.onprogress — shows bytes uploaded in real timeNot supported — known Fetch API limitation with no current spec solution
Request timeoutBuilt-in xhr.timeout property — simple integer in millisecondsNo built-in timeout — implement with AbortController + setTimeout, clear in finally
Cookie handlingxhr.withCredentials = true to include cookies on cross-origin requestscredentials: 'include' in the options object for cross-origin cookie inclusion

🎯 Key Takeaways

  • fetch() resolves on any server response — always check response.ok or response.status before parsing the body. A 404 or 500 response that is parsed as valid data is a silent failure, and silent failures are worse than visible errors.
  • The two-step parse exists because the response body is a ReadableStream — always await response.json(). Missing await is silent at runtime and produces a Promise where downstream code expects an object.
  • Separate data-fetching and DOM-rendering into independent functions — the separation is a testing strategy, not just code organization. Functions that both fetch and render require a live DOM and a live server to test.
  • The Content-Type header is not optional for POST requests — it defines the contract between client and server about how the body is encoded. Fetch does not auto-set it. Always set it explicitly.
  • The finally block is the only correct place for UI state resets — spinner hiding, button re-enabling, loading flag clearing. A try block that handles success but not failure leaves users in broken states.

⚠ Common Mistakes to Avoid

    Trusting that fetch() rejects on HTTP errors like 404 or 500
    Symptom

    fetch() resolves successfully on 404/500 responses. Code proceeds to call response.json() on the error body and treats it as valid application data. The error body fields do not match the expected data shape, so the UI renders empty state, stale data, or crashes on property access — with no error visible to the user or in the console.

    Fix

    Always check response.ok immediately after fetch() resolves. If response.ok is false, throw an error with the status code and status text: if (!response.ok) throw new Error(HTTP ${response.status}: ${response.statusText}). Wrap fetch in a utility function that enforces this check structurally — do not rely on developers remembering to add it at every call site.

    Forgetting that response.json() is also asynchronous — missing await on body parsing
    Symptom

    const data = response.json() without await produces a pending Promise object assigned to data. Every subsequent property access — data.name, data.id, data.results — throws TypeError: Cannot read properties of undefined. The error message points to the property access line, not the missing await, which sends debugging in the wrong direction.

    Fix

    Always await response.json(): const data = await response.json(). Add the ESLint rule no-floating-promises to catch unhandled Promise assignments at lint time before they reach production. If you need to inspect the raw body before parsing — useful during debugging — call await response.text() first and then JSON.parse() the result manually.

    Missing the Content-Type header on POST requests when sending JSON
    Symptom

    POST request returns 400 Bad Request or 415 Unsupported Media Type from the server. The server cannot identify the body format and rejects it. This is particularly confusing because the same request works correctly in Postman, which automatically sets Content-Type to application/json.

    Fix

    Always include 'Content-Type': 'application/json' in the headers object for any POST request sending JSON. Unlike Axios, Fetch does not automatically set this header when the body is a string. Also verify the body is JSON.stringify(data) — passing a raw JavaScript object to the body field silently sends the string '[object Object]', which is not JSON.

    Not disabling the submit button during fetch — allowing double-submission
    Symptom

    User clicks submit twice before the first request completes, creating two identical records in the database — two orders, two accounts, two payments. The bug is only reproducible under slow network conditions or server latency spikes, which means it passes local testing and only surfaces in production.

    Fix

    Set submitButton.disabled = true before the fetch call and re-enable it in the finally block — not the try block. The finally block guarantees re-enable on both success and failure paths. If re-enable is in the try block, a failed request leaves the button permanently disabled and the user cannot retry without a page refresh.

    Using sequential awaits for independent fetch calls — multiplying page load time
    Symptom

    Page load takes 2-4 seconds because three independent API calls are awaited one after the other. Each await blocks until the previous completes, even though the calls share no data dependency. Total time is the sum of all three requests instead of the maximum of one.

    Fix

    Use Promise.all() for independent fetches: const [users, posts, comments] = await Promise.all([fetchUsers(), fetchPosts(), fetchComments()]). All three fire simultaneously and the await resolves when the slowest one completes. Use Promise.allSettled() when partial success is acceptable — it resolves with an array of settled outcomes regardless of individual failures.

Interview Questions on This Topic

  • QWhy does the Fetch API's promise resolve even when the server returns a 404 or 500 status code?JuniorReveal
    The Fetch API distinguishes between two fundamentally different failure types: network-level failures and HTTP-level errors. A network failure means the browser could not reach the server at all — DNS resolution failed, the connection was refused, there was no internet connection, or a CORS policy rejected the request before it left the browser. In these cases, no HTTP response was received, and fetch() rejects the Promise. An HTTP error like 404 or 500 means the server was successfully reached, processed the request, and returned a response — it just used a status code outside the 200-299 range to communicate an error condition. From the browser's perspective, the HTTP transaction completed successfully. The Promise resolves because the network communication worked. This distinction reflects the HTTP specification: a 404 response IS a valid, complete HTTP response. The server received the request and told the client the resource does not exist. The design puts the responsibility for HTTP error handling on application code rather than the browser runtime. To handle HTTP errors, you check response.ok — which is true for 200-299 only — and throw manually. This is the single most important thing to understand about Fetch and the source of the most common production bug when developers switch from Axios, which throws automatically on non-2xx responses.
  • QExplain the difference between a 'Network Error' and an 'HTTP Error' in the context of the Fetch API.Mid-levelReveal
    A Network Error means the browser could not establish or complete an HTTP connection. Common causes: DNS resolution failure, no internet connection, the server actively refusing the connection, or a CORS policy rejection before the preflight completes. Network errors reject the fetch Promise — they are caught by .catch() or the catch block in async/await. An HTTP Error means the browser successfully connected to the server, sent the request, and received a complete HTTP response — but the status code indicates a problem: 404 Not Found, 500 Internal Server Error, 503 Service Unavailable, 403 Forbidden. HTTP errors resolve the fetch Promise — they are not caught by .catch(). You must check response.ok and throw an error manually. The practical consequence is direct: if your error handling only has a catch block with no response.ok check, you will catch network failures but silently ignore every HTTP error. A 500 from your API will appear to succeed, you will try to parse the error body as application data, and your UI will render incorrect or empty state with no error visible to users or in the console. This is why production fetch wrappers always include the response.ok check — it closes the gap between what developers expect (rejection on error) and what the API actually does (resolution on any server response).
  • QWhy does response.json() return a Promise instead of the parsed object directly?Mid-levelReveal
    The Response body is a ReadableStream. When fetch() resolves and you receive the Response object, the browser has received the HTTP headers but may not have received the complete response body yet — particularly for large responses. Calling response.json() initiates the process of reading the body stream to completion and then parsing the accumulated bytes as JSON. Both steps — reading the stream and parsing the JSON — are performed asynchronously without blocking the main thread. If response.json() were synchronous, it would need to block the JavaScript execution thread until the entire response body arrived from the network. For large responses — API endpoints returning thousands of records, file content, image data — this would freeze the browser UI for perceptible durations. The streaming architecture also explains why response.text(), response.blob(), and response.arrayBuffer() are all asynchronous — they all consume the same underlying ReadableStream. It also explains why you can only read the body once: after calling response.json(), the stream is consumed. Calling response.text() on the same response afterwards throws a TypeError because there is nothing left to read. This one-read constraint is practically important during debugging: if you want to log the raw response body before parsing it, call response.text() first and then JSON.parse() manually rather than calling response.json() and then trying to inspect the original bytes.
  • QHow do you handle a race condition where multiple fetch requests are fired in sequence and the responses arrive out of order?SeniorReveal
    Use AbortController to cancel the previous in-flight request before starting a new one. This is the correct solution for user-triggered fetches — search inputs, filter changes, or typeahead completions — where a newer request supersedes all older ones. ``javascript let currentController = null; async function search(query) { // Abort any in-flight request from a previous call if (currentController) currentController.abort(); currentController = new AbortController(); try { const response = await fetch(/api/search?q=${encodeURIComponent(query)}, { signal: currentController.signal, }); if (!response.ok) throw new Error(HTTP ${response.status}); return await response.json(); } catch (error) { // AbortError is expected — a newer search superseded this one // Do not surface this to the user or error reporting if (error.name === 'AbortError') return null; // Real errors: network failure, server error — handle and report throw error; } } `` The AbortError must be caught separately and silently ignored — it signals an intentional cancellation, not a failure. Conflating AbortError with real errors produces false error reports and confusing user-facing messages. In React, you implement this in useEffect by storing the controller in a ref and calling abort() in the cleanup function — the cleanup runs before the next effect fires, which is exactly the right moment to cancel the stale request.
  • QWhat is an AbortController and how would you use it to cancel a fetch request when a user navigates away from a page?JuniorReveal
    AbortController is a browser API that creates a signal you can attach to one or more fetch calls. Calling controller.abort() sends a cancellation signal through the signal object, which causes any attached fetch calls to reject with an AbortError. For navigation cleanup in a vanilla JavaScript single-page application: ``javascript const controller = new AbortController(); // Abort all attached requests when the user leaves window.addEventListener('beforeunload', () => controller.abort()); // Or in a router's navigation guard: // router.beforeEach((to, from, next) => { controller.abort(); next(); }) fetch('/api/large-report', { signal: controller.signal }) .then(res => { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }) .then(data => renderReport(data)) .catch(error => { if (error.name === 'AbortError') { // User navigated away — expected, not an error return; } console.error('Report fetch failed:', error.message); }); ` In React, the canonical pattern is a useEffect cleanup: `javascript useEffect(() => { const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then(res => res.ok ? res.json() : Promise.reject(res.status)) .then(data => setData(data)) .catch(err => { if (err.name !== 'AbortError') setError(err); }); // Cleanup: abort when the component unmounts or url changes return () => controller.abort(); }, [url]); `` Without AbortController, navigating away from a page mid-request wastes bandwidth on a download whose result will never be used, and in React causes the 'setState on unmounted component' warning — or in newer React versions, silently drops the update if the component is already gone.

Frequently Asked Questions

Is Fetch better than Axios?

Fetch is built into every modern browser and Node.js 18+ — no dependency, no bundle size impact, no version management. For straightforward GET and POST requests with manual error handling, Fetch is the right default.

Axios adds features that Fetch does not have out of the box: automatic JSON serialization and Content-Type setting, request and response interceptors for global auth header injection, automatic throws on non-2xx status codes, and built-in request cancellation with cleaner syntax. For complex applications that need interceptors — adding auth tokens to every request, logging every response, retrying on 429 rate limit — Axios pays for its dependency.

For new projects: start with Fetch wrapped in a utility function. Add Axios if you find yourself rebuilding interceptor logic. Do not add Axios as a default dependency before you know you need it.

How do I handle timeouts with Fetch?

Fetch has no built-in timeout property. Implement it with AbortController and setTimeout:

```javascript async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

try { const response = await fetch(url, { ...options, signal: controller.signal, }); return response; } catch (error) { if (error.name === 'AbortError') { throw new Error(Request timed out after ${timeoutMs}ms); } throw error; } finally { clearTimeout(timeoutId); // Always clear to prevent memory leaks } } ```

Clear the timeout in the finally block — if you only clear on success, a failed request leaves the timeout running until it fires and aborts a request that has already completed.

Can I use Fetch in a Node.js environment?

Yes — as of Node.js 18, the Fetch API is available as a global without any import. Node.js 21 stabilized the implementation further. For Node.js 16 and earlier, install the node-fetch package (npm install node-fetch) and import it explicitly.

One behavioral difference worth noting: Node.js Fetch does not handle cookies the same way browser Fetch does — the browser's cookie jar is not available in Node. For server-side requests that need cookie management, you will need to handle Cookie headers manually or use a library like got or undici that provides native Node.js HTTP handling.

How do I cancel a fetch request when a React component unmounts?

Create an AbortController inside useEffect and call abort() in the cleanup function. The cleanup runs when the component unmounts and also before the effect re-runs when dependencies change:

```javascript useEffect(() => { const controller = new AbortController();

async function loadData() { try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) throw new Error(HTTP ${response.status}); const data = await response.json(); setData(data); } catch (error) { // AbortError is expected on unmount — do not set error state if (error.name !== 'AbortError') { setError(error.message); } } }

loadData();

return () => controller.abort(); // Cleanup: abort on unmount or url change }, [url]); ```

This prevents the React warning about state updates on unmounted components and cancels wasted bandwidth on requests whose results will never be rendered.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousEvent Delegation in JavaScriptNext →LocalStorage and SessionStorage
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged