Why Uploads Fail on Flaky Networks
An image upload that silently fails when the network blips is a common frustration. Users are forced to repeatedly retry, uncertain whether their file was received. While we can't control the network, we can change how the application behaves under failure.
Using progressive web app technologies — IndexedDB, service workers, and the Background Sync API — we can build an upload system that stores the image locally when offline, then automatically retries the upload once connectivity returns. The user never has to re-select the file or monitor the connection.
How the Offline Flow Works
The core logic follows a straightforward path: the user picks an image, the app checks connectivity, and if the network is unavailable, the image is moved into local storage. A service worker then watches for the connection to be restored. Once back online, a background sync event fires, the service worker retrieves the image from local storage, uploads it, and finally removes the local copy.
The sequence breaks down into these steps:
- User selects an image
The process starts with the file input. - Image is stored locally in
IndexedDB
If the network is available, the upload proceeds immediately to avoid using local storage. Otherwise, the image is saved toIndexedDB. - Service worker detects restored connectivity
The system waits until the network is back. - Background sync processes pending uploads
The queued image is uploaded automatically. - File is successfully uploaded
The local copy is deleted fromIndexedDBonce the upload completes.
Building the Upload Handler
The implementation begins with the file selection UI. You can provide a standard <input type="file"> element, a drag-and-drop zone, or both. Many users expect a traditional file picker, so including it alongside drag-and-drop improves accessibility. The Clipboard API is another option for pasting images directly.
Registering the Service Worker
The service worker is central to this design. It fetches the image from IndexedDB, performs the upload when connectivity returns, and cleans up the stored data afterward. First, you must register it in your main application script.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
.then(reg => console.log('Service Worker registered', reg))
.catch(err => console.error('Service Worker registration failed', err));
}
Detecting Network State
Before queuing anything, the app must decide whether an upload can happen immediately. This check occurs right after the user selects the image.
function uploadImage() {
if (navigator.onLine) {
// Upload Image
} else {
// register Sync Event
// Store Images in IndexedDB
}
}
Note that the navigator.onLine property shown here is not reliable for production use. It can report incorrect states. A more accurate approach is to ping a known server endpoint you control to verify actual reachability.
Queuing the Upload with a Sync Event
When the network check fails, the next step is to register a one-time sync event. This registration happens in the same place where the upload would normally occur.
async function registerSyncEvent() {
if ('SyncManager' in window) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register('uploadImages');
console.log('Background Sync registered');
}
}
The service worker then listens for that sync tag.
self.addEventListener('sync', (event) => {
if (event.tag === 'uploadImages') {
event.waitUntil(sendImages());
}
});
The sendImages handler is an asynchronous function that pulls the image data from IndexedDB and pushes it to the server.
async function sendImages() {
try {
// await image retrieval and upload
} catch (error) {
// throw error
}
}
Managing Local Image Storage
Storing the image involves opening an IndexedDB database. A global variable holds the database instance so you don't reopen it repeatedly when retrieving the image later.
let database; // Global variable to store the database instance
function openDatabase() {
return new Promise((resolve, reject) => {
if (database) return resolve(database); // Return existing database instance
const request = indexedDB.open("myDatabase", 1);
request.onerror = (event) => {
console.error("Database error:", event.target.error);
reject(event.target.error); // Reject the promise on error
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create the "images" object store if it doesn't exist.
if (!db.objectStoreNames.contains("images")) {
db.createObjectStore("images", { keyPath: "id" });
}
console.log("Database setup complete.");
};
request.onsuccess = (event) => {
database = event.target.result; // Store the database instance globally
resolve(database); // Resolve the promise with the database instance
};
});
}
Why IndexedDB Rather Than localStorage
An obvious alternative is localStorage, but it has a key flaw here. IndexedDB operates asynchronously and does not block the main JavaScript thread. localStorage, however, runs synchronously and can freeze the UI, especially when storing larger binary data like images. That makes IndexedDB the better fit for this workflow.
With the database open, saving the image is a matter of putting it into an object store.
async function storeImages(file) {
// Open the IndexedDB database.
const db = await openDatabase();
// Create a transaction with read and write access.
const transaction = db.transaction("images", "readwrite");
// Access the "images" object store.
const store = transaction.objectStore("images");
// Define the image record to be stored.
const imageRecord = {
id: IMAGE_ID, // a unique ID
image: file // Store the image file (Blob)
};
// Add the image record to the store.
const addRequest = store.add(imageRecord);
// Handle successful addition.
addRequest.onsuccess = () => console.log("Image added successfully!");
// Handle errors during insertion.
addRequest.onerror = (e) => console.error("Error storing image:", e.target.error);
}
Uploading and Cleaning Up
When the connection is restored, the sync event triggers the service worker to retrieve the queued image and send it to the server.
async function retrieveAndUploadImage(IMAGE_ID) {
try {
const db = await openDatabase(); // Ensure the database is open
const transaction = db.transaction("images", "readonly");
const store = transaction.objectStore("images");
const request = store.get(IMAGE_ID);
request.onsuccess = function (event) {
const image = event.target.result;
if (image) {
// upload Image to server here
} else {
console.log("No image found with ID:", IMAGE_ID);
}
};
request.onerror = () => {
console.error("Error retrieving image.");
};
} catch (error) {
console.error("Failed to open database:", error);
}
}
After a successful upload, the stored data is no longer needed. Deleting the database frees up the space the image was occupying.
function deleteDatabase() {
// Check if there's an open connection to the database.
if (database) {
database.close(); // Close the database connection
console.log("Database connection closed.");
}
// Request to delete the database named "myDatabase".
const deleteRequest = indexedDB.deleteDatabase("myDatabase");
// Handle successful deletion of the database.
deleteRequest.onsuccess = function () {
console.log("Database deleted successfully!");
};
// Handle errors that occur during the deletion process.
deleteRequest.onerror = function (event) {
console.error("Error deleting database:", event.target.error);
};
// Handle cases where the deletion is blocked (e.g., if there are still open connections).
deleteRequest.onblocked = function () {
console.warn("Database deletion blocked. Close open connections and try again.");
};
}
Known Limitations
This approach works well, but it has constraints worth knowing before you adopt it.
- No foolproof connectivity detection
JavaScript doesn't expose a guaranteed way to know if the user is truly online. A custom ping-based check is recommended over relying onnavigator.onLine. - Chromium-only API support
The Background Sync API is only implemented in Chromium-based browsers. Non-Chromium users will not get automatic background retries, which may require an alternative fallback strategy if your audience primarily uses Safari or Firefox. - Storage eviction policies
Browsers can clearIndexedDBdata under certain conditions. Safari, for example, may purge stored data after seven days if the user doesn't revisit the site. Keep this in mind when designing long-lived offline queues.
Keeping Users Informed
Since uploads happen in the background, the UI must communicate what's occurring. Toast notifications for "image saved for later," spinners during active uploads, and clear network status indicators help users understand the state. Retry and cancel buttons also give users control when something goes wrong.




