Source: Articles on Smashing Magazine — For Web Designers And Developers | Read More
IndexedDB
, service workers, and the Background Sync API, you can build an offline-friendly image upload system that queues uploads and retries them automatically — so your users can upload stress-free, even when offline.So, you’re filling out an online form, and it asks you to upload a file. You click the input, select a file from your desktop, and are good to go. But something happens. The network drops, the file disappears, and you’re stuck having to re-upload the file. Poor network connectivity can lead you to spend an unreasonable amount of time trying to upload files successfully.
What ruins the user experience stems from having to constantly check network stability and retry the upload several times. While we may not be able to do much about network connectivity, as developers, we can always do something to ease the pain that comes with this problem.
One of the ways we can solve this problem is by tweaking image upload systems in a way that enables users to upload images offline — eliminating the need for a reliable network connection, and then having the system retry the upload process when the network becomes stable, without the user intervening.
This article is going to focus on explaining how to build an offline-friendly image upload system using PWA (progressive web application) technologies such as IndexedDB
, service workers, and the Background Sync API. We will also briefly cover tips for improving the user experience for this system.
Here’s a flow chart for an offline-friendly image upload system.
As shown in the flow chart, the process unfolds as follows:
IndexedDB
.IndexedDB
.IndexedDB
, the system waits to detect when the network connection is restored to continue with the next step.IndexedDB
.The first step in the system implementation is allowing the user to select their images. There are different ways you can achieve this:
<input type="file">
element;I would advise that you use both. Some users prefer to use the drag-and-drop interface, while others think the only way to upload images is through the <input type="file">
element. Having both options will help improve the user experience. You can also consider allowing users to paste images directly in the browser using the Clipboard API.
At the heart of this solution is the service worker. Our service worker is going to be responsible for retrieving the image from the IndexedDB
store, uploading it when the internet connection is restored, and clearing the IndexedDB
store when the image has been uploaded.
To use a service worker, you first have to register one:
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));
Remember, the problem we are trying to solve is caused by unreliable network connectivity. If this problem does not exist, there is no point in trying to solve anything. Therefore, once the image is selected, we need to check if the user has a reliable internet connection before registering a sync event and storing the image in IndexedDB
.
function uploadImage() if (navigator.onLine) // Upload Image else // register Sync Event // Store Images in IndexedDB
Note: I’m only using the navigator.onLine
property here to demonstrate how the system would work. The navigator.onLine
property is unreliable, and I would suggest you come up with a custom solution to check whether the user is connected to the internet or not. One way you can do this is by sending a ping request to a server endpoint you’ve created.
Once the network test fails, the next step is to register a sync event. The sync event needs to be registered at the point where the system fails to upload the image due to a poor internet connection.
async function registerSyncEvent() if ('SyncManager' in window) const registration = await navigator.serviceWorker.ready; await registration.sync.register('uploadImages'); console.log('Background Sync registered');
After registering the sync event, you need to listen for it in the service worker.
self.addEventListener('sync', (event) => if (event.tag === 'uploadImages') event.waitUntil(sendImages()); );
The sendImages
function is going to be an asynchronous process that will retrieve the image from IndexedDB
and upload it to the server. This is what it’s going to look like:
async function sendImages() try // await image retrieval and upload catch (error) // throw error
The first thing we need to do in order to store our image locally is to open an IndexedDB
store. As you can see from the code below, we are creating a global variable to store the database instance. The reason for doing this is that, subsequently, when we want to retrieve our image from IndexedDB
, we wouldn’t need to write the code to open the database again.
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 ; );
With the IndexedDB
store open, we can now store our images.
Now, you may be wondering why an easier solution likelocalStorage
wasn’t used for this purpose.The reason for that is that
IndexedDB
operates asynchronously and doesn’t block the main JavaScript thread, whereaslocalStorage
runs synchronously and can block the JavaScript main thread if it is being used.
Here’s how you can store the image in IndexedDB
:
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);
With the images stored and the background sync set, the system is ready to upload the image whenever the network connection is restored.
Once the network connection is restored, the sync event will fire, and the service worker will retrieve the image from IndexedDB
and upload it.
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);
Once the image has been uploaded, the IndexedDB
store is no longer needed. Therefore, it should be deleted along with its content to free up storage.
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."); ;
With that, the entire process is complete!
While we’ve done a lot to help improve the experience by supporting offline uploads, the system is not without its limitations. I figured I would specifically call those out because it’s worth knowing where this solution might fall short of your needs.
IndexedDB
Storage PoliciesIndexedDB
. For instance, in Safari, data stored in IndexedDB
has a lifespan of seven days if the user doesn’t interact with the website. This is something you should bear in mind if you do come up with an alternative for the background sync API that supports Safari.Since the entire process happens in the background, we need a way to inform the users when images are stored, waiting to be uploaded, or have been successfully uploaded. Implementing certain UI elements for this purpose will indeed enhance the experience for the users. These UI elements may include toast notifications, upload status indicators like spinners (to show active processes), progress bars (to show state progress), network status indicators, or buttons to provide retry and cancel options.
Poor internet connectivity can disrupt the user experience of a web application. However, by leveraging PWA technologies such as IndexedDB
, service workers, and the Background Sync API, developers can help improve the reliability of web applications for their users, especially those in areas with unreliable internet connectivity.