Case study
Sky Islands
The painted world at the top of this site: plain JavaScript drawing on canvas in two web workers, with no images and no libraries. Moving the drawing off the main thread took mobile Lighthouse from 61 to 94–97.
- Canvas 2D
- Web Workers
- OffscreenCanvas
- Web Audio
- View Transitions
- Performance
Problem
I wanted the top of my site to show how I build things instead of describing it: a painted world that people want to stay in and play with. It had to feel alive and full of small surprises, and it still had to load and respond like a normal page on an ordinary phone. A big painted image would have been heavy and couldn't move, and every library I added would have been more to download before anything appeared.
Approach
Everything is drawn with the Canvas 2D API from a seeded random generator, so there are no image files and it is the same island on every visit. The scene is 11 stacked canvases. Eight are painted once for each size and theme and after that only slide; the other three hold everything that moves and are the only ones redrawn each frame. Each layer has its own depth, which gives the parallax, and its own resolution, so a soft sky renders at 60% and the blurred ferns in front at 30%. On top of that sit a field guide, seasons that follow the calendar, and sound made live with Web Audio. Interaction follows a few rules: hovering only looks, a click touches, nothing on screen explains how to play, and on a first visit the creatures arrive one at a time instead of all at once. To a screen reader the scene is decoration: the field guide and Sound are ordinary buttons, and Escape closes the guide. If your system asks for reduced motion, the world draws once and holds still.
Architecture
Two web workers do the drawing. The paint worker paints the still layers and sends them back as ImageBitmaps, which pass between threads without being copied, and then paints the other theme in the background. The render worker owns the animated canvases, handed over with transferControlToOffscreen(), and draws every frame. The page's main thread only reads the pointer and the scroll position, posts them across, and moves the finished layers with CSS transforms. The hover hit tests run in the render worker against the same shapes it draws. Browsers without OffscreenCanvas run the same renderer on the main thread through the same messages, so there is one renderer to test. The rest of the site is Next.js 16 and React 19, with every page built ahead of time and the articles written in MDX.
Tradeoffs
Drawing everything in code made the download small but made the first frame expensive, so most of the work went into deciding what never has to be drawn twice. Quality follows measured frame times, not a list of devices: when frames run long, the renderer drops to half-rate layers and then to lower resolution, and it never steps back up, because flipping between modes would be more noticeable than either one. A benchmark script drives headless Firefox through fixed scenes, runs the change and the last commit in alternating rounds, and fails if any scene gets more than 1 ms a frame slower, so a new feature either fits or gets cheaper. Two more checks render the change and the last commit side by side and fail if a single pixel or sound sample differs, so a cleanup can never quietly change the picture or the sound.
Outcome
Moving the drawing off the main thread took mobile Lighthouse from 61 to between 94 and 97, with desktop at 99 and nothing shifting while the page loads. Painting the first frame in the worker cut main-thread time at load from 145 to 65 ms on a standard screen and from 282 to 44 ms on a high-density one. The theme switch went from a one-second freeze to an instant swap. In Firefox on Linux, which draws canvas on the CPU, the lower-resolution step took clicking around the world from 26 to 53 frames a second. The painted scenes on these case studies, this one included, run on the same code.