From the archive

AJAX in Modern JavaScript: Fetching Data Without Reloading the Page

A modern introduction to AJAX with fetch, async and await, response checks, cancellation, CORS boundaries, and safe DOM updates.

AJAX is a name for requesting data from browser JavaScript and updating part of a page without navigating to a new document. It is not a programming language, a framework, or a requirement to use XML.

Modern code usually performs this work with fetch(), promises, async functions, and data formats such as JSON. The older XMLHttpRequest API still exists, but it is no longer the clearest starting point for most new browser code.

What Happens During an AJAX Request

A browser-side request has several separate stages:

  1. JavaScript creates a request for a URL.
  2. The browser applies URL, same-origin, credentials, and CORS rules.
  3. A server receives the request and decides how to authenticate, authorize, validate, and process it.
  4. The browser receives an HTTP response or reports a network-level failure.
  5. JavaScript checks the response, parses the expected data, and updates the page.

The Fetch Standard defines the request and response model behind the Fetch API. fetch() returns a promise, so await can make the asynchronous flow easier to read, but the application still has to handle every stage deliberately.

A Modern Fetch Example

This example separates the request from the page update. Passing fetchImpl as an argument keeps the normal browser default while allowing the request to be tested with a deterministic mock.

const loadButton = document.querySelector('#load-message');
const cancelButton = document.querySelector('#cancel-message');
const status = document.querySelector('#message-status');
const output = document.querySelector('#message-output');

let activeController;

async function fetchMessage(fetchImpl = fetch, signal) {
  const response = await fetchImpl('/api/message', {
    headers: { Accept: 'application/json' },
    signal
  });

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

  const data = await response.json();

  if (typeof data.message !== 'string') {
    throw new TypeError('The response did not contain a message.');
  }

  return data.message;
}

async function loadMessage() {
  const controller = new AbortController();
  activeController = controller;
  loadButton.disabled = true;
  status.textContent = 'Loading message…';
  output.textContent = '';

  try {
    const message = await fetchMessage(fetch, controller.signal);
    output.textContent = message;
    status.textContent = 'Message loaded.';
  } catch (error) {
    status.textContent =
      error?.name === 'AbortError'
        ? 'Request canceled.'
        : 'The message could not be loaded.';
  } finally {
    if (activeController === controller) {
      activeController = undefined;
    }

    loadButton.disabled = false;
  }
}

loadButton.addEventListener('click', () => {
  void loadMessage();
});

cancelButton.addEventListener('click', () => {
  activeController?.abort();
});

The associated controls need accessible names and a live status message:

<button id="load-message" type="button">Load message</button>
<button id="cancel-message" type="button">Cancel request</button>
<p id="message-status" role="status" aria-live="polite"></p>
<p id="message-output"></p>

/api/message is an illustrative same-origin endpoint, not a route provided by this static website. A real application must supply an endpoint with the documented response format.

HTTP Failures and Network Failures Are Different

A fulfilled fetch() promise does not guarantee that the server returned a successful HTTP status. Responses such as 404 Not Found and 500 Internal Server Error still produce a Response object. The example checks response.ok, which is true for statuses from 200 through 299.

A network interruption, blocked request, invalid URL, or cancellation can instead reject the promise. JSON parsing can also fail after a successful HTTP response if the response body is empty or malformed.

That is why the example keeps the status check, JSON parsing, response-shape validation, and catch path separate. Production diagnostics may record more detail privately, but a public error message should not expose stack traces, tokens, internal URLs, or server configuration.

Cancellation and Loading States

AbortController gives browser code a standard way to request cancellation. The controller’s signal is passed to fetch(), and calling abort() causes the pending operation to reject with an abort reason. The current behavior is defined by the DOM Standard.

Cancellation is useful when a visitor leaves a view, replaces one search with another, or explicitly chooses to stop waiting. It does not guarantee that a server has undone work it already received. Server-side operations need their own transactional and cancellation design.

Loading state also matters. Disable or otherwise manage controls that should not start duplicate work, announce progress without stealing focus, provide a useful failure state, and restore the controls in finally so success and failure follow the same cleanup path.

Update the DOM Safely

The example assigns remote text through textContent. That treats the value as text instead of parsing it as HTML.

Do not place an untrusted response into innerHTML. If an application genuinely needs server-provided HTML, it needs an explicit sanitization and content-security design rather than an assumption that the response is safe.

Parsing JSON does not make every value trustworthy. Check the response shape and validate values before using them in URLs, attributes, selectors, commands, database queries, or other sensitive contexts.

Sending JSON

A POST request can use the same Fetch API. This example sends a non-personal preference value:

async function saveTheme(theme, fetchImpl = fetch, signal) {
  const response = await fetchImpl('/api/preferences', {
    method: 'POST',
    headers: {
      Accept: 'application/json',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ theme }),
    signal
  });

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

  return response.json();
}

The browser serializes the object, but it does not decide whether the request is allowed. The server must authenticate the caller when necessary, verify authorization, validate the body, enforce size and content-type limits, protect state-changing cookie-authenticated requests from cross-site request forgery, and return an intentional response.

Same-Origin Policy and CORS

Browsers normally restrict script access to responses from another origin. Cross-Origin Resource Sharing is the protocol by which a server can permit selected cross-origin requests and response access. It is a server policy enforced by the browser, not a client-side permission switch.

Do not use mode: 'no-cors' as a workaround. It does not grant access to a blocked response; it generally produces an opaque response whose body and useful status details are unavailable to the calling script.

Cross-origin credentials require additional care. The Fetch Standard’s CORS and credentials rules require an intentional server response, and a credentialed response cannot simply combine a wildcard origin with credential sharing. Add credentials: 'include' only when the application’s authentication and cross-origin design explicitly require it.

What Fetch Does Not Secure for You

fetch() moves bytes. It does not replace application security.

  • Authentication establishes who is making a request.
  • Authorization determines what that identity may do.
  • CSRF protection defends state-changing requests that a browser might send with ambient credentials such as cookies.
  • Server validation treats every incoming value as untrusted, even when browser JavaScript already checked it.
  • Output handling prevents returned data from becoming executable markup or code.
  • Rate limits and abuse controls protect endpoints from automated or excessive use.

Private API keys and service credentials must not be embedded in browser JavaScript. Visitors can inspect downloaded source, network requests, runtime values, and source maps. A secret needed to call another service belongs on a trusted server, which can expose only the limited operation the browser is authorized to request.

Where XMLHttpRequest Fits

XMLHttpRequest is still part of the web platform and remains present in older applications. Some specialized code also uses its event model. The current XMLHttpRequest Standard defines that behavior.

For a new introductory request, fetch() usually expresses the request, promise, response, and cancellation flow more directly. Understanding an existing XMLHttpRequest implementation is useful, but new code should not copy an older pattern without adding current error handling, security boundaries, and accessibility states.

Use AJAX Deliberately

Asynchronous requests can avoid a full document navigation, but they do not automatically make an application faster or easier to use. Each request adds network work, failure states, security decisions, and interface state that the application must handle.

Use AJAX when updating part of the current page improves the task. Keep direct URLs and normal navigation where they remain clearer. Check HTTP status, validate the data, update the DOM safely, make work cancelable where appropriate, and treat the server as the authority for every protected operation.