The xkcd API has been quietly perfect for 15 years โ€” and your browser isn't allowed to call it

The xkcd JSON API is one of the nicest free APIs on the internet: keyless, no rate-limit hoops, HTTPS, fast (~0.5s in our September 2026 runs), and so stable that /info.0.json URLs from 2010 still work unchanged. Which is why it's the opening act of a thousand "build a comic viewer" tutorials. Then the tutorial-follower writes their first fetch() โ€” and hits this:

// in any web page:
fetch("https://xkcd.com/info.0.json").then(r => r.json())

// โ–ถ Access to fetch at 'https://xkcd.com/info.0.json' from origin
//   'https://your-app.example' has been blocked by CORS policy: No
//   'Access-Control-Allow-Origin' header is present on the requested resource.
// โ–ถ Uncaught (in promise) TypeError: Failed to fetch

Verified while writing this (September 2026): the comic JSON endpoints send no Access-Control-Allow-Origin header at all, and an OPTIONS preflight answers 405. The same curl works fine from a terminal โ€” the API is effectively server-side only. (Fun nuance: imgs.xkcd.com โ€” the images โ€” does send Access-Control-Allow-Origin: *. The pictures are CORS-open; the metadata about them isn't.) Two more walls tutorials hit right after: there's no search or list endpoint โ€” an archive or search feature means one request per comic, 3,300 of them as of September 2026 โ€” and comic #404 genuinely doesn't exist: https://xkcd.com/404/info.0.json returns an HTTP 404, a hole your random-comic button will eventually step in.

None of this is a complaint โ€” it's xkcd's site, serving its own front end, and it owes nobody CORS headers. But the standard workaround (routing a third-party CORS proxy like allorigins or corsproxy.io into your critical path) trades a CORS error for someone else's uptime and rate limits. For dev, CI, and browser apps, mock the shapes; for production, put the one-line proxy in your own backend.

1. Serve captured comics on the same URLs โ€” with CORS

Capture the comics you use, then reproduce the exact paths as custom routes. One curl creates a project, no signup:

curl -s https://xkcd.com/info.0.json      > latest.json
curl -s https://xkcd.com/614/info.0.json  > 614.json

curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects?name=xkcd-mock" | tee project.json
# โ†’ {"id":"YOUR_ID","adminKey":"YOUR_ADMIN_KEY",โ€ฆ}

jq '{path:"/info.0.json", body:.}' latest.json \
  | curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
    -H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' --data-binary @-
jq '{path:"/614/info.0.json", body:.}' 614.json \
  | curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
    -H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' --data-binary @-

Now fetch("https://mockbird.mockbird.workers.dev/m/YOUR_ID/info.0.json") works from a browser โ€” every /m/โ€ฆ endpoint answers with Access-Control-Allow-Origin: * and real preflight handling. The body is the real response replayed byte-for-byte: num, title, safe_title, img, alt, transcript, year/month/day as the strings they actually are. And because the img URLs still point at imgs.xkcd.com โ€” which is CORS-open and hotlink-tolerant โ€” your viewer renders the real comics while the metadata comes from your mock. Bonus for tests: your "latest" is now pinned, so assertions about it never rot.

2. Comic #404: the hole your random button falls into

The classic random-comic implementation โ€” Math.ceil(Math.random() * latest.num) โ€” will sooner or later pick 404 and get an HTTP 404 with an HTML body (res.json() throws). Make the hole a permanent fixture and assert your handler survives it:

curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/YOUR_ID/routes" \
  -H "x-admin-key: YOUR_ADMIN_KEY" -H 'content-type: application/json' \
  -d '{"path":"/404/info.0.json","status":404,"contentType":"text/html",
       "body":"<html>\r\n<head><title>404 Not Found</title></head>\r\n<body>\r\n<center><h1>404 Not Found</h1></center>\r\n<hr><center>nginx</center>\r\n</body>\r\n</html>\r\n"}'

That's the real endpoint's 404 shape (HTML from nginx, not JSON โ€” we captured it). One test renders comic 614; another requests 404 and asserts the retry-with-a-different-number path. Both instant, both deterministic. Slow-connection skeletons and other failure modes are one query param away: ?mock_delay=3000, ?mock_status=500, ?mock_ratelimit=5.

3. The search endpoint xkcd never had

If you're building archive, search, or favorites features, host comics as records instead of replayed URLs โ€” you get querying the real API can't do:

for n in 303 353 614 927 2347; do curl -s "https://xkcd.com/$n/info.0.json"; done \
  | jq -s '{comics: .}' > db.json
curl -s -X POST "https://mockbird.mockbird.workers.dev/api/projects/import?name=xkcd-archive" \
  -H 'content-type: application/json' --data-binary @db.json

Every field is kept verbatim, and the full query toolkit applies: ?num=614 exact-matches, ?q=antigravity full-text-searches titles, alt text and transcripts (finds #353, the import antigravity comic), ?num_gte=900 ranges, ?sortBy=num&order=desc, ?select=num,safe_title,img projects a light list payload, X-Total-Count paginates, writes persist (a favorites flag is one PATCH away), and /m/YOUR_ID/types.ts generates a TypeScript Comic interface from your records (?format=zod for Zod).

4. Honest comparison

xkcd JSON APIYour mock
The actual comicsโœ” all 3,300+, free, foreveronly what you capture (but img URLs stay real)
Callable from a browserโœ˜ no CORS headers, preflight 405โœ” Access-Control-Allow-Origin: *
Search / list / rangesโœ˜ one request per comicโœ” ?q=, filters, sort, pagination
Comic #404HTTP 404, HTML bodysame โ€” as a fixture you can test on purpose
Deterministic for testsโœ˜ "latest" changes 3ร—/weekโœ” pinned until you change it
Failure drills (404/500/timeout/429)โœ˜โœ” fixtures + mock_delay/mock_status/mock_ratelimit
Right forserver-side fetching, via your own backend/proxybrowser apps in dev, CI, demos, workshops

The split that's fair to everyone: in production, fetch xkcd from your own backend (or a one-line edge function) and cache it โ€” comics change three times a week, so cache hard. In dev and CI, point the browser at /m/YOUR_ID and stop debugging CORS errors that aren't yours to fix. Current status of the real API: /status/xkcd. Related: Rick and Morty API, Studio Ghibli API, Dog API, mock any third-party API.

โšก Skip the terminal: paste your captured JSON in the dashboard importer (CSV, OpenAPI, db.json, Postman, and HAR work too) โ€” or create a seeded sandbox in one click. No signup.