Course outline
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.
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
Reserve too little
In the second example set
.slot.reservedtoheight: 60px. B still printsparagraph 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
Break the download
In the first example change
import("three")toimport("three-missing"), the way a failed or offline fetch ends. The console printsvisible: fetching three.js nowand thenUncaught (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, asboot.jsdoes, and say why 3D is missing.
Code & diagrams
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.
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 stoppedA is the common mistake. B reserves the canvas's final height before it exists.
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 pxdist/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 rideBreak 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().
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
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
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
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 aurl()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
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 runsimport("./main.js"). A dynamicimport()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
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 pauserequestAnimationFrame; an off-screen canvas in a visible tab does not. - 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
A product page has a 3D viewer below the fold. List four changes that protect LCP, INP and CLS.
Why does import("./main.js") make the page lighter than a normal import?
Practice
Write whenVisible(element, start) that calls start once, when the element is within 200 px of the viewport.
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.