A web application can work perfectly on a stable internet connection and still fail users when the network drops. A form submission may disappear, an edited record may never reach the server, or a user may have to enter the same information again after reconnecting.
These problems are common in field-service applications, inventory tools, travel apps, and business dashboards used in places with unreliable connectivity.
An offline-first application handles this situation differently. It stores important data locally, allows users to continue working, and synchronizes pending changes when the server becomes reachable again.
Building this behavior requires more than adding a service worker. The application needs a local data store, a queue for pending changes, a synchronization process, and a strategy for handling conflicts.
This article explains how to build these pieces using browser APIs and JavaScript.
What Does Offline-First Mean?
An offline-first application treats local storage as an important part of the user experience instead of assuming that every action requires an immediate server response.
Consider an inventory application used by a warehouse employee. The employee updates the quantity of a product while working in an area with poor connectivity.
A traditional application might display an error and ask the employee to try again. An offline-first application can save the update locally, show that it is waiting to sync, and send it to the server when connectivity returns.
The application should distinguish between three states:
| State | Meaning | User experience |
|---|
| Saved locally | The change is stored on the device | The user can continue working |
| Waiting to sync | The change has not been confirmed by the server | Show a pending status |
| Synced | The server has accepted the change | Show a successful sync status |
This distinction matters because saving data in the browser does not mean the server has received it.
Offline-first design also differs from ordinary caching. Caching makes previously downloaded resources available. Offline-first behavior must also preserve new user actions and handle changes that have not yet reached the server.
Choose the Right Browser Storage
The first decision is where to store data while the application is offline.
Browsers provide several storage options, but they serve different purposes.
| Storage option | Suitable use | Limitations |
|---|
| Local Storage | Small settings and simple preferences | Synchronous API, string-only values, limited capacity |
| IndexedDB | Structured records, drafts, and pending changes | Asynchronous API requires more implementation work |
| Cache Storage | Cached responses and application assets | Not a general-purpose database for application records |
| Service worker | Intercepting requests and managing offline behavior | Does not replace a database or synchronization system |
For an application that needs to save records and queue changes, IndexedDB is generally a better choice than Local Storage. It supports structured data and asynchronous operations, which are more suitable for larger offline workflows.
Cache Storage is useful for files such as HTML, CSS, JavaScript, and selected API responses. It should not be treated as the primary database for editable business records.
Cache the Application Shell With a Service Worker
A service worker can intercept network requests and return cached resources when the network is unavailable.
Before using a service worker, the application must be served over HTTPS in production. Localhost is generally treated as a secure context for development.
Create a file named service-worker.js:
const CACHE_NAME = "inventory-app-v1";
const APP_SHELL = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/offline.html"
];
self.addEventListener("install", event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(APP_SHELL);
})
);
});
self.addEventListener("activate", event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(
keys
.filter(key => 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 (url.pathname.startsWith("/api/")) {
return;
}
event.respondWith(
fetch(request).catch(async () => {
const cached = await caches.match(request);
if (cached) {
return cached;
}
if (request.mode === "navigate") {
const offlinePage = await caches.match("/offline.html");
if (offlinePage) {
return offlinePage;
}
}
return new Response("Resource unavailable offline.", {
status: 503,
headers: {
"Content-Type": "text/plain; charset=utf-8"
}
});
})
);
});
Register the service worker from the main application:
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
await navigator.serviceWorker.register("/service-worker.js");
console.log("Service worker registered.");
} catch (error) {
console.error("Service worker registration failed:", error);
}
});
}
This example caches the application shell and attempts a network request before falling back to cached resources. API requests are deliberately excluded because application data needs a separate caching and synchronization strategy.
The cache version also matters. When the application changes, update the cache name and ensure that the new asset list matches the deployed application. Deleting old caches without considering active application tabs can cause problems if an older page still expects files that have been removed.
A service worker does not automatically make every screen or action available offline. The application must explicitly decide which resources and operations can work without a server.
Save User Changes in IndexedDB
Caching the interface is only part of the solution. The application must also preserve changes made by users.
For example, an inventory application might store pending quantity updates in an IndexedDB object store named pendingChanges.
The following helper opens the database and creates the object store when necessary:
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("inventory-db", 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("pendingChanges")) {
db.createObjectStore("pendingChanges", {
keyPath: "id"
});
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
Next, add a helper for storing a change:
async function savePendingChange(change) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(
"pendingChanges",
"readwrite"
);
transaction.objectStore("pendingChanges").put(change);
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.onerror = () => {
const error = transaction.error;
db.close();
reject(error);
};
transaction.onabort = () => {
const error = transaction.error;
db.close();
reject(error);
};
});
}
A change can now be stored locally before the application attempts to send it to the server:
async function updateInventory(productId, quantity) {
const change = {
id: crypto.randomUUID(),
type: "update-inventory",
productId,
quantity,
createdAt: new Date().toISOString(),
status: "pending"
};
await savePendingChange(change);
showMessage("Saved on this device. Waiting to sync.");
if (navigator.onLine) {
await syncPendingChanges();
}
}
The local save happens first. If the browser cannot reach the server, the pending record remains available for a later synchronization attempt.
The crypto.randomUUID() method generates a unique identifier in supported secure contexts. Applications that support older browsers may need a compatible ID-generation strategy.
The example stores a quantity update, but production applications should validate the input, verify that the user is authorized to perform the operation, and decide whether repeated edits should replace earlier pending changes.
Synchronize Pending Changes With the Server
When connectivity returns, the application can send queued changes to the server.
However, navigator.onLine is only a connectivity hint. A device may report that it is online while a VPN, DNS problem, captive portal, or unavailable API prevents the actual request from succeeding.
The synchronization process must handle request failures directly.
The next example assumes the server accepts a change through a POST request and returns a successful HTTP status when it has processed the change.
async function getPendingChanges() {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(
"pendingChanges",
"readonly"
);
const request = transaction
.objectStore("pendingChanges")
.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
transaction.oncomplete = () => db.close();
transaction.onabort = () => {
const error = transaction.error;
db.close();
reject(error);
};
});
}
After retrieving the pending changes, send them one at a time:
async function syncPendingChanges() {
const changes = await getPendingChanges();
for (const change of changes) {
try {
const response = await fetch("/api/inventory/changes", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(change)
});
if (!response.ok) {
throw new Error(`Sync failed: HTTP ${response.status}`);
}
await removePendingChange(change.id);
showMessage(`Change ${change.id} synced.`);
} catch (error) {
console.error("Unable to sync pending change:", error);
break;
}
}
}
The removePendingChange() function should delete the record from IndexedDB using a read-write transaction, similar to the transaction used in savePendingChange().
The example stops after the first failed request to avoid repeatedly sending later changes when an earlier operation has not been processed. This behavior is appropriate when changes must be applied in order. If changes are independent, a more advanced queue may allow separate items to retry individually.
The server must also support safe retries. Suppose it processes a request but the connection drops before the browser receives the response. The browser cannot know whether the operation succeeded, so it may send the same change again.
A robust API should use the change ID as an idempotency key or maintain an equivalent deduplication mechanism. Repeating the same request should not apply the same business operation twice.
Retry When Connectivity Returns
The application can listen for browser connectivity events:
window.addEventListener("online", () => {
syncPendingChanges().catch(error => {
console.error("Synchronization attempt failed:", error);
});
});
window.addEventListener("offline", () => {
showMessage("You are offline. Changes will be saved locally.");
});
These events are useful triggers, but they do not guarantee that the server is reachable. The synchronization function must still handle network failures and HTTP errors.
A production implementation should also retry pending work when the application starts or returns to the foreground. Use bounded retries with increasing delays, often called exponential backoff, rather than retrying continuously.
The Background Sync API can help supported browsers retry work through a service worker after connectivity returns. It is not universally available, and applications should not depend on it as their only synchronization mechanism.
A practical strategy is to combine:
- Local persistence before network requests.
- A sync attempt when the application starts.
- A sync attempt after the browser reports connectivity.
- Retry handling for failed requests.
- Background Sync where supported.
- A visible pending or failed status for users.
Handle Conflicts When Multiple Devices Edit the Same Record
Offline changes introduce a difficult question: what happens when the server record changes before a device reconnects?
Imagine two warehouse employees update the same product while working on separate devices. One device records a quantity of 20, while another records a quantity of 15. If both updates reach the server, the application needs a rule for deciding what the final value should be.
One simple approach is last-write-wins, where the most recent update replaces the previous value. This is easy to implement but can silently discard valid changes, especially when device clocks are inaccurate or users edit the record independently.
Other approaches include:
| Strategy | How it works | Best suited for |
|---|
| Last-write-wins | The latest accepted update replaces the old value | Simple preferences or low-risk fields |
| Version checking | The server rejects updates based on an outdated record version | Business records that need consistency |
| Field-level merging | Non-conflicting field changes are combined | Forms where users edit different fields |
| Manual resolution | Users review competing changes | Financial, inventory, or other important records |
Version checking is a useful default for many business applications.
When the client downloads a record, the server can provide a version number. The client includes that version when submitting an update. If the record has changed since the download, the server returns a conflict response, such as HTTP 409 Conflict.
The client can then fetch the latest record and either merge changes safely or ask the user to resolve the conflict.
For operations such as increasing or decreasing stock, sending an absolute quantity may be less appropriate than sending a validated business operation. The correct model depends on the rules of the application.
Common Mistakes in Offline-First Applications
Assuming Cached Pages Mean the Application Works Offline
A cached page may load while its API calls fail. Identify which screens, records, and actions must remain available offline, then implement storage and fallback behavior for each one.
Removing Pending Changes Too Early
Do not delete a queued operation simply because a request was attempted. Remove it only after receiving a response that confirms successful processing. For operations that may be retried, the server must support deduplication.
Using Local Storage for a Large Queue
Local Storage is synchronous and stores strings. Large or frequent writes can block the main thread. Use IndexedDB for structured records and pending operations.
Ignoring Storage Limits
Browser storage is limited, and browsers may evict data under some conditions. Handle storage errors, request persistent storage where appropriate, and explain the limits of offline data retention to users.
Treating Local Data as Trusted
Browser storage can be inspected or modified by someone with access to the device. Do not store passwords, long-lived secrets, or sensitive information without a clear security design. The server must validate all incoming changes and enforce authorization.
Hiding Sync Failures
If synchronization fails repeatedly, users need a way to see which changes remain pending. Provide a sync status, an error message, and a recovery option rather than silently leaving changes in the queue.
Best Practices for Production Applications
- Save each user change locally before attempting to synchronize it.
- Keep application assets, cached responses, and mutable business data in storage designed for their purpose.
- Give each queued operation a stable identifier.
- Make server operations safe to retry.
- Use version checks or explicit conflict handling for shared records.
- Show users whether a change is saved locally, pending, synced, or in conflict.
- Validate and authorize every change on the server.
- Test with offline mode, slow connections, dropped requests, expired sessions, and server errors.
- Consider what should happen when a user clears browser data, changes devices, or loses access to the device containing pending changes.
- Keep local data only as long as necessary and provide a policy for sensitive information.
Advantages and Limitations
Offline-first design lets users continue working during temporary network failures, reduces disruption in unreliable environments, and can make applications feel more responsive because changes are stored locally first.
It also introduces additional responsibilities. The application must manage local persistence, storage limits, retries, duplicate requests, conflicting edits, and data security. Not every operation should be available offline, particularly when it depends on immediate server authorization, current financial balances, or other live information.
The goal is not to make every feature work without a network. It is to identify the actions users need to complete offline and make their behavior predictable.
Summary
An offline-first web application needs more than a service worker and a cache. It needs a reliable way to store user changes, track pending operations, synchronize with the server, and recover when requests fail.
Use service workers and Cache Storage for application resources, IndexedDB for structured local data, and an explicit queue for changes waiting to sync. Treat connectivity events as hints, make retries safe, and plan for conflicts when multiple users or devices edit the same record.
Most importantly, make the synchronization state visible. Users should know when their work is saved on the device and when the server has actually accepted it.
Join the conversation! Your thoughts help the community grow.