I build a customizer for printed sneakers. You upload an artwork, it gets split across the leather panels of a shoe, and you see the result on the actual shoe before buying. The geometry is the interesting part — that's the first post and the second.
This post is about the other half: it worked, and it was unusably slow. Cold load, first preview: 44 seconds.
Here is what actually fixed it — including one "optimization" I had to revert, and a measuring mistake that sent me chasing a bug that didn't exist.
The pipeline, briefly
For each of 5 shoe angles, the browser:
- downloads a 1800×1800 factory photo of the shoe from that angle,
- downloads one alpha mask per panel for that angle (29 of them on one model),
- for each panel, inverse-bilinear-maps every masked pixel back into the uploaded artwork, samples it, and multiplies.
Rendering was already fast in isolation: ~77 ms of pure computation per angle after I dropped the resolution of the inverse-mapping pass. So 44 seconds was never arithmetic. It was the browser waiting, and then the browser doing work I never asked for.
Fix 1: the angle strip was decoding 16 MB of images nobody could see
Each angle thumbnail is displayed at 41 × 43 px. The code that produced it looked like this:
img.src = viewCanvas.toDataURL('image/png'); // viewCanvas is 1800 × 1800
A 1800² PNG of a shoe photo is about 5.6 MB as base64. Five angles: 16 MB of data URLs, each one an <img> the layout had already reserved 41 px for. The browser dutifully decoded all of them at full resolution, on the main thread.
This is the trap with toDataURL: it looks like "export a small preview". It doesn't know what small means. It exports at canvas resolution, and the cost lands later, at decode time, where it isn't attributed to any of your code.
Replacing it with a 256 px JPEG on a white background:
function thumbUrl(srcCanvas, side = 256) {
const s = Math.min(side / srcCanvas.width, side / srcCanvas.height, 1);
const c = makeCanvas(Math.round(srcCanvas.width * s), Math.round(srcCanvas.height * s));
const g = c.getContext('2d');
g.fillStyle = '#fff'; // JPEG has no alpha; flatten first or edges go grey
g.fillRect(0, 0, c.width, c.height);
g.drawImage(srcCanvas, 0, 0, c.width, c.height);
return c.toDataURL('image/jpeg', 0.86);
}
5.6 MB → 15 KB per angle. The white fill matters: JPEG has no alpha channel, and a transparent-to-JPEG round trip gives you a grey halo exactly where the shoe silhouette meets the background.
Fix 2: the source images were PNG
Same photos, no alpha needed, 1800 px long edge: 51.4 MB → 3.0 MB as WebP, visually indistinguishable at q84. The old PNGs stayed on disk but nothing referenced them anymore, so the deploy step dropped them.
One detail: I convert with a white background rather than keeping transparency, because the print pipeline multiplies the artwork against the shoe photo. Semi-transparent edges multiply against black and the shoe comes out dirty grey.
Fix 3: stop downloading base images you can't draw on
Not every angle has a usable mesh. The sixth gallery image is usually a 4000² whole-shoe shot for reference — 5 MB on one model — that the renderer can't map panels onto at all. It was being downloaded every time and then skipped.
const hasMesh = i => !!(meshViews[i] && meshViews[i].parts && meshViews[i].parts.length);
Two lines of guard. On one model it removed a 5 MB download from the critical path.
Fix 4: 29 masks awaited one after another
The render loop did this, inside a for:
const mask = await reconLoad(part.mask); // 29 iterations
Each iteration waited for a full round trip. 29 masks × latency is where tens of seconds go, with the CPU idle the whole time. The fix is not clever — kick them all off when you enter the branch, and let the render loop hit an already-resolved promise:
// reconLoad caches by src and returns a promise, so a second call is free
[...new Set(wantedViews.flatMap(v => (v.parts || []).map(x => x.mask)))]
.forEach(m => { reconLoad(m).catch(() => null); });
On the same session, request span for that batch went from 612 ms to 0.1 ms — proof it was real concurrency, not just faster.
Fix 5: yielding between angles
Five angles rendered back-to-back in one task held the main thread for 525 ms straight. Nobody can scroll during that. One line between iterations:
views.push(await renderPartWarpView(bases[i], source, mesh));
await new Promise(r => setTimeout(r, 0)); // let the frame paint
Longest task: 525 ms → 181 ms. Total wall time barely moved; interactivity changed completely.
Fix 6: draw one angle, then the rest
Originally: download all 5 base images and all masks, render all 5, then show anything. So the user stared at nothing until the last angle finished — 44 s on a cold cache.
Split into two passes:
updateReconstruction(version, true) // current angle only
.then(() => updateReconstruction(version, false)); // fill in the rest
The first pass touches one base image. The second pass reuses the same reconLoad promise cache, so nothing downloads twice.
One trap here: the empty-canvas early return in pass one means .then() still fires. Without a second guard, pass two immediately starts pulling the full 9.6 MB batch on first paint — exactly the thing the early return was supposed to prevent.
if (!state.layers.length) return;
The optimization that made it worse
Encouraged by fix 4, I tried prefetching everything — all masks for all angles — in parallel on first paint.
All angles went from 13 s to 21 s. The full prefetch competed with the first-pass downloads for the same bandwidth, and pushed several requests past the load timeout, which then triggered my retry path and made the queue longer.
Reverting it was the right call, and the general lesson is worth stating plainly: parallelism helps latency-bound work and hurts bandwidth-bound work. Masks are 41 KB each — latency bound, so parallel wins 9 s. Base images are 10.9 MB total — bandwidth bound, so parallel just reorders the same wait. Those two bottlenecks need different fixes and I had been treating "slow" as one problem.
Where it landed
| Before | After | |
|---|---|---|
| First preview, cold | 44 s | 2.2–2.9 s |
| All angles done | — | 3.6–6.5 s |
| Longest main-thread task | 525 ms | 181 ms |
| First-screen image bytes | 9,797 KB | 337 KB |
| Whole page transferred | 9,853 KB | 583 KB |
Measured across six models, each in a fresh browser. Not a lab number: that's the production build on the CDN.
The measuring mistake worth repeating
At one point my DevTools Protocol calls started timing out while inspecting the renderer, and I concluded the renderer was broken on one model. It wasn't. I had 16 MB of data URLs decoding on the main thread; Runtime.evaluate simply couldn't get a slot. A timing-out inspector is not evidence that the page is dead — it's evidence the main thread is busy. Read the DOM with one long wait and a single call instead of polling.
Part 2: after speed comes layout shift
With rendering fast, a second problem surfaced — one that is the same bug in three costumes.
CLS on the designer page was 0.80. Google's "poor" threshold starts at 0.25, so this was off the chart. The cause in every case: a container whose height is decided by content injected after first paint.
| Element | What it did | Fix |
|---|---|---|
| Step indicator | built by JS and prepended |
write the skeleton in HTML, JS only fills it |
| Toolbar | 34 px → 101 px when two buttons and a language selector were appended | same: static markup, JS binds handlers to existing nodes |
| Panel grid | 0 → 376 px, pushing everything below it | move the growing block to the end of the column, so growth pushes nothing |
| Scroll gutter | content overflowed, scrollbar appeared, inner width −16 px | scrollbar-gutter: stable |
0.80 → 0.038, and after the scrollbar fix on narrower viewports, 0.0164 on desktop and 0 at 390 px.
Two things I got wrong on the way:
A CLS of 0 measured once is not a fix. The product page returned 0 / 0.1311 / 0 / 0 across four consecutive runs of the same URL. Warm cache meant the JS won the race to first paint and nothing moved. The layout shift only appears when the network is slow enough — which is exactly the condition real visitors are in, and exactly the condition your warm cache test doesn't reproduce.
Local and production differ by an order of magnitude. The same viewport measured 0.0017 locally and 0.0342 in production. Locally the first paint is already the final state; in production it isn't. Any CLS number from file:// or localhost is a lower bound you can't ship on.
What I'd tell someone starting this
-
toDataURLis a serialization, not a thumbnail. If the display size is 41 px, hand it 256 px on purpose. - Attribute bytes before you attribute CPU. I spent a day reading flame charts for a problem that was 9.8 MB of images.
- Separate latency-bound from bandwidth-bound before parallelizing.
- Yield every few hundred milliseconds. Total time unchanged, perceived responsiveness transformed.
- Anything appended after first paint has a layout cost. If the container's height depends on it, either reserve the height or move it to the end of the flow.
The customizer is live at bukefootwear.com/designer if you want to watch it render, and the measurement that started all of this — all 66 panels, sampled and scaled — is written up at bukefootwear.com/guides/panel-magnification-measured. The panel-by-panel viewer is at bukefootwear.com/panel-preview.
Top comments (0)