jQuery still runs an enormous share of the web β admin panels, WordPress themes, intranets, and every codebase whose AJAX layer was written a decade ago and works too well to rewrite. If you're maintaining one of those, you don't need a build step, a service worker, or an npm mocking library. You need a real https URL that returns predictable JSON, fails on command, and doesn't care what year your jQuery is from.
This guide pairs jQuery with a hosted mock API and fixes the three classic ways jQuery AJAX fails silently β each one measured in a real Chrome before publishing. Every snippet was run verbatim on jQuery 3.7.1 and again on 1.12.4 (results were identical, noted per section), because "legacy" is exactly where this pain lives.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for everything below. CORS is open (Access-Control-Allow-Origin: *), so it works from file:// pages, CodePen, localhost, or your staging host β no proxy, no JSONP. When you want your own schema it's one curl or one click β see Β§10.
$.getJSON with only a success callback swallows errors wholeThe single most common jQuery AJAX line in the wild:
$.getJSON(API + '/products', function (items) {
render(items); // never called on failure β and nothing else happens either
});
We pointed exactly that at ?mock_status=500 (the mock returns a real HTTP 500 on demand) and waited 2.5 seconds: the success callback never fired, and no error surfaced anywhere in page logic. No exception, no rejected promise you're handling, nothing β the user just sees whatever the page looked like before. A DevTools console line is the only trace, and your users don't read DevTools. The fix is one chained call:
$.getJSON(API + '/products')
.done(render)
.fail(function (jqXHR, textStatus) {
showError(jqXHR.status, jqXHR.responseJSON); // see Β§6 for what these contain
});
Because the mock fails on command, "did we actually handle errors?" becomes a URL you can open, not a code review question. Verified identical on 1.12.4.
$.post(url, {obj}) doesn't send JSONThis one bites everyone moving old code onto a JSON API:
$.post(API + '/products', { name: 'Desk lamp', price: 12 });
// β HTTP 400 {"error": "invalid JSON body"}
$.post with a plain object sends application/x-www-form-urlencoded (name=Desk+lamp&price=12) β a 2006 default that modern JSON APIs reject. We ran it live: the mock answered 400 {"error":"invalid JSON body"}. And if your failure handling is Β§2a-style, that 400 is also invisible β two silent failures stacked. The fix (works on every jQuery version; there is no shorthand):
$.ajax({
url: API + '/products',
method: 'POST', // use type: 'POST' on jQuery < 1.9
contentType: 'application/json',
data: JSON.stringify({ name: 'Desk lamp', price: 12.5, category: 'toys' })
}).done(function (rec) {
console.log('created id', rec.id); // β created id 31
});
Run live: the record came back with id: 31, a follow-up GET /products/31 returned it (writes really persist β this is a stateful mock, not a canned fixture), and we deleted it afterwards with method: 'DELETE'. Verified identical on 1.12.4.
async: false freezes the whole page β measuredLegacy codebases are full of async: false because it made "return the data from the function" easy. To show what it costs, we started a 100 ms setInterval counter, then fetched the same URL with a server-side ?mock_delay=3000 both ways:
var ticks = 0;
setInterval(function () { ticks++; }, 100);
$.ajax({ url: API + '/products?limit=1&mock_delay=3000', async: false,
complete: function () { console.log(ticks); } });
| Wall time | 100 ms interval ticks during the request | |
|---|---|---|
async: false | 3.99 s | 0 β timers, repaints, clicks: all frozen |
async: true (default) | 3.53 s | 35 β page fully responsive |
Same numbers on 1.12.4 (0 vs 36 ticks). Chrome has deprecated synchronous XHR on the main thread for years; ?mock_delay turns "it's probably fine, the API is fast" into a reproducible 4-second freeze you can show whoever owns that code. The refactor is Β§2a's .done() shape.
The standard loading / error / empty / data pattern, jQuery-style. This exact snippet ran in Chrome and all four branches were asserted programmatically:
<div id="status"></div>
<ul id="products"></ul>
<script>
var API = 'https://mockbird.mockbird.workers.dev/m/demo/products';
function loadProducts(params) {
$('#status').text('Loadingβ¦');
$('#products').empty();
$.getJSON(API + '?' + (params || 'limit=5'))
.done(function (items) {
if (!items.length) {
$('#status').text('No products match.');
return;
}
$('#status').text('');
$.each(items, function (_, p) {
$('<li>').text(p.name + ' β $' + p.price).appendTo('#products');
});
})
.fail(function (jqXHR, textStatus) {
var msg = (jqXHR.responseJSON && jqXHR.responseJSON.error) || textStatus;
$('#status').text('Could not load products: ' + msg + ' (HTTP ' + jqXHR.status + ')');
});
}
loadProducts();
</script>
No code changes to test any state β pass different params:
| Call | What we observed |
|---|---|
loadProducts('limit=5') | 5 <li> rows rendered (names are random per daily reseed) |
loadProducts('limit=5&mock_delay=3000') | "Loadingβ¦" visible for 3 s β sampled mid-flight |
loadProducts('mock_status=500') | "Could not load products: simulated 500 error (mock_status) (HTTP 500)" |
loadProducts('q=zzzznotaword') | "No products match." β a real empty 200 [], not an error |
X-Total-CountThe success callback's third argument is the jqXHR β most tutorials ignore it, but it's how you read headers:
$.getJSON(API + '/products?page=1&limit=12')
.done(function (items, textStatus, jqXHR) {
var total = Number(jqXHR.getResponseHeader('X-Total-Count'));
var pages = Math.ceil(total / 12);
console.log(items.length, 'of', total, 'β', pages, 'pages');
// β 12 of 30 β 3 pages
});
The header is CORS-exposed, so this works cross-origin (many APIs forget Access-Control-Expose-Headers and the read returns null). ?sortBy=price&order=desc, field filters, _gte/_lte ranges and q= free-text search all compose with paging β the server does the work, your page just changes the query string. Verified identical on 1.12.4.
What .fail(jqXHR, textStatus, errorThrown) really contains β from a live ?mock_status=503:
| Argument / property | Observed value | Notes |
|---|---|---|
jqXHR.status | 503 | the HTTP status β branch on this |
textStatus | "error" | category only: "error", "timeout", "abort", "parsererror" |
errorThrown | "" β empty string | docs say "textual portion of the HTTP status", but HTTP/2 has no reason phrases, so on any modern connection this is empty. Don't build messages from it. |
jqXHR.responseJSON | {"error": "simulated 503 error (mock_status)"} | the parsed body β this is where real APIs put the useful message |
So the robust pattern is: status from jqXHR.status, message from jqXHR.responseJSON with a fallback β exactly what Β§3's .fail does. There's also the statusCode map if you prefer per-code handlers; we verified only the matching key fires:
$.ajax({
url: API + '/products?mock_status=404',
statusCode: {
404: function () { console.log('not found'); }, // β fired
500: function () { console.log('server blew up'); } // β did not
}
});
Drill the whole matrix by URL: ?mock_status=401, 429, 500β¦ each returns a proper JSON error body. Verified identical on 1.12.4.
A hung API shouldn't hang your page. timeout is a plain $.ajax option β we drilled it against a server that really takes 4 seconds:
$.ajax({ url: API + '/products?mock_delay=4000', timeout: 1200 })
.fail(function (jqXHR, textStatus) {
console.log(textStatus, jqXHR.status); // β "timeout" 0 (after 1.20 s)
});
Note jqXHR.status is 0 on a timeout β another reason to branch on textStatus there. Now retries. The usual problem with testing retry logic is you can't make a real API fail twice then recover. ?mock_seq scripts exactly that β the endpoint returns 503, 503, then 200, in order:
function getWithRetry(url, retriesLeft) {
return $.ajax({ url: url }).then(null, function (jqXHR) {
if (retriesLeft > 0 && jqXHR.status >= 500) {
return getWithRetry(url, retriesLeft - 1);
}
return $.Deferred().reject(jqXHR);
});
}
var url = API + '/products?limit=1&mock_seq=503,503,200&mock_seq_key=' + Date.now();
getWithRetry(url, 3).done(function (items) { console.log('recovered'); });
Run live: exactly 3 attempts, final status 200 β the retry loop provably works, and provably stops working if you break it. mock_seq_key namespaces the sequence so parallel tests (or two people reading this guide) don't consume each other's steps β a fresh key (here Date.now()) starts a fresh sequence. Verified identical on 1.12.4.
The classic jQuery race: the user types "sta", that request is slow; they finish typing "stable", that request is fast. The fast response renders first β then the slow, stale response lands and overwrites it. We reproduced it deliberately with two overlapping requests (?mock_delay=2500 on the first, 200 ms on the second): without protection, the stale "sta" results won. The 15-year-old fix still holds β keep the pending jqXHR and abort it:
var pending = null;
function search(term) {
if (pending) pending.abort(); // kill the stale request
pending = $.getJSON(API + '/products?q=' + encodeURIComponent(term))
.done(renderResults)
.always(function () { pending = null; });
}
$('#q').on('input', function () { search(this.value); });
Same race with the abort pattern: the fresh "stable" results rendered and stayed. One caveat we hit: an aborted request also lands in .fail with textStatus === "abort" (and jqXHR.status === 0) β don't show an error banner for it. The ?mock_delay param is what makes this testable at all: you can't reproduce a race against an API that always answers in 40 ms.
Testing gated UI without a backend β the demo accepts any email/password and returns a real signed JWT:
$.ajax({
url: API_ROOT + '/auth/login', method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ email: 'demo@example.com', password: 'anything' })
}).done(function (auth) {
// auth.token β "eyJhbGciOiJIUzI1NiIsβ¦"
$.ajax({ url: API_ROOT + '/auth/me',
headers: { Authorization: 'Bearer ' + auth.token } })
.done(function (me) { console.log(me.user.email); }); // β demo@example.com
});
(API_ROOT is https://mockbird.mockbird.workers.dev/m/demo.) Tokens have configurable expiry β set it to 5 seconds to test your 401-redirect handling β and protected mode makes every endpoint demand the header, so Β§5's 401 branch gets real exercise.
If your legacy code uses dataType: 'jsonp', it exists for one reason: the API had no CORS headers. This mock does β every endpoint answers with Access-Control-Allow-Origin: * β so plain $.getJSON works from any origin and you can retire the callback-injection machinery (and its script-injection attack surface) from the parts of your app you're modernizing.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "Content-Type: application/json" \
-d '{"preset": "ecommerce"}'
β¦or skip the terminal: one click creates it in the dashboard (presets: blog, ecommerce, saas, or define fields yourself). Also useful with jQuery codebases:
| Hardcoded array | jquery-mockjax | Local json-server | Mockbird | |
|---|---|---|---|---|
| Setup on a legacy page | edit the JS | one extra <script> | Node on every machine | a URL |
| Exercises real HTTP / CORS / headers / timing | β | β β intercepts $.ajax in-process | partly (localhost only) | β |
| Catches Β§2b's wrong content-type | β | β (no real server to reject it) | β | β β real 400 |
| Also covers non-jQuery calls (fetch, other libs) | β | β β jQuery only | β | β |
| Inject latency / errors / sequences | β | β in code | middleware to write | one query param |
| Works when the page leaves your machine | β (still fake) | β (still fake) | β | β |
| Offline | β | β | β | β β real network call |
Be clear-eyed: jquery-mockjax is the venerable in-page tool and fine for unit-testing jQuery-only code, but it can't catch wire-level bugs (content types, CORS, header exposure, real timing) because nothing crosses the wire. A hosted mock is the opposite trade β everything is real except the backend's existence.
Related: mock API for htmx (often found in the same server-rendered codebases), mock API for Alpine.js (the modern "sprinkle behaviour on HTML" successor), testing loading and error states, and free mock-API tools compared.
Verification: every snippet on this page was run verbatim in a real Chrome (plain HTML files, jQuery 3.7.1 and 1.12.4 β no bundler) against production on 22 Sep 2026, with results asserted programmatically: the swallowed 500 (success callback never fired), the form-encoded 400, the 0-vs-35-ticks freeze measurement (0 vs 36 on 1.12.4), all four Β§3 branches including "Loadingβ¦" sampled mid-flight, the persisted POST (id 31, fetched back, then deleted), 12-of-30 β 3 pages pagination, the empty-string errorThrown over HTTP/2, the 404-only statusCode firing, the 1.20 s timeout, the 3-attempt mock_seq recovery, the stale-search race lost then won with abort(), and the JWT login β /auth/me round trip. If a snippet doesn't work on your page, that's a bug: tell us.