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

In Plain English 🔥
Imagine you're at a restaurant. Old-school web pages work like a waiter who takes your order, disappears into the kitchen, and makes you sit in the dark until the ENTIRE meal is ready — then slams the whole thing on the table at once. AJAX and the Fetch API are like a modern waiter who keeps checking back: 'Your soup is ready — here it is. Still working on the steak — hang tight.' Your page stays alive and usable while data arrives in the background, piece by piece.
⚡ Quick Answer
Imagine you're at a restaurant. Old-school web pages work like a waiter who takes your order, disappears into the kitchen, and makes you sit in the dark until the ENTIRE meal is ready — then slams the whole thing on the table at once. AJAX and the Fetch API are like a modern waiter who keeps checking back: 'Your soup is ready — here it is. Still working on the steak — hang tight.' Your page stays alive and usable while data arrives in the background, piece by piece.

Every time you scroll Twitter and new tweets appear without a page refresh, search Google and see suggestions drop down as you type, or add something to your Amazon cart without being yanked to a new page — that's AJAX at work. It's 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 action.

Before AJAX, every interaction 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) 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 powerful than its predecessor, XMLHttpRequest.

By the end of this article you'll understand why the Fetch API exists and what it replaced, how to make GET and POST requests with real error handling (not the fake kind that ignores HTTP errors), how to build a working mini-app that fetches live data, and the subtle async mistakes that silently break production code. Let's build something real.

Why XMLHttpRequest Existed — and Why Fetch Replaced It

To appreciate Fetch, you need to feel the pain it solved. XMLHttpRequest (XHR) was introduced by Microsoft in the late 1990s and later standardized. It let JavaScript talk to a server without a page reload — revolutionary at the time. But its API is verbose, callback-heavy, and genuinely unpleasant to work with in 2024.

With XHR, you create an object, attach multiple event handlers for different states, open a connection, and then send — all before you get a single byte back. Nesting multiple XHR calls (fetch a user, then fetch their posts, then fetch comments on each post) produces deeply indented callback hell that's nearly impossible to read or debug.

The Fetch API, introduced in 2015 and now supported in every modern browser, uses Promises natively. Promises let you chain asynchronous operations with .then() or write them sequentially with async/await. The code reads almost like synchronous logic, which massively reduces bugs. Fetch is not just syntactic sugar — it's a complete redesign of how JavaScript handles HTTP that aligns with the modern async model of the language.

xhr-vs-fetch-comparison.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233
// ─────────────────────────────────────────────────────────────
// THE OLD WAY: XMLHttpRequest — notice how noisy this is
// ─────────────────────────────────────────────────────────────
const xhr = new XMLHttpRequest();

// You must specify the method and URL before anything else
xhr.open('GET', 'https://jsonplaceholder.typicode.com/users/1');

// Attach a handler — this fires every time readyState changes (0 through 4)
xhr.onreadystatechange = function () {
  // readyState 4 means the request is complete
  if (xhr.readyState === 4 && xhr.status === 200) {
    const user = JSON.parse(xhr.responseText); // response is a raw string — must parse manually
    console.log('XHR result:', user.name);
  }
};

xhr.send(); // kick off the request


// ─────────────────────────────────────────────────────────────
// THE MODERN WAY: Fetch API — same result, dramatically cleaner
// ─────────────────────────────────────────────────────────────
fetch('https://jsonplaceholder.typicode.com/users/1')
  // fetch() returns a Promise that resolves to a Response object
  .then(response => response.json()) // .json() parses the body — it also returns a Promise
  .then(user => {
    console.log('Fetch result:', user.name);
  })
  .catch(error => {
    // This catches network failures (no internet, DNS error, etc.)
    console.error('Network error:', error.message);
  });
▶ Output
XHR result: Leanne Graham
Fetch result: Leanne Graham
⚠️
Watch Out: XMLHttpRequest Is Not 'Dead'You'll still encounter XHR in legacy codebases and some older libraries (like jQuery's $.ajax under the hood). Knowing it exists is important — but write all new code with Fetch or a library like Axios that wraps it.

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

Here's the thing that trips up almost every developer the first time: fetch() resolves its Promise as soon as the server responds with headers — even if the response is a 404 or a 500 error. The Promise only rejects on a true network failure (like your laptop being offline). This is the single biggest gotcha in the Fetch API.

