DEV Community

loading...
Cover image for Loading Images with Web Workers

Loading Images with Web Workers

trezy profile image Trezy ・Updated on ・7 min read

Web workers are magical. They allow for multi-threading in JavaScript, a language that has been single-threaded since the beginning. Their practical applications range from heavy number crunching to managing the retrieval and dissemination of asynchronous data, to loading images (as I'll discuss in this article).

I'm actually preparing for an upcoming talk about web workers, and according to a good friend of mine...

Of course Trezy's talk is about web workers. If he wasn't already married to Meg, the man would marry a web worker.
β€” @xlexious

I mean, I suppose I like them a bit. WHAT OF ITβ€½

Why would we want to load images with web workers?

Moving image loading off of the UI thread and into a worker is a really great opportunity for image-heavy sites and one of my favorite web worker implementations. It prevents image downloads from blocking rendering and it can speed up your site significantly.

Fun fact: <img> tags actually block your application load. If you have 100 images on your page, the browser will download all 100 of them before it renders your page.

Let's talk a little bit about implementing web workers in a couple of different environments.

The standard, vanilla implementation

To start a web worker in your average JavaScript app, you need to have it in its own file. Let's assume that we're working on my website, https://trezy.com. We'll name our worker file image-loader.worker.js and it'll be available at https://trezy.com/workers/image-loader.worker.js.

We'll start with a very simple web worker that will log out whatever data it receives:

/*
 * image-loader.worker.js
 */

// The `message` event is fired in a web worker any time `worker.postMessage(<data>)` is called.
// `event.data` represents the data being passed into a worker via `worker.postMessage(<data>)`.
self.addEventListener('message', event => {
  console.log('Worker received:', event.data)
})

To start using it in our main JavaScript file, we'll do something like this:

/*
 * main.js
 */

const ImageLoaderWorker = new Worker('/workers/image-loader.worker.js')

ImageLoaderWorker.postMessage('Hello world!')

If we load all of this up, we should see Hello world! in the console.
πŸŽ‰ Woot! πŸ₯³

Let's Get Into It

Step 1: Update your markup

With your worker implementation all figured out, we can now start implementing our image loader. I'll start with the HTML that we're going to plan on working from:

<body>
  <img data-src="/images/image1.png">
  <img data-src="/images/image2.png">
  <img data-src="/images/image3.png">
  <img data-src="/images/image4.png">
  <img data-src="/images/image5.png">
</body>

Uuuuuuuh, hang on. That's not what <img> tags look like!
β€” You, just now.

Very astute observation, you! Normally you would use the src property of an <img> element to tell it where to download the image file from, but here we're using data-src. This is because when the browser encounters an <img> element with a src attribute, it will immediately start downloading the image. Since we want that job to be offloaded to our web worker, we're using data-src to prevent the browser from handling the download on the UI thread.

Step 2: Pass the image URLs to our web worker

In our main.js file, we'll need to retrieve all of the relevant <img> elements so we can pass their URLs to our web worker:

/*
 * main.js
 */

// Load up the web worker
const ImageLoaderWorker = new Worker('/workers/image-loader.worker.js')

// Get all of the `<img>` elements that have a `data-src` property
const imgElements = document.querySelectorAll('img[data-src]')

// Loop over the image elements and pass their URLs to the web worker
imgElements.forEach(imageElement => {
  const imageURL = imageElement.getAttribute('data-src')
  ImageLoaderWorker.postMessage(imageURL)
})

Step 3: Download the images

Excellent! Now that our web worker has received a bunch of image URLs, let's figure out how to process them. This gets a bit complex in web workers for a couple of reasons:

  1. You don't have access to the DOM API. A lot of non-web worker image downloader implementations create a new image element and set the src attribute on it, initiating the download, then replace the original <img> with the new one. This won't work for us because there's no way to create DOM elements inside of a web worker.

  2. Images don't have a native JavasScript format. Images are made up of binary data, so we need to convert that data into something that we can use in JavaScript.

  3. You can only communicate with the UI thread using strings. I've been corrected. This was the case in the days of yore, but no longer! 😁

So how can we get the image downloaded, converted from binary format to something JavaScript can use, and then passed back to the UI thread? This is where fetch and the FileReader API come in.

fetch is for more than just JSON

You're probably used to seeing fetch used to grab data from some API, then calling response.json() to get the JSON body of the response as an object. However, .json() isn't the only option here. There's also .text(), .formData(), .arrayBuffer(), and the one that matters to us for this exercise, .blob().

A Blob can be used to represent virtually anything, including data that doesn't have a native JavaScript format like images! They're perfect for what we're trying to do here. With that in mind, let's update our web worker to receive the image URLs and download them as Blobs:

/*
 * image-loader.worker.js
 */

// I'm making the event handler `async` to make my life easier. If
// you're not compiling your code, you may want to use the Promise-based
// API of `fetch`
self.addEventListener('message', async event => {
  // Grab the imageURL from the event - we'll use this both to download
  // the image and to identify which image elements to update back in the
  // UI thread
  const imageURL = event.data

  // First, we'll fetch the image file
  const response = await fetch(imageURL)

  // Once the file has been fetched, we'll convert it to a `Blob`
  const fileBlob = await response.blob()
})

Alright, we're making progress! We've updated our images so they don't download automatically, we've grabbed their URLs and passed them to the worker, and we've downloaded the images to the browser!

Step 4: Return the image data to the UI thread

Now that we've got the image as a blob, we need to send it back to the UI thread to be rendered. If we send the string back alone then the UI thread won't know where to render it. Instead, we'll send back an object that tells the UI thread what to render and where:

/*
 * image-loader.worker.js
 */

self.addEventListener('message', async event => {
  const imageURL = event.data

  const response = await fetch(imageURL)
  const blob = await response.blob()

  // Send the image data to the UI thread!
  self.postMessage({
    imageURL: imageURL,
    blob: blob,
  })
})

Our worker file is done! The final step is to handle what we've received in the UI thread.

Step 6: Render that image!

We're so close to being finished! The last thing we need to do is update our main.js file to receive and handle the image data that's returned from the web worker.

/*
 * main.js
 */

const ImageLoaderWorker = new Worker('/workers/image-loader.worker.js')
const imgElements = document.querySelectorAll('img[data-src]')

// Once again, it's possible that messages could be returned before the
// listener is attached, so we need to attach the listener before we pass
// image URLs to the web worker
ImageLoaderWorker.addEventListener('message', event => {
  // Grab the message data from the event
  const imageData = event.data

  // Get the original element for this image
  const imageElement = document.querySelectorAll(`img[data-src='${imageData.imageURL}']`)

  // We can use the `Blob` as an image source! We just need to convert it
  // to an object URL first
  const objectURL = URL.createObjectURL(imageData.blob)

  // Once the image is loaded, we'll want to do some extra cleanup
  imageElement.onload = () => {
    // Let's remove the original `data-src` attribute to make sure we don't
    // accidentally pass this image to the worker again in the future
    imageElement.removeAttribute(β€˜data-src’)

    // We'll also revoke the object URL now that it's been used to prevent the
    // browser from maintaining unnecessary references
    URL.revokeObjectURL(objectURL)
  }

  imageElement.setAttribute('src', objectURL)
})

imgElements.forEach(imageElement => {
  const imageURL = imageElement.getAttribute('data-src')
  ImageLoaderWorker.postMessage(imageURL)
})

Check out the Codepen demo with everything working together:

BONUS: Implementing web workers with Webpack

If you're using Webpack to compile all of your code, there's another nifty option to load up your web workers: worker-loader. This loader allows you to import your web worker into a file and initialize it as if it were a regular class.

I think it feels a little more natural this way, too. Without changing the content of image-loader.worker.js, this is what an implementation would look like if you have worker-loader set up in your Webpack config:

/*
 * main.js
 */

import ImageLoaderWorker from './workers/image-loader.worker.js'

const imageLoader = new ImageLoaderWorker

imageLoader.postMessage('Hello world!')

Just as in our vanilla implementation, we should see Hello world! logged out in the console.

Conclusion

And we're done! Offloading image downloading to web workers is a great exercise in using several different browser APIs, but more importantly, it's an awesome way to speed up your website rendering.

Make sure to drop off your questions and suggestions in the comments below. Tell me your favorite web worker uses, and most importantly, let me know if I missed something awesome in my examples.

Updates

13 November, 2019

  • Added the URL.revokeObjectURL(objectURL) call based on @monochromer 's comment.
  • Fixed several typos.

Discussion (14)

pic
Editor guide
Collapse
monochromer profile image
monochromer

Don't forget about URL.revokeObjectURL(objectURL)

imageElement.onload = () => {
    imageElement.removeAttribute('data-src')
    URL.revokeObjectURL(objectURL);    
  }
Enter fullscreen mode Exit fullscreen mode

This techinque can be used as polyfill for imageElement.decode()

Collapse
trezy profile image
Trezy Author

That's a great callout, @monochromer ! I updated the article with that piece of information. πŸ₯°

Collapse
makingthings profile image
James Grubb

Hello, thanks for the tutorial. I'm looking for ways to dynamically update the image URL in the document's head. Specifically, a meta tag using the OpenGraph protocol

<meta
      property="og:image"
      content="http://ia.media-imdb.com/images/rock.jpg" 
    /> //<-- us a cronjob to update the content property to serve a new image every day
Enter fullscreen mode Exit fullscreen mode

Is this something that a Web Worker could do?

Collapse
trezy profile image
Trezy Author

Unfortunately, no. Web Workers can't access the DOM to make changes. You'd need to do that from the UI thread.

Collapse
imkevdev profile image
Kevin Farrugia

Thank you, great read. However, this is incorrect.

Fun fact: tags actually block your application load. If you have 100 images on your page, the browser will download all 100 of them before it renders your page.

<img> tags delay your PageLoad event but do not block rendering.

Collapse
makingthings profile image
James Grubb

Hello, thanks for the tutorial. I'm looking for ways to dynamically update the image URL in the document's head. Specifically, a meta tag using the OpenGraph protocol

      property="og:image"
      content="http://ia.media-imdb.com/images/rock.jpg" 
    /> //<-- us a cronjob to update the content property to serve a new image every day
Enter fullscreen mode Exit fullscreen mode

Is this something that a Web Worker could do?

Collapse
cikaldev profile image
Ian Cikal

Hi Trez, thank you for the article..

Correct me if i'm wrong, in Step 6 you write this,

// Get the original element for this image
const imageElement = document.querySelectorAll(`img[data-src='${imageData.imageURL}']`)
Enter fullscreen mode Exit fullscreen mode

but the images never showing. instead i have the error message inside console setAttribute() is not a function ... after change into this one, the errors gone and images showing. :)

