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 |
<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>not found | textStatus: error | status: 404
timeout | textStatus: timeout | status: 0
aborted | textStatus: abort | status: 0The 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:
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:
<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>button enabled again: true | shown: Clementine Bauch, Nathan@yesenia.netaria-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:
<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>attempt 1: Timed out.
attempt 2: delectus aut autemCancelling 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:
<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>results for "delectus": 7 | earlier requests aborted: 7Typing 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
parsererrorto 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
<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>done: Leanne Graham
done: No such userKey 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.