
Learn practical sync strategies for IndexedDB in offline-first web apps to handle conflict resolution and seamless background syncing.
When I build web apps that need to work without an internet connection, getting the local storage right is only half the battle. Storing data safely in the browser using IndexedDB is straightforward, but syncing that data back to a remote server once connectivity returns can quickly get messy.
Over the years of building projects like Nept Track, where reliability offline is non-negotiable, I have tested several approaches to offline-first architecture. In this post, we will walk through practical strategies for building resilient synchronization mechanisms using IndexedDB, handling local mutations, and resolving conflicts gracefully.
Many developers start with localStorage because it has a simple key-value API. However, it blocks the main thread, has a tiny storage limit around 5MB, and only stores strings. IndexedDB, on the other hand, is an asynchronous, transactional database built directly into the browser.
It handles structured clones of JavaScript objects, supports indexes for fast querying, and scales to gigabytes of data depending on the user's disk space. This makes it the ideal client-side database for storing complex relational data, cached API responses, and pending offline actions.
When a user performs an action while offline—like creating a new habit entry or updating a profile—you cannot send an HTTP request to your backend immediately. Instead, you need to execute two steps locally:
sync_queue store inside IndexedDB detailing the mutation.Here is a simplified structure of how I typically structure a mutation queue item:
{
"id": "uuid-v4-string",
"endpoint": "/api/habits",
"method": "POST",
"payload": {
"name": "Morning Run",
"completed": true
},
"createdAt": 1718000000000,
"retryCount": 0
}
By saving this operation locally, your app remains fully functional. The user can close the tab, refresh the page, or lose connection entirely without losing their work.
Tagged with
Leave a comment
To push these queued mutations to your server, you need to listen for network state changes. Browsers provide the navigator.onLine property alongside online and offline window events.
window.addEventListener('online', () => {
console.log('Connection restored. Starting sync process...');
processSyncQueue();
});
window.addEventListener('offline', () => {
console.log('Connection lost. Switching to offline mode.');
});
While navigator.onLine is useful, it is not foolproof. A user might technically be connected to a Wi-Fi router that has no actual internet access. To handle this, your sync function should wrap API requests in robust error handlers. If a request fails due to a network timeout, the sync worker should pause and back off.
Sooner or later, you will run into a scenario where a piece of data was modified locally while offline and also modified on the server by another device or user. How do you resolve the mismatch?
There are three common strategies I use depending on the product requirements:
This is the simplest approach. Every record gets a timestamp (updatedAt). When syncing, the server compares the client's timestamp with its own. The most recent timestamp wins and overwrites the older data. While easy to implement, it can lead to silent data loss if an older update overwrites important newer edits.
For specific use cases, you can enforce a deterministic rule. For example, in a personal offline habit tracker, local edits made by the user almost always take precedence over stale server states.
For collaborative apps, you can store both versions and prompt the user to resolve the conflict manually, similar to how Git handles merge conflicts. This provides maximum safety but adds significant complexity to the user interface.
One tricky issue when syncing offline-created records is handling primary keys. If your client generates a temporary ID (like a UUID) for a new record, the server needs to recognize that ID or return its own authoritative ID upon successful insertion.
When the server responds with a 201 Created and provides the official database ID, your sync worker must update the local IndexedDB record, replacing the temporary client ID with the permanent server ID, and update any foreign key relationships pointing to it.
Building offline-first applications requires shifting how you think about data flow. Instead of treating the server as the single source of truth at all times, you treat the client as an autonomous node that eventually reconciles its state with the backend.
By combining IndexedDB for structured local storage with a robust mutation queue and clear conflict-resolution rules, you can deliver lightning-fast, privacy-friendly web applications that never leave your users hanging when the Wi-Fi drops.
Need help architecting an offline-first web app or robust API backend for your next product? Let's talk about your project requirements. Get in touch and tell me what you are building.