// the problem is located at "querySelector()"
const imageElement = document.querySelector(`img[data-src='${imageData.imageURL}']`)
Enter fullscreen mode Exit fullscreen mode

If you have a time please update the sample code, for future reader,
Again thank you for the article.

Collapse
snewcomer profile image
Scott Newcomer

Thanks for the article! Do you happen to know what browser specifics caused performance problems when you don't load images with web workers?

Take lazy loading images as an example. Downloading images isn't render blocking. However, without lazy loading, FMP and/or TTI for the site I work on was 2-3 seconds greater on a normal connection.

I'm not sure which steps (decoding/resizing/rasterization) happen off vs. on thread. So, overall I'm still figuring out what browser specifics are blocking/causing performance problems...

developers.google.com/web/fundamen...

Collapse
hdden profile image
HDDen

Hello! Great work!
Please, can you explain me, how blob-urls cooperate with caching? For every page load images will be retrieving from cache, or bypass it and download from web instead?

Collapse
tamunoibi profile image
Ib Aprekuma

This was very helpful. Tbank you

Collapse
baimaoli profile image
Bai MaoLi

Its amazing tutorial.
I was just having issue with image loading in my smart tv app.
In smart tv app, had to show many images in one page, but it was so slow.
I also tried with javascript lazyload event, but when there are many images to load, the key event(when user fast up/down keys), it was also slow.
I think web worker can solve this issue.
Thanks for good tutorial.

Collapse
ashrulkhair profile image
Ashrul Khair

hi, i m interesting to learn something new about web worker, i just wonder to know, how about load img and data in same time, i mean if we have service and that service work every 1 second to fetch data and also load img from path (source path get from fetch data), if you have time please anwers my question sir, thanks a lot. regards from indonesia.

Collapse
kamcio profile image
kamcio

What's the point of this? Doesn't the browsers already decode the images in a separate thread?

Collapse
trezy profile image
Trezy Author

Browsers decode the image separately, yes, but they don't load the images separately. While images are being downloaded they're blocking the UI thread and preventing anything else from being rendered until the image is done downloading. The upside of this approach is that if you have some massive 10k image to load, the rest of your UI can be rendered while the web worker handles the heavy lift of downloading the image.

It's the same reason that you move <script> tags to the bottom of the <body> or mark as async. The download of those scripts will block your UI rendering, so you want to delay them as long as possible, or mark them for async download.