← All guides

Mock a REST API for jQuery β€” $.ajax without the silent failures

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.

1. Try it in 10 seconds (no signup)

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.

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, data browser already open, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.

2. The three ways jQuery AJAX fails silently (measured)

2a. $.getJSON with only a success callback swallows errors whole

The 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.

2b. $.post(url, {obj}) doesn't send JSON

This 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.

2c. async: false freezes the whole page β€” measured

Legacy 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 time100 ms interval ticks during the request
async: false3.99 s0 β€” timers, repaints, clicks: all frozen
async: true (default)3.53 s35 β€” 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.

3. One product list, four UI states β€” flipped by URL alone

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:

CallWhat 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

4. Real pagination with X-Total-Count

The 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.

5. Error handling that actually tells you something

What .fail(jqXHR, textStatus, errorThrown) really contains β€” from a live ?mock_status=503:

Argument / propertyObserved valueNotes
jqXHR.status503the HTTP status β€” branch on this
textStatus"error"category only: "error", "timeout", "abort", "parsererror"
errorThrown"" β€” empty stringdocs 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.

6. Timeouts and deterministic retries

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.

7. Search-as-you-type without stale results

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.

8. Mock login: a real JWT flow

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.

9. You can delete your JSONP code

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.

10. Create your own backend (one curl)

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:

11. Honest comparison

Hardcoded arrayjquery-mockjaxLocal json-serverMockbird
Setup on a legacy pageedit the JSone extra <script>Node on every machinea URL
Exercises real HTTP / CORS / headers / timingβœ—βœ— β€” intercepts $.ajax in-processpartly (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 codemiddleware to writeone 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.