Himanshu SharmaSoftware engineer
Let’s talk

jQuery · Lesson 37 of 45

Ajax errors, loading states and timeouts

Handle every jQuery Ajax failure (HTTP errors, timeouts, lost connections, bad JSON), show loading states, retry safely and cancel stale requests.

13 minAjaxUpdated 23 October 2026

By the end of this lessonMake Ajax features that tell the user what is happening, recover from failures gracefully, and never leave the page stuck.

Requests fail

Real networks are unreliable: servers return errors, phones lose signal, requests time out. An Ajax feature is only finished when it handles failure. Users should always know whether something is loading, has worked, or has failed and what they can do about it.

What went wrong?

.fail(jqXHR, textStatus, errorThrown) tells you why a request failed:

textStatus Meaning jqXHR.status
"error" the server answered with an error (4xx, 5xx), or the network failed the HTTP code, or 0 with no response
"timeout" no answer within the timeout you set 0
"abort" your code cancelled the request 0
"parsererror" the response arrived but was not valid for the dataType, for example broken JSON usually 200
html
<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  const report = label => (xhr, textStatus) => console.log(label.padEnd(10), "| textStatus:", textStatus, "| status:", xhr.status);

  $.ajax({ url: "https://jsonplaceholder.typicode.com/posts/99999", dataType: "json" })
    .fail(report("not found"))
    .always(function () {
      return $.ajax({ url: "https://jsonplaceholder.typicode.com/posts", timeout: 1 }).fail(report("timeout"))
        .always(function () {
          const request = $.ajax({ url: "https://jsonplaceholder.typicode.com/posts" });
          request.fail(report("aborted"));
          request.abort();
        });
    });
</script>
result
not found  | textStatus: error | status: 404
timeout    | textStatus: timeout | status: 0
aborted    | textStatus: abort | status: 0

The three requests run one after another so their messages appear in order. A 1 millisecond timeout is far too short for any real request, which makes it a reliable way to see a timeout.

Friendly error messages

Turn those details into messages people understand. Never show raw technical errors to users, but do log them for yourself:

javascript
function explain(xhr, textStatus) {
  if (textStatus === "timeout") return "The server is taking too long. Please try again.";
  if (textStatus === "abort") return "";
  if (xhr.status === 0) return "You seem to be offline. Check your connection and try again.";
  if (xhr.status === 404) return "We could not find that item.";
  if (xhr.status === 422) return "Please check the highlighted fields.";
  if (xhr.status >= 500) return "Something went wrong on our side. Please try again in a minute.";
  return "Sorry, that did not work.";
}

A 422 response from frameworks like Laravel usually includes the validation errors as JSON in xhr.responseJSON, which you can show next to each field.

Loading states

Show that something is happening as soon as the request starts, and undo it in .always(), so it disappears however the request ends:

html
<button id="load">Load profile</button>
<div id="profile" aria-live="polite"></div>

<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  $("#load").on("click", function () {
    const $btn = $(this).prop("disabled", true).text("Loading...");
    $("#profile").text("").attr("aria-busy", "true");

    $.ajax({ url: "https://jsonplaceholder.typicode.com/users/3", dataType: "json", timeout: 8000 })
      .done(user => $("#profile").text(user.name + ", " + user.email))
      .fail(() => $("#profile").text("Could not load the profile. Please try again."))
      .always(function () {
        $btn.prop("disabled", false).text("Load profile");
        $("#profile").removeAttr("aria-busy");
        console.log("button enabled again:", !$btn.prop("disabled"), "| shown:", $("#profile").text());
      });
  });
</script>
result
button enabled again: true | shown: Clementine Bauch, Nathan@yesenia.net

aria-live="polite" makes screen readers announce the new content when it appears, and aria-busy tells them the area is updating. Disabling the button prevents duplicate requests.

Always set a timeout

By default, jQuery waits as long as the browser does, which can be minutes. A user staring at "Loading..." for a minute will leave. Set timeout (in milliseconds) on requests where a user is waiting, typically 8 to 15 seconds, and show a "try again" option when it fires.