When fetch() resolves, you get a Response object — not your data. Think of it as an envelope. You know the envelope arrived, but you haven't opened it yet. You call .json(), .text(), or .blob() on that envelope to extract the contents, and that also returns a Promise because reading the stream takes time.

This two-step dance — fetch() then .json() — is not a quirk, it's by design. The Fetch API streams data, which means it can start processing a response before the entire body has arrived. That makes it memory-efficient for large payloads.

Using async/await flattens this beautifully and lets you write proper error-checking logic. Always check response.ok (which is true for status codes 200-299) before parsing the body.

fetch-with-proper-error-handling.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// A reusable async function that handles BOTH network failures
// AND HTTP error responses (404, 500, etc.) correctly
async function fetchUserProfile(userId) {
  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${userId}`
    );

    // ⚠️  Critical check: fetch() does NOT throw on 404 or 500
    // response.ok is true only for status codes 200-299
    if (!response.ok) {
      // Create a real error so our catch block handles it uniformly
      throw new Error(
        `Server returned ${response.status}: ${response.statusText}`
      );
    }

    // Now we parse the JSON body — this is the second async step
    const userProfile = await response.json();

    // Destructure only the fields we care about
    const { name, email, address } = userProfile;

    return {
      name,
      email,
      city: address.city, // nested access from the API response
    };
  } catch (networkOrHttpError) {
    // This catches BOTH network errors AND our manually thrown HTTP errors
    console.error('Failed to fetch user profile:', networkOrHttpError.message);
    return null; // return null so callers can check gracefully
  }
}

// ─────────────────────────────────────────────────────────────
// Usage: run two fetches — one valid, one for a user that doesn't exist
// ─────────────────────────────────────────────────────────────
async function demonstrateFetch() {
  console.log('--- Fetching valid user (ID: 3) ---');
  const validUser = await fetchUserProfile(3);
  if (validUser) {
    console.log(`Name: ${validUser.name}`);
    console.log(`Email: ${validUser.email}`);
    console.log(`City: ${validUser.city}`);
  }

  console.log('\n--- Fetching non-existent user (ID: 9999) ---');
  const missingUser = await fetchUserProfile(9999);
  if (!missingUser) {
    console.log('User not found — showing fallback UI.');
  }
}

demonstrateFetch();
▶ Output
--- Fetching valid user (ID: 3) ---
Name: Clementine Bauch
Email: Nathan@yesenia.net
City: McKenziehaven

--- Fetching non-existent user (ID: 9999) ---
Failed to fetch user profile: Server returned 404: Not Found
User not found — showing fallback UI.
⚠️
Pro Tip: Build a Wrapper Once, Use It EverywhereWrap `fetch()` in a single `apiRequest(url, options)` utility function for your whole project. Put the `response.ok` check, JSON parsing, and error throwing inside it. Every feature that needs data calls your wrapper — not raw fetch. This way, when the API adds auth headers or rate-limit handling, you change one function, not fifty.

Making POST Requests — Sending Data to a Server

GET requests read data. POST requests send it — think submitting a form, creating a new record, or logging a user in. With Fetch, a POST request uses the same function but with an options object that specifies the method, headers, and body.

The body must be a string, so you serialize your JavaScript object with JSON.stringify(). And you must set the Content-Type header to application/json — without it, many servers won't know how to parse the incoming data and will silently return an error or behave unexpectedly.

Here's a pattern you'll use constantly in real apps: a form captures user input, a submit handler calls an async function, that function posts the data, and the UI updates based on the response — all without a page refresh. That's modern web development in a nutshell.

create-blog-post-fetch.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Real-world pattern: posting a new blog article to a REST API
async function createBlogPost(title, body, authorId) {
  const newPost = {
    title: title,
    body: body,
    userId: authorId,
  };

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
      method: 'POST',                  // Tell the server we're creating a resource
      headers: {
        'Content-Type': 'application/json',  // Tell the server the body is JSON
        'Authorization': 'Bearer my-api-token-here', // Many APIs require auth
      },
      body: JSON.stringify(newPost),   // JavaScript objects MUST be serialized to a string
    });

    if (!response.ok) {
      throw new Error(`Post creation failed with status: ${response.status}`);
    }

    // The server returns the newly created post with its assigned ID
    const createdPost = await response.json();
    console.log('Post created successfully!');
    console.log('Server-assigned ID:', createdPost.id);
    console.log('Title:', createdPost.title);
    console.log('Author ID:', createdPost.userId);

    return createdPost;
  } catch (error) {
    console.error('Could not create post:', error.message);
    return null;
  }
}

// Simulating a form submission handler
async function handleFormSubmit(event) {
  // In a browser, you'd call event.preventDefault() here to stop page reload
  // event.preventDefault();

  const submitButton = { disabled: true };  // In real DOM: document.querySelector('button')
  submitButton.disabled = true;             // Prevent double-submissions while waiting

  const result = await createBlogPost(
    'Why JavaScript Async/Await Changed Everything',
    'Before async/await, chaining promises was the gold standard...',
    42
  );

  submitButton.disabled = false;            // Re-enable after response arrives

  if (result) {
    console.log(`\nRedirecting to post #${result.id}...`);
  }
}

