Ashley Sheridan​.co.uk

Using Local Storage to Cache Images

Posted on

I was recently working on improving the performance of a web page that pulled in a large dynamic list of images, and displaying their respective thumbnails on the page. Even with caching, the entire set of images took a considerable amount of time to fully load on the page. Enter the local storage API.

What is Local Storage

The local storage API is part of a family of new interfaces for storing data in a browser in a more consistent and efficient manner. Local storage is often seen as being most similar to cookies, in that you can place whatever string data you wish to inside and retrieve it later if it still exists.

The main difference between the two is that cookies, by default, have an expiry. If you don't set anything specific, the data is deemed to be for the current session only, and gets discarded when that session ends. Local storage has no such time limit. The only way that items get removed from there are from your own code dropping them, or if a person decides to manually clear out the storage cache in their browser.

Storing Items for a Finite Duration

As I mentioned, items within local storage are stored infefinitely, or at least until they are manually removed by user or by code. There's no automatic process that natually ages items out of the cache, so if we want that behaviour, we need to add it in ourselves.

Local storage is basically just a key value store for string data, just like the older cookie mechanism. Because of that, if you need to associate a value with some kind of extra metadata then you have to get a little creative.

The approach I went for was to use a JSON object stored as a string. This will allow me to add any metadata I require into the value part. Here's a basic example in TypeScript:

setItem(itemKey: string, itemValue: string): boolean { try { let now = new Date(); let jsonData = JSON.stringify({time: now, data: itemValue}); window.localStorage.setItem(itemKey, jsonData); return true; } catch(e) { return false; } }

Now, when using this method, a string representation of a JSON object is stored instead of only the string value we passed in. We can use this at any point to identify the age of a piece of data, in my case an image, and remove it if it is deemed to be too old.

I've wrapped the storing of data inside a try/catch block. This ensures that any warnings thrown from the API do not blow up the rest of the code execution. Warnings and errors may be thrown if a browser doesn't fully support local storage, or if the storage cache for the current domain is already full.

Fetching Items Stored as JSON

Fetching items back out from local storage is only slightly more work because of the extra JSON layer:

getItem(itemKey: string): string { try { let jsonObjectString = window.localStorage.getItem(itemKey); let parsedData = JSON.parse(jsonObjectString); return parsedData.data; } catch(e) { return null; } }

Again, another try/catch block here to protect against acting on data that doesn't exist in local storage, and also to safeguard against the JSON parsing from throwing errors if the raw data isn't a valid JSON string.

The catch returns null to match the default return value of a regular call to window.localStorage.getItem() if an item key could not be found.

Aging Out Old Cached Items

Now that the wrappers for setting and getting data to and from local storage are done, we need a way to take advantage of the timestamp metadata we've attached to each item.

Unfortunately, the API doesn't give us a lot of methods to work with the data, so we have to iterate over the entire series:

expireOldCacheItems(): void { let totalStorageItems = window.localStorage.length; let now = new Date(); let maxAge = 1000 * 60 * 60 * 24 * 5; // 5 days for(let i = 0; i < totalStorageItems; i++) { let itemKey = window.localStorage.key(i); let itemData = window.localStorage.getItem(itemKey); try { let parsedData = JSON.parse(itemData); let itemCacheTime = new Date(parsedDate.time); let timeDifference = now.valueOf() - imageCacheTime.valueOf(); if(timeDifference > maxAge) { window.localStorage.removeItem(itemKey); } } } }

What we're doing here is looping over the entire data set, inspecting each items time value in the JSON metadata, and removing the item if the difference between that time and now is greated than the maximum age we set up for it.

Putting Images in Local Storage

Now that we've covered all the basics, we come to the meat of the task: storing image data.

In order to get the base64 encoded image data, we need an image in the DOM to work with:

<img src="some-image.png" id="myImage"/>

Then, in our code, we can use an HTML5 canvas to paint that image and then pull the encoded data:

let thumbnailImageObj = document.getElementById('myImage'); let canvas = document.createElement("canvas"); canvas.width = thumbnailImageObj.width; canvas.height = thumbnailImageObj.height; let ctx = canvas.getContext("2d"); ctx.drawImage(thumbnailImageObj, 0, 0); let imageKey = thumbnailImageObj.src; let dataUrl = canvas.toDataURL("image/png"); setItem(imageKey, dataUrl);

First, I get a reference to the image object, create a new canvas of the same dimensions, then I draw the image into the canvas.

The key part here is the canvas.toDataURL() call, which grabs the base64 data we need. At that point, it's just a matter of calling the method created earlier. For simplicities sake the methods here are just global functions, but in reality you'd want to encasulate this work behind a class or module. There's rarely a good time to pollute the global scope like this these days.

Putting it to Use

Obviously, the reason for putting these images into local storage in the first place is to avoid making expensive network calls. To make best use of this, you would probably want to follow these steps:

  1. Get the URL that you want your image to finally have
  2. Look this up against local storage
  3. If it exists, use the value you got back
  4. If it doesn't exist, output that image tag as normal, but add an image load event handler
  5. The handler should wait until triggered, and then grab the complete downloaded image data, shoving it into storage

For myself, the images were being dynamically generated from within an Angular application, so I could make my storage lookups before the image tags were even output to the page.

One possible alternative method for other applications where you cannot do this is to put the URL for the images into a data attribute:

<img src="" data-src="some url"/>

Then you can have some JavaScript loop over images with that attribute, do the lookup, and fill in the blank src attribute with either the data URL or the path to the original resource.

The Benefits

When I implemented this on the project I was working on, I saw page load times decrease by more than half. This was on a section of the site pulling in 60 gallery thumbnails. The Google Lighthouse performance score for the page jumped considerable (this locale storage change was part of a larger group of updates I made which brought the lighthouse score from 14 to 94!).

In conclusion, which this won't be a magic wand for all your performance issues, used in a targetted and specific manner, you could greatly improve performance of your website or application at the critical parts right where it's needed.