In theory, a stable internet connection should be a given. In practice, for field workers or in warehouse halls with poor reception, reality often looks different. That’s why it was clear for my current project: The app had to work offline too. Data had to be stored locally, edited and synchronized later.
In this post, I show how I implemented a Local-First architecture for my Nuxt app using IndexedDB – including upload queue, offline sync and PWA support.
The problem: Network is not guaranteed
Users should be able to create, edit and upload records and files – even without an internet connection. As soon as the connection is back, all changes must be automatically synchronized.
The challenges:
- Local data storage – All relevant data must be available offline
- Upload queue – Form data and files must be cached and uploaded later
- Dependencies – Some records must be created before associated files can be uploaded
- Sync logic – Server data must be incrementally synchronized
The solution: IndexedDB + Upload Queue + PWA
1. IndexedDB as the central data layer
All relevant data is stored in IndexedDB. The structure is simple: One store per data type. I created stores for various entities – from master data to files to the upload queue itself.
The database connection is cached as a singleton, so a new connection isn’t opened on every access. This saves resources and significantly speeds up access.
For working with IndexedDB, I wrote helper functions: getItem(), setItem(), getAllItems(), removeItem() and clearStore(). They encapsulate the somewhat cumbersome IndexedDB API and make the code much more readable.
2. Upload queue for offline operations
The heart of the offline functionality is the upload queue. It stores all actions performed offline and processes them as soon as the connection is back.
How the queue works
Each queue item has a type, a payload, a status, a priority and optionally a dependency on another item. The status can be pending, uploading, done or error.
For each upload type, a handler is registered that handles the actual API communication. I have handlers for various entities: systems, customers, locations and files.
Handlers for files are somewhat more complex, as blobs must be loaded from IndexedDB and converted to Base64 before being passed to the Catalyst function.
Resolving dependencies
A record must be created before associated images can be uploaded. The queue resolves this via placeholders: When an image is to be uploaded, the payload can reference the ID of the parent record – even if it doesn’t exist yet.
The resolvePlaceholders() function replaces these placeholders with the actual values from the results of previous queue items. This way, complex dependency chains can be mapped.
Automatic processing
The queue is automatically processed as soon as:
- The app is started (if there are still unprocessed items)
- A new item is added
- The internet connection is restored (via the
onlineevent)
This means: Users don’t have to worry about anything. They just keep working, and as soon as the connection is back, all changes are automatically synchronized.
3. Incremental sync for server data
Besides uploading local changes, server data must also be regularly synchronized. For this, I built a generic sync mechanism.
Each table has a sync configuration with store name, function name and response key. The sync process works like this:
- Load last sync timestamp from IndexedDB
- Call server function with this timestamp
- Only changed records are returned
- Update or delete local data (on
is_active = false) - Save new sync timestamp
This saves bandwidth and significantly speeds up the sync. Instead of loading all data every time, only the changes since the last sync are transferred.
4. PWA support with Vite PWA
The app is configured as a Progressive Web App (PWA). This means:
- It can be installed on the home screen
- A service worker caches static assets
- The app works completely offline
I use the @vite-pwa/nuxt module, which handles the entire PWA configuration. A small plugin ensures the page automatically reloads when a new version is available.
5. Conflict resolution for concurrent changes
For offline applications, it’s inevitable that multiple users will edit the same record offline. When both later synchronize, a conflict arises.
Currently, the app uses Last-Write-Wins: The most recently synchronized change overwrites previous versions. This is simple but not ideal – data can be lost.
A more robust conflict resolution with versioning and manual conflict resolution is still on the TODO list. For the current use case (single users per record), Last-Write-Wins is sufficient.
6. Robust error handling with retry strategy
Not every error means the connection is gone. The queue distinguishes between temporary and permanent errors:
- Temporary errors (5xx, timeout, network error): Exponential backoff with max 5 attempts
- Permanent errors (4xx except 408): Item is marked as `failed`, user notification
- Auth errors (401, 403): Token refresh is triggered, then retry
The retry delays follow the pattern: 1s, 2s, 4s, 8s, 16s. This prevents the server from being overloaded during problems.
7. Storage management and quota monitoring
IndexedDB has browser-dependent limits (typically 50MB to 2GB). To avoid quota issues:
- The app regularly checks available quota with the Storage API
- At 80% usage, a warning is displayed
- Images are compressed before saving (max. 1920px width, 85% JPEG quality)
An LRU cache ensures that rarely used records can be removed from IndexedDB when needed.
How it works in practice
Scenario 1: Working offline
A user is in a building without reception. They create a new record, upload three images and edit an existing entry.
All actions are immediately saved in the upload queue. The UI shows that the data is stored locally. As soon as the person has reception again, all changes are automatically uploaded – without them having to do anything.
Scenario 2: Poor connection
The connection drops during an upload. The queue marks the item as error and tries again on the next processQueue() call. Users can continue working in the meantime.
Scenario 3: Complex dependencies
A system is created, three images are to be uploaded. The system item gets priority 1, the image items priority 2 and a dependency on the system item.
The queue first processes the system, saves the returned ID and then replaces the placeholder in the image items with this ID. All three images are correctly assigned to the new system.
Conclusion
The combination of IndexedDB, upload queue, PWA and well-thought-out conflict resolution turns a normal web app into a fully-fledged offline application. Users can work without interruption, whether with or without an internet connection.
The setup requires some initial work but pays off quickly. The architecture is extensible: New entities can be integrated simply by adding a handler and a sync configuration.
The important thing is to think in Local-First terms from the start. Not “What happens when the connection drops?” but “How does the app work offline, and how do I synchronize later?”. This mindset shift makes the difference between a web app with an offline mode and a true Local-First application.
The biggest challenges lie not in the technical implementation but in the edge cases: conflict resolution, error handling, storage management and security. Those who consider these aspects from the start build a robust solution that works reliably even under difficult conditions.