Retrying

Some failures are temporary. Offer a Retry button rather than retrying silently for ever, and never automatically retry a request that changes data (a payment, an order) unless the server is designed to handle duplicates safely:

html
<button id="get">Load to-do</button>
<p id="out"></p>

<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  let attempt = 0;
  $("#get").on("click", function () {
    attempt++;
    // The first attempt uses an impossible timeout to simulate a slow network
    $.ajax({ url: "https://jsonplaceholder.typicode.com/todos/1", dataType: "json", timeout: attempt === 1 ? 1 : 8000 })
      .done(task => { $("#out").text(task.title); $("#get").text("Load to-do"); })
      .fail(() => { $("#out").text("Timed out."); $("#get").text("Retry"); })
      .always(() => console.log("attempt", attempt + ":", $("#out").text()));
  });
</script>
result
attempt 1: Timed out.
attempt 2: delectus aut autem

Cancelling stale requests

When a user types a search, each pause may start a request. Responses can come back out of order, so an older, slower response could replace newer results. Keep the last jqXHR and abort it before starting a new one:

html
<input id="q" placeholder="Search to-dos">
<ul id="hits"></ul>

<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  let current = null;
  let aborted = 0;

  $("#q").on("input", function () {
    if (current) current.abort();
    const term = $(this).val().trim();
    current = $.getJSON("https://jsonplaceholder.typicode.com/todos", { title_like: term })
      .done(function (todos) {
        $("#hits").empty().append(todos.slice(0, 3).map(task => $("<li>", { text: task.title })));
        console.log("results for", JSON.stringify(term) + ":", todos.length, "| earlier requests aborted:", aborted);
      })
      .fail((xhr, status) => { if (status === "abort") aborted++; });
  });
</script>
result
results for "delectus": 7 | earlier requests aborted: 7

Typing eight letters started eight requests, and every one except the last was aborted, so only the results for the full word were shown. Combine this with the debouncing technique from the keyboard lesson to send fewer requests in the first place. (title_like is a JSONPlaceholder search parameter.)

Common mistakes

  • No .fail() handler, so a failure leaves "Loading..." on screen.
  • Clean-up only in .done(). Use .always().
  • No timeout on requests users are waiting for.
  • Showing raw error text such as parsererror to users.
  • Letting stale responses overwrite newer ones.

Exercise

Write a function loadUser(id) that shows "Loading..." in a paragraph, loads /users/<id> from JSONPlaceholder with a 5 second timeout, shows the name on success, shows "No such user" for a 404, and logs done in .always(). Call it with 1 and then with 999.

Show one possible answer
html
<p id="who"></p>

<script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
<script>
  function loadUser(id) {
    $("#who").text("Loading...");
    return $.ajax({ url: "https://jsonplaceholder.typicode.com/users/" + id, dataType: "json", timeout: 5000 })
      .done(user => $("#who").text(user.name))
      .fail(xhr => $("#who").text(xhr.status === 404 ? "No such user" : "Could not load"))
      .always(() => console.log("done:", $("#who").text()));
  }
  loadUser(1).always(() => loadUser(999));
</script>
result
done: Leanne Graham
done: No such user

Key points

  • In .fail(), textStatus tells you what went wrong: "error", "timeout", "abort" or "parsererror"; jqXHR.status gives the HTTP code (0 if no response).
  • Show a loading state before the request and remove it in .always(), so it disappears on success and failure.
  • Set a timeout so a slow server cannot leave the user waiting for ever.
  • Abort older requests that are no longer wanted, such as a previous search, so their results do not overwrite newer ones.

Check yourself

1. In .fail(function (xhr, textStatus) { ... }), what is textStatus after a timeout?
2. What does xhr.status equal when the network is down or the server cannot be reached?
3. Where should you remove a loading spinner?
4. Why abort the previous search request when a new one starts?