An offline-capable web app should make it clear what still works when a connection drops. A service worker can keep the application shell available, while IndexedDB can store structured data and changes waiting to sync. Neither API makes a server-powered app automatically work offline: you need to decide what can be read, changed, and reconciled without the server.
This guide builds a small notes-app pattern for individual developers and small teams. The service worker caches the shell, the page stores notes and pending changes locally, and the app retries those changes when it can reach its server. The server remains authoritative for shared data.
Decide what offline means for your app
Separate the app's data into three groups before writing code:
| Data | Store | Offline behavior |
|---|---|---|
| HTML, CSS, JavaScript, and a fallback page | Cache API, managed by a service worker | Load the interface without a network request |
| User-owned records needed on this device | IndexedDB | Read and edit locally |
| Shared or server-owned records | Your server | Show a cached snapshot as stale, or make the feature unavailable |
An offline save is a local save, not proof that a server accepted the change. Show whether a record is pending, synced, or in conflict. Do not describe a queued change as published, sent, or backed up until your server confirms it.
Service workers require a secure context, normally HTTPS (localhost is treated as secure for development). Their scope is based on where the worker script is served. Put it at the intended app root, and check the service worker guide before deploying under a subdirectory.
Cache the app shell
Create offline.html as a small page that explains the connection state and loads the same local app code used online. Include an element such as <p id="sync-status" role="status"></p> so the interface can report pending changes and errors.
Then add sw.js at the app root:
const CACHE_NAME = "notes-app-shell-v1";
const APP_SHELL = ["/offline.html", "/app.js", "/styles.css"];
const CACHE_PREFIX = "notes-app-shell-";
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
.map((key) => caches.delete(key))
)
)
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== "GET" || url.origin !== self.location.origin) {
return;
}
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(async () => {
const cache = await caches.open(CACHE_NAME);
return (await cache.match("/offline.html")) ||
new Response("You are offline.", {
status: 503,
headers: { "Content-Type": "text/plain" },
});
})
);
return;
}
if (APP_SHELL.includes(url.pathname)) {
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
return (await cache.match(request)) || fetch(request);
})
);
}
});
cache.addAll() makes installation fail if a required shell file cannot be fetched, rather than activating a worker with only part of the app available. Keep the cache name specific to this app; the activation handler removes only older versions with that prefix. The Cache API is an explicit store, so version and replace cached assets as part of your release process.
This example uses a network-first strategy for page navigation and a cache-first strategy for the listed static files. It deliberately does not cache API responses. A cached API response can be out of date, incomplete, or specific to a different signed-in user. If you choose to cache API data, define its freshness, account separation, and logout cleanup rules first.
Register the worker from your app after its status element exists:
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch((error) => {
document.querySelector("#sync-status").textContent =
`Offline support could not start: ${error.message}`;
});
}
The app should still work online if a browser does not support service workers. Treat registration failure as a visible loss of offline capability, not as a reason to hide the rest of the app.
Store notes and pending changes in IndexedDB
IndexedDB is an asynchronous, transactional database suited to structured data. Keep a notes store for the latest local records and an outbox store for operations the server has not acknowledged. Create both stores when the database is first opened:
function openNotesDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("notes-app", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("notes")) {
db.createObjectStore("notes", { keyPath: "id" });
}
if (!db.objectStoreNames.contains("outbox")) {
db.createObjectStore("outbox", { keyPath: "mutationId" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function transactionDone(transaction) {
return new Promise((resolve, reject) => {
transaction.addEventListener("complete", resolve, { once: true });
transaction.addEventListener(
"abort",
() => reject(transaction.error || new Error("Database transaction aborted.")),
{ once: true }
);
transaction.addEventListener(
"error",
() => reject(transaction.error || new Error("Database transaction failed.")),
{ once: true }
);
});
}
async function saveNoteLocally(db, note) {
const transaction = db.transaction(["notes", "outbox"], "readwrite");
const done = transactionDone(transaction);
transaction.objectStore("notes").put(note);
transaction.objectStore("outbox").put({
mutationId: crypto.randomUUID(),
noteId: note.id,
operation: "save",
payload: note,
queuedAt: Date.now(),
});
await done;
}
Use the same transaction for the local note and its queued operation. If the browser aborts the transaction, the app must not report a successful offline save. Render notes from IndexedDB when the page starts, and determine whether a note is pending by checking for its ID in the outbox. This also handles multiple edits to the same note without marking it synced after only the first edit succeeds.
The mutationId is a unique idempotency key for one change. Your server endpoint should authenticate the user, validate the payload, remember processed mutation IDs, and return success only after accepting that operation. Reusing an idempotency key lets the client safely retry if the server accepted a request but the connection failed before the response arrived.
Send the outbox when the server is reachable
Attempt synchronization at app startup and when the browser reports that connectivity may have returned. The online event is only a hint: a reachable network does not guarantee that your API is available, so handle fetch failures and leave unacknowledged items in the outbox.
The following function sends operations in queue order and removes each one only after a successful server response. It expects the page to contain the #sync-status element shown earlier.
function requestResult(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function setSyncStatus(message) {
document.querySelector("#sync-status").textContent = message;
}
async function syncOutbox(db) {
const readTransaction = db.transaction("outbox", "readonly");
const readDone = transactionDone(readTransaction);
const pending = await requestResult(
readTransaction.objectStore("outbox").getAll()
);
await readDone;
pending.sort((a, b) => a.queuedAt - b.queuedAt);
for (const item of pending) {
const response = await fetch("/api/notes/sync", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": item.mutationId,
},
body: JSON.stringify(item),
});
if (response.status === 409) {
setSyncStatus("A note needs conflict review before it can sync.");
return;
}
if (!response.ok) {
throw new Error(`The server could not sync a note (${response.status}).`);
}
const deleteTransaction = db.transaction("outbox", "readwrite");
const deleteDone = transactionDone(deleteTransaction);
deleteTransaction.objectStore("outbox").delete(item.mutationId);
await deleteDone;
}
const checkTransaction = db.transaction("outbox", "readonly");
const checkDone = transactionDone(checkTransaction);
const remaining = await requestResult(
checkTransaction.objectStore("outbox").getAll()
);
await checkDone;
setSyncStatus(
remaining.length === 0
? "All queued changes are synced."
: `${remaining.length} change(s) are still waiting to sync.`
);
}
/api/notes/sync is the app's server endpoint, not a browser API. Define its request and response contract alongside your server code. In particular, decide how it handles two devices editing the same note. A conflict response should keep the local operation available for review; silently overwriting either version can lose work.
Connect syncing to app lifecycle events and surface failures:
function trySync(db) {
syncOutbox(db).catch((error) => {
setSyncStatus(`Sync paused: ${error.message}`);
});
}
window.addEventListener("online", () => trySync(db));
trySync(db);
Do not delete an outbox entry just because navigator.onLine is true or fetch() started. Only a successful response from your server is an acknowledgement. The browser's online status cannot establish that your particular API is reachable.
The Background Synchronization API can let a service worker retry work after connectivity returns in browsers that support it. It is not universally available, and the browser controls when background work runs. Use it as an enhancement; keep foreground retries and a clear pending state as the baseline.
Handle stale data, storage limits, and updates
- Tell readers when data is old. Store when a server snapshot was last confirmed. If you display that snapshot offline, label it with its sync time instead of implying it is current.
- Preserve pending work. Keep queued operations until the server acknowledges them. Let users inspect, retry, or resolve conflicts rather than dropping a queue after an error.
- Plan for storage failure. Browser storage is subject to quotas and may be cleared. Handle rejected IndexedDB writes, keep the server as the durable source, and consider requesting persistent storage with the StorageManager
persist()method. A request is not a promise that storage will be granted. - Keep data private. Same-origin script can access IndexedDB. Do not store secrets there as if it were a secure vault, and clear account-specific records on logout when appropriate.
- Release cache versions deliberately. Change the cache name when the shell changes, test an upgrade with an old tab still open, and avoid forcing a waiting worker to activate in the middle of a session. Service worker updates can otherwise leave a page and its cached files on different versions.
The Cache API and IndexedDB are separate stores with separate lifecycles. Deleting an old app-shell cache should not delete a pending data outbox, and resetting application data should explain that locally unsynced changes will be lost.
Test the offline path
Use browser developer tools to verify the actual behavior rather than assuming that a green install means the app works offline:
- Load the app online and wait for the service worker to install.
- Reload once so the page is controlled by the worker.
- Switch the browser to offline mode and reload. Confirm the fallback page and cached scripts load.
- Create and edit a note. Close and reopen the tab while offline to confirm the record is in IndexedDB.
- Restore connectivity. Confirm the app shows pending work until the server acknowledges it, then confirm the outbox entry is removed.
- Simulate an API error and a conflict response. Verify that the item remains available and the user sees a useful status.
- Install a new shell version and test with an older page open. Check that no pending notes disappear.
Also test with service workers unavailable or blocked. The online version should remain usable, while offline-dependent actions explain their limitation instead of failing silently.