handleFormSubmit();
▶ Output
Post created successfully!
Server-assigned ID: 101
Title: Why JavaScript Async/Await Changed Everything
Author ID: 42

Redirecting to post #101...
🔥
Interview Gold: Why Do We Disable the Button?Disabling the submit button while a fetch is in-flight prevents the user from firing duplicate requests (race conditions). Without it, a user clicking twice could create two records. Always disable UI controls during async operations and re-enable them in a `finally` block — that way they re-enable even if the request fails.

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

Theory only gets you so far. Let's wire everything together into a working pattern you'd actually ship: a page that fetches a list of posts from an API and renders them into the DOM, with a loading state and error handling.

This covers the full lifecycle of AJAX in a real app: the user lands on the page, a spinner shows while data loads, the data renders into the DOM, and if something goes wrong, the user sees a helpful message instead of a blank screen.

Notice how the data-fetching logic is completely separate from the DOM-rendering logic. This separation of concerns is not just good style — it makes your code testable. You can test fetchPosts() by checking what it returns, and test renderPosts() by passing it mock data, all without touching the network.

post-list-mini-app.js · JAVASCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// ─────────────────────────────────────────────────────────────
// Mini-app: Fetch blog posts and render them into the DOM
// In a browser, this runs after the HTML has loaded
// ─────────────────────────────────────────────────────────────

