Command Palette

Search for a command to run...

Hectal
Course outline
Topic 18.4Advanced ~20 min · step 4 of 7

Core Web Vitals and Lazy Canvases

In one line

A 3D scene is the heaviest thing on most pages. Show a poster picture first, fetch three.js only when the scene is about to be seen, reserve its space, and stop drawing when it scrolls away.

0/7 · 0%

Think of it like this

A shop window shows a photo of the new bicycle while the real one is in the back room. The passer-by sees something at once, and the staff wheel the real bicycle out only when someone comes in. The poster is the photo; the lazy start is the staff.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Core Web Vitals
Three measured numbers (LCP, INP, CLS) that Google uses to describe a page's real loading, reaction and stability.
LCP
Largest Contentful Paint: how long until the biggest image or block of text on screen is drawn. Good: 2.5 s or less.
INP
Interaction to Next Paint: how long the page takes to react after a tap or key. Good: 200 ms or less.
CLS
Cumulative Layout Shift: how much visible content jumps while loading. Good: 0.1 or less.
dynamic import()
import("./file.js") loads a module when the line runs and returns a promise. Bundlers put it in its own file.
IntersectionObserver
A browser object that calls your function when an element enters or leaves the visible area, without you listening to scroll.

Step by step

01The page starts with a poster

The page has a 200 px stage with a <img> poster in it, and 600 px of content above and below. The first line printed is page ready: poster shown, no scene built yet.

02Scrolling near it starts the work

After 300 ms the example scrolls the stage into view. The observer fires, start() runs await import("three"), builds a renderer and a cube, hides the poster and prints started: poster hidden: true. The observer has a rootMargin of 100 px, which counts the viewport as 100 px bigger so the work can begin early. We could not show that effect inside this preview frame.

03Scrolling away stops it

The example scrolls back to the top. The observer fires with isIntersecting: false, stop() calls setAnimationLoop(null), and it prints scrolled away: loop stopped. Scrolling back would resume the same renderer, not build a second one.

04Measure the shift

The second example puts the same canvas into two boxes. In A the box has no height until the canvas is added; in B the box is already 120 px tall. The paragraph below moves 120 px in A and 0 px in B.

Try it yourself

  1. 1

    Reserve too little

    In the second example set .slot.reserved to height: 60px. B still prints paragraph moved by 0 px: the box keeps 60 px, and the 120 px canvas (never drawn in this example, so you cannot see it) sticks out of it and would sit over the paragraph. Reserve the real height, not just any height.

  2. 2

    Break the download

    In the first example change import("three") to import("three-missing"), the way a failed or offline fetch ends. The console prints visible: fetching three.js now and then Uncaught (in promise) TypeError: Failed to resolve module specifier 'three-missing'. The poster stays, which is the right result: the visitor keeps a picture. Catch the failure, as boot.js does, and say why 3D is missing.

Code & diagrams

Poster first, three.js when seen, stop when not New tab

The first line only sets up this preview's import map; a real app does not write it. three.js is already on this page, so the saving shows in your project's Network panel, not here.

idle

Console

Run the scene to see its console.

Expected console output

page ready: poster shown, no scene built yet
visible: fetching three.js now
started: poster hidden: true
scrolled away: loop stopped
Layout shift: the box with and without reserved space New tab

A is the common mistake. B reserves the canvas's final height before it exists.

idle

Console

Run the scene to see its console.

Expected console output

A: no space reserved       paragraph moved by 120 px
B: space reserved          paragraph moved by 0 px
What `npm run build` printed for the finished showroom (Vite 8.3.4)bash
dist/index.html                      5.82 kB │ gzip:     1.95 kB
dist/assets/index-rjql1MhQ.js        2.68 kB │ gzip:     1.26 kB   <- loaded first (boot.js + lazy.js)
dist/assets/main-dbpGDQVt.js       730.82 kB │ gzip:   186.62 kB   <- three.js and the showroom, when visible
dist/assets/paint.tsl-D28XoILR.js   11.39 kB │ gzip:     4.04 kB   <- only with ?webgpu
dist/assets/three.webgpu-DnF_iw0t.js 675.57 kB │ gzip:  187.94 kB   <- only with ?webgpu
dist/assets/rapier-Ct8Isipz.js    4,332.82 kB │ gzip: 1,669.31 kB   <- the first press of Test ride

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Never stop

In the first example change the observer callback to ([entry]) => entry.isIntersecting && start().

terminal
$ Run
── what you'll see ──
page ready: poster shown, no scene built yet
visible: fetching three.js now
started: poster hidden: true

Myth vs fact

Myth

Lazy loading the canvas fixes LCP by itself.

Fact

It only helps if something else, such as the poster or a heading, is the LCP element.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Preload what the first view needs: <link rel="preload" as="image" href="/data/3d/showroom-poster.png"> helps the poster become the LCP element sooner. Do not preload the model: it competes with the page's own content.

  • ▸

    A stopped loop is not a freed GPU: textures and buffers stay in memory. Recall from Topic 17.4: call dispose() when a canvas leaves for good, and with many canvases keep few (Topic 18.2's limit of 16 in Chromium); stopping only saves the drawing work.

Remember this

  1. 1

    The problem this solves: three.js plus a showroom is hundreds of kilobytes of JavaScript, a model, textures and shader compilation. If all of it must load before the page shows anything, the page feels slow, and a visitor who never scrolls to the 3D part paid for it anyway.

  2. 2

    Three numbers Google measures. The Core Web Vitals are LCP (Largest Contentful Paint, when the biggest thing appears), INP (Interaction to Next Paint, how fast the page reacts to input) and CLS (Cumulative Layout Shift, how much things jump). web.dev's "good" thresholds (75th percentile of page loads) are: LCP at most 2.5 seconds, INP at most 200 milliseconds, CLS at most 0.1.

  3. 3

    A canvas is not an LCP element. web.dev lists the elements that count for LCP: <img>, an <image> inside <svg>, a <video> poster, an element with a url() background image, and block-level text. A <canvas> is not on the list. A poster <img> is what LCP measures; a 3D hero with no poster leaves LCP to something else, later.

  4. 4

    Poster first, three.js later. The page loads a tiny entry script. It waits, with IntersectionObserver, until the showroom is near the viewport, and only then runs import("./main.js"). A dynamic import() makes the bundler put everything behind it in a separate file that is fetched on demand. The first example does this with a poster and a scroll, and prints the order of events.

  5. 5

    Start and stop. The same observer can stop the render loop when the canvas scrolls out of view and start it again when it returns, so a page with a 3D section far below costs no GPU time while the visitor reads text. The example prints scrolled away: loop stopped. Background tabs already pause requestAnimationFrame; an off-screen canvas in a visible tab does not.

  6. 6

    CLS: reserve the space. A canvas starts with no size, so content below jumps down when the scene arrives. Give the container its final size first (aspect-ratio, or a fixed height). The second example measures the jump: 120 px without a reserved slot, 0 px with one.

Explain it without notes

01

A product page has a 3D viewer below the fold. List four changes that protect LCP, INP and CLS.

02

Why does import("./main.js") make the page lighter than a normal import?

Practice

01

Write whenVisible(element, start) that calls start once, when the element is within 200 px of the viewport.

02

Add a CSS rule so a 16:9 canvas slot is reserved at any width.

Done when you can

  • You can name the three Core Web Vitals and web.dev's "good" thresholds.

  • You can load three.js with a dynamic import() when the scene is about to be seen.

  • You reserve the canvas's space so nothing jumps.

  • You stop the render loop when the canvas is off-screen.