Offline-first requests in the browser: a guide to oajax.js v2
How oajax.js v2 keeps web and hybrid apps working offline: cached reads, an outbox that replays writes in order, idempotency keys, and moving from v1.
Asghar Ali · Chief Technology Officer, Kakushin.io LTD
oajax.js is a small library that lets a web or hybrid app keep working when the network does not. Reads come from a local cache when the server cannot be reached. Writes that fail are saved to an outbox on the device and sent later, in the order they were made, each with the same idempotency key every time it is tried. Version 2 is a rewrite of a jQuery plugin I first published in 2017, and it is now on npm.
npm install oajax.js
The code is on GitHub at chasgharali/oajax.js, MIT licensed.
Why I wrote it in the first place
In 2017 I was building PhoneGap and Cordova apps for people who used them on the move. The interface was never the hard part. The hard part was what happened to a request made on a train, in a basement, or on a site with one bar of signal. A plain $.ajax call fails, the user's input is gone, and the app looks broken.
Version 1 was my answer at the time: a patch over jQuery's $.ajax that cached successful responses in localStorage and, when a request failed, kept it in a queue and retried it every ten seconds. It did the job for those apps, and it stayed on my GitHub profile, untouched, for nine years.
What was wrong with v1
Reading it again in 2026, it had real bugs, and I would rather say so than leave it looking finished:
- Its error path called the success handler. An offline failure ran your
successcallback with error arguments. - It changed the queue while looping over it, so a successful replay could make it skip the next item.
- Replays used
async: false, which blocks the page while it waits for the network. - Nothing told the app a request had been queued. The user could not be told "saved offline, will sync".
- Writes had no ordering guarantee and no idempotency, so a retry after a lost response could save the same thing twice.
What v2 does
v2 keeps the idea and replaces everything else. It is built on fetch, has no dependencies, and ships ESM, CommonJS, TypeScript types and a script-tag build of about 7 KB gzipped.
Reads: network first, cache when offline
A GET or HEAD goes to the network. A good response is cached on the device. If the network fails, times out or answers with a server error, you get the last good copy instead of an exception, marked so the UI can say it is old:
import { createOajax } from 'oajax.js'
const api = createOajax()
const res = await api('/api/notes')
render(res.data)
if (res.fromCache) showBanner('You are offline. Showing saved data.')
The cache has a time limit and a size limit, and you can clear it, which matters on shared devices: clear it on logout.
Writes: an outbox, replayed in order
A POST, PUT, PATCH or DELETE that cannot reach the server is stored in an outbox (IndexedDB, with localStorage as the fallback) and the call resolves straight away:
const saved = await api({ url: '/api/notes', method: 'POST', body: { text: 'Buy milk' } })
if (saved.queued) showToast('Saved offline, will sync')
api.on('replayed', ({ item }) => markSynced(item.meta))
api.on('dead', ({ item }) => showError(`Could not save: ${item.lastError}`))
The outbox is replayed when the browser comes back online, when the page becomes visible, on a backoff timer, or when you call api.flush(). Three rules make it safe:
- Oldest first, and stop at the first failure. The third write is never sent before the second.
- New writes queue behind old ones, even if the network is back, so later changes never overtake earlier ones.
- Retries back off from 2 seconds up to 15 minutes. After 15 attempts an item moves to a dead-letter list so one bad request cannot block the rest forever. A clear rejection from the server (a 4xx other than 408, 425 or 429) moves it there at once.
Idempotency keys, and the one thing your server must do
Every write gets an Idempotency-Key header before its first attempt, and the same key is sent on every retry. This matters because "the request failed" on the client does not mean the server did not receive it. The connection can drop after the server saved the note but before the response arrived.
So oajax can promise at-least-once delivery. To get exactly-once effects, the server should remember the keys it has seen, per user, for as long as an item can sit in an outbox (days, not minutes), and return the stored response when a key comes back instead of doing the work again.
Moving an old v1 app over
v2 keeps a v1-style call so a jQuery app can move by swapping the script:
<script src="jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/oajax.js@2/dist/oajax.min.js"></script>
$oajax({
url: '/getData.php',
type: 'POST',
data: { id: 2 },
success(result) { /* fresh, or from the cache when offline */ },
error(xhr, textStatus) { /* a real failure; textStatus is 'queued' for a write saved offline */ },
queued(info) { /* optional: a write saved offline */ },
})
Two changes are deliberate. A failure now calls error, and a queued write is reported as queued rather than as a success. $.ajax is no longer patched unless you ask for it with oajax.installJQuery(jQuery, { patchAjax: true }). Storage moved from localStorage to IndexedDB, so anything still sitting in a v1 queue is not replayed.
What it is not
- It is not a service worker. Replay happens while a page using oajax is open. If the user closes the app with writes waiting, they go the next time it is opened and online. Sending while the app is closed needs a service worker and the Background Sync API.
- Queued writes do not update cached reads. Update the UI optimistically and refresh when the
replayedevent fires. - Headers are stored as you pass them. Do not leave an
Authorizationheader on a queued write; add fresh credentials at replay time with theprepareReplayoption.
How it was tested
There are 52 unit tests (vitest, jsdom, a fake fetch and fake-indexeddb) covering the cache fallback, ordered replay, backoff and the dead-letter list, stable idempotency keys, persistence across a simulated reload, and the jQuery adapter. The script-tag build was also run in headless Chromium and WebKit: a cached read was served offline, and two writes made offline were queued, survived a page reload and replayed in order with their keys once the network came back.
It has not been tested in Firefox, on real devices, or inside Cordova or Capacitor WebViews yet. If you use it there, I would like to hear how it behaves: open an issue on GitHub.
FAQ
What is oajax.js?
oajax.js is an open-source JavaScript library for offline-first requests in web and hybrid apps. It serves cached data for reads when the network is down and saves failed writes to an outbox that is replayed in order, with idempotency keys, when the connection returns.
Do I still need jQuery?
No. Version 2 is built on fetch and has no dependencies. A jQuery-style $oajax adapter is included so apps written for version 1 can move over without rewriting their calls.
Does it work in Cordova, Capacitor and PWAs?
It is written for them and uses only standard browser APIs (fetch, IndexedDB, localStorage). It has been smoke-tested in Chromium and WebKit; testing inside Cordova and Capacitor WebViews is still to be done.
Can a queued write be sent twice?
Yes, if a response is lost after the server has already saved the write. Every write carries the same Idempotency-Key on every attempt, so the server can recognise a repeat and return the first result instead of applying it again.
How is it different from a service worker with Background Sync?
A service worker can send queued requests while the app is closed, and Background Sync only works in Chromium-based browsers. oajax replays while a page using it is open, works in any modern browser and in hybrid WebViews, and needs no service worker.