// ── DATA LAYER: handles fetching only, no DOM touches here ──
async function fetchLatestPosts(limit = 5) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_limit=${limit}`
  );

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json(); // returns a Promise<Array>
}

// ── RENDER LAYER: handles DOM only, no fetch logic here ──
function renderPosts(posts, containerElement) {
  // Build all HTML as a string for a single DOM write (better performance than
  // appending elements one-by-one in a loop)
  const postListHTML = posts
    .map(
      post => `
      <article class="post-card">
        <h3>${escapeHtml(post.title)}</h3>
        <p>${escapeHtml(post.body)}</p>
        <small>Post #${post.id}</small>
      </article>
    `
    )
    .join('');

  containerElement.innerHTML = postListHTML;
}

// Prevent XSS: never inject user/API data into innerHTML without escaping it
function escapeHtml(unsafeString) {
  return unsafeString
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;');
}

// ── CONTROLLER: orchestrates loading state, fetching, and rendering ──
async function initPostList() {
  // In a real browser app these would be actual DOM elements:
  // const container = document.getElementById('post-list');
  // const loadingSpinner = document.getElementById('loading');
  // Simulating with plain objects here so this runs in Node.js too
  const container = { innerHTML: '' };
  const loadingSpinner = { hidden: false };

  // Step 1: Show the loading state immediately
  loadingSpinner.hidden = false;
  console.log('[UI] Showing loading spinner...');

  try {
    // Step 2: Go get the data
    const posts = await fetchLatestPosts(3);
    console.log(`[Data] Received ${posts.length} posts from API`);

    // Step 3: Render data into the DOM
    renderPosts(posts, container);
    console.log('[UI] Posts rendered to DOM');
    console.log('[Preview] First post title:', posts[0].title);
  } catch (error) {
    // Step 4: If anything went wrong, show an error state — never a blank screen
    container.innerHTML = `<p class="error">Failed to load posts: ${error.message}</p>`;
    console.error('[UI] Displayed error message to user:', error.message);
  } finally {
    // Step 5: Always hide the spinner — whether success or failure
    loadingSpinner.hidden = true;
    console.log('[UI] Spinner hidden');
  }
}

initPostList();
▶ Output
[UI] Showing loading spinner...
[Data] Received 3 posts from API
[UI] Posts rendered to DOM
[Preview] First post title: sunt aut facere repellat provident occaecati excepturi optio reprehenderit
[UI] Spinner hidden
⚠️
Pro Tip: Always Use a `finally` Block for UI StateIf you hide your loading spinner only in the `try` block, a failed request leaves the spinner spinning forever — one of the most unprofessional UX bugs you can ship. Use `finally` to reset UI state (spinners, disabled buttons, loading text) because `finally` runs regardless of success or failure.
Feature / AspectXMLHttpRequest (XHR)Fetch API
Syntax styleCallback-based, verbosePromise-based, chainable with async/await
Error handlingChecks readyState AND status manuallyChecks response.ok; only rejects on network failure
Response parsingxhr.responseText — manual JSON.parse()response.json() — returns a Promise
Aborting a requestxhr.abort()AbortController — cleaner, reusable
Request streamingNo native supportSupports readable streams natively
Progress eventsBuilt-in onprogress eventRequires ReadableStream — more complex
Browser supportIE5+ — universal legacy supportAll modern browsers; IE requires polyfill
Readable codeHard to read, especially nested callsReads almost like synchronous code
Use case todayMaintaining legacy code onlyAll new development
Third-party alternativejQuery $.ajax() wraps itAxios wraps Fetch with added features

🎯 Key Takeaways

  • fetch() resolves on ANY server response — including 404s. Always check response.ok and throw manually for non-2xx status codes, or your error handling is just decoration.
  • There are always two async steps: await fetch() gives you the Response envelope, then await response.json() opens it. Forget the second await and you're working with a Promise, not your data.
  • Separate your data-fetching functions from your DOM-rendering functions. One function fetches and returns data; another receives data and paints the DOM. This makes both independently testable and far easier to maintain.
  • Always reset UI state (spinners, disabled buttons) in a finally block — not in try or catch alone. A spinner that never hides on error is a silent UX bug that erodes user trust fast.

⚠ Common Mistakes to Avoid

  • Mistake 1: Trusting that fetch() rejects on HTTP errors like 404 or 500 — The fetch Promise only rejects on network failure. A 404 response resolves successfully, so your .catch() never fires. Symptom: your app silently does nothing when the API returns an error. Fix: Always check if (!response.ok) { throw new Error(...) } immediately after the await, before you parse the body.
  • Mistake 2: Forgetting that response.json() is also async — Developers write const data = response.json() without await and get a Promise object instead of their data. Symptom: data.name is undefined, console.log(data) prints Promise { }. Fix: Always await response.json() or chain .then(res => res.json()).then(data => ...) — both async steps must be handled.
  • Mistake 3: Missing the Content-Type header on POST requests — Sending JSON without 'Content-Type': 'application/json' means the server receives the body as plain text and either fails to parse it or silently ignores it. Symptom: the server returns a 400 Bad Request or creates a record with null/empty fields. Fix: Always pair JSON.stringify(data) in the body with 'Content-Type': 'application/json' in the headers — they are a matched pair and must be used together.

Interview Questions on This Topic

  • QWhy doesn't the Fetch API reject its Promise when the server returns a 404 or 500 status code — and how do you handle HTTP errors correctly?
  • QExplain the two-step process of using fetch() to get JSON data. Why does response.json() return a Promise instead of giving you the data directly?
  • QIf you fire two fetch requests at the same time and the second one resolves before the first, how do you prevent a race condition where stale data overwrites fresh data in the UI? Walk me through using AbortController or Promise.race() to solve this.

Frequently Asked Questions

What is the difference between AJAX and the Fetch API?

AJAX is the broad concept of making asynchronous HTTP requests from a browser without a page reload. XMLHttpRequest and the Fetch API are both implementations of that concept. Fetch is the modern, Promise-based implementation that replaced the older, callback-based XMLHttpRequest. Think of AJAX as the idea and Fetch as the current best-practice tool for doing it.

Can I use the Fetch API in Node.js?

Yes — as of Node.js 18, Fetch is available globally without any import. In older Node.js versions (14-16), you need to install the node-fetch package or use the --experimental-fetch flag. For server-side code in Node.js 18+, you can use fetch() exactly as you would in a browser.

How do I cancel a fetch request if the user navigates away?

Use the AbortController API. Create const controller = new AbortController(), pass { signal: controller.signal } as the second argument to fetch(), and call controller.abort() whenever you need to cancel — for example, in a React useEffect cleanup function. The fetch Promise will reject with an AbortError, which you can catch and handle silently.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

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