Engineering

Rendering Without Freezing the Main Thread

What I learned moving page thumbnail generation in the browser onto a web worker and an offscreen canvas: why you describe your scene instead of screenshotting the DOM, and why memory suddenly becomes your problem.

Sjors Verhoef4 min read

Every page in a document needs a little preview image, a thumbnail you show in something like an overview or a sidebar. Sounds simple. It is not. My first approach was the obvious one: just screenshot the live page in the DOM and scale it down. It worked fine in a demo, but the moment the page got busy and needed constant re-capturing, the whole interface locked up. I want to walk through how I stopped fighting the main thread and started rendering next to it, using web workers and an offscreen canvas.

Why the main thread is so precious

In a browser, one thread runs your JavaScript, your layout, your paint, and your event handling. All of it. That is why a single heavy loop can freeze everything at once. When I was generating page previews, capturing one busy page could lock the interface for hundreds of milliseconds.

If a task takes longer than a frame and it is not directly painting what the user is looking at right now, it probably should not be on the main thread.

What a web worker actually gives you

A web worker is a separate thread with its own JavaScript context. It cannot touch the DOM, and honestly that limitation is the whole point. It forces you to hand over plain data and get plain data back. The heavy work happens somewhere the user never feels it.

The worker is not a place to move your components. It is a place to move your computation. You send it a description of what to render, it makes the pixels, and it sends a result back.

OffscreenCanvas: pixels without a visible canvas

The piece that ties it together is OffscreenCanvas. It is a canvas that is not attached to the page, so a worker can draw on it. You rasterise your scene there, encode it to a compact image, and hand back something the main thread can drop straight into an <img>.

Describe the scene, do not screenshot the DOM

My biggest early mistake was trying to screenshot the live DOM. Walk the nodes, inline the styles, serialise, paint. It works in a demo and falls apart in real life. Fonts are not loaded yet, media does not come through, cross-origin content is tainted, and results differ per platform.

What finally worked was describing each thing on the page as small, serialisable data. An image becomes a reference plus a box. Text becomes a string plus layout info. Once everything is plain data, you can transfer it to the worker cheaply and render it deterministically. You are no longer at the mercy of whatever the DOM happens to look like in that millisecond.

The unglamorous part that makes it usable

Drawing the picture is maybe twenty percent of the work. The other eighty percent is deciding when not to draw it.

  • Queue the work. Ten visible previews should not launch ten parallel renders. Run them one at a time so the browser does not thrash.
  • Cache by version. Key each result on the item and its last change. Unchanged content hits the cache instantly, edited content renders fresh.
  • Cancel stale work. Edit something three times fast and the older renders are worthless. Throw them away and keep the newest.
  • Debounce input. Do not re-render on every keystroke. Wait for a pause, then read the latest state.

Memory is now your job

Once a render finishes, I hand the thumbnail back as an object URL instead of a base64 data string, so the image bytes never sit in the JavaScript heap. That keeps the main thread light, but it comes with a catch: the memory is now yours to manage. The browser (Chrome, for example) keeps the underlying blob in native memory, outside your JS heap, so the garbage collector will not reclaim it. The browser does invalidate those URLs on a reload or page navigation, but as long as the page stays open it will not clean anything up for you. The fix is boring and essential: every URL you create needs a matching release when its owner goes away. Treat it like manual allocation, because that is what it is.

What I would tell my past self

  1. Move heavy work off the main thread early.
  2. Describe your scene as data, but only when you have a heavy visual load. For a normal web page this is of course unnecessary. When you do need it, it is faster, more predictable, and portable to a worker.
S
Sjors Verhoef
Freelance Developer

Share this article

Related Posts

Interested in Working Together?

Let's discuss your next project.

Contact Me