<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bo Qian</title>
    <description>The latest articles on DEV Community by Bo Qian (@bukestudio).</description>
    <link>https://dev.to/bukestudio</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4200634%2Fe795f1a4-1144-44ac-b57f-a2211801bf5d.png</url>
      <title>DEV Community: Bo Qian</title>
      <link>https://dev.to/bukestudio</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bukestudio"/>
    <language>en</language>
    <item>
      <title>Nine files of headroom: what a 1,000-file deploy limit taught me about srcset and dead assets</title>
      <dc:creator>Bo Qian</dc:creator>
      <pubDate>Sat, 24 Oct 2026 14:39:35 +0000</pubDate>
      <link>https://dev.to/bukestudio/nine-files-of-headroom-what-a-1000-file-deploy-limit-taught-me-about-srcset-and-dead-assets-56lm</link>
      <guid>https://dev.to/bukestudio/nine-files-of-headroom-what-a-1000-file-deploy-limit-taught-me-about-srcset-and-dead-assets-56lm</guid>
      <description>&lt;p&gt;I build a browser-side customizer for printed sneakers. You upload artwork, it gets sliced across 66 leather panels, and you see it on the actual shoe: &lt;a href="https://bukefootwear.com/" rel="noopener noreferrer"&gt;bukefootwear.com&lt;/a&gt;. The geometry is covered in &lt;a href="https://dev.to/bukestudio/one-image-66-shoe-panels-the-geometry-and-four-silent-bugs-behind-a-canvas-cutting-pipeline-4490"&gt;the first post&lt;/a&gt;, the magnification math in &lt;a href="https://dev.to/bukestudio/i-measured-how-much-66-shoe-panels-magnify-one-upload-and-the-number-changed-our-file-requirements-g0f"&gt;the second&lt;/a&gt;, the 44-second render in &lt;a href="https://dev.to/bukestudio/44-seconds-to-render-a-shoe-six-fixes-and-the-biggest-one-was-a-single-todataurl-call-3b1i"&gt;the third&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This one is about the part nobody writes about: the deploy.&lt;/p&gt;

&lt;p&gt;Our release is a folder of static files pushed to Cloudflare Pages through a direct upload. That path caps a deployment at &lt;strong&gt;1,000 files&lt;/strong&gt;, and it fails at upload time, not build time. We found out where we stood by counting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;find cloudflare-release &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
1068
&lt;span class="nv"&gt;$ &lt;/span&gt;find cloudflare-release/assets &lt;span class="nt"&gt;-type&lt;/span&gt; f | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
991
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;991 assets, 9 files of headroom, and a product roadmap that wanted to add more. That is not a performance problem. It is a &lt;strong&gt;packaging&lt;/strong&gt; problem, and it forced a question I had been avoiding: how much of what we ship is never requested?&lt;/p&gt;

&lt;p&gt;The answer was "more than half," and the two traps I hit while removing it are what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the files come from
&lt;/h2&gt;

&lt;p&gt;The customizer needs factory photos and alpha masks. Per model: 5 camera angles, one base photo each, and 29 panel masks per angle. Multiply by 8 models and you get a few thousand candidate images before anyone writes a line of CSS.&lt;/p&gt;

&lt;p&gt;Most of that mass is not the 66 panel textures. It is higher-resolution originals, validation renders, calibration frames, and three generations of mask sets that were superseded but never deleted. Deleting them by hand is how you break a page you forgot about — nobody remembers which calibration image is still referenced from a comparison view.&lt;/p&gt;

&lt;p&gt;So instead of guessing, measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scanner: keep only what is reachable
&lt;/h2&gt;

&lt;p&gt;Our pages are static HTML plus a handful of JS and CSS files, and all local assets live under &lt;code&gt;assets/&lt;/code&gt;. That is enough to define reachability: walk every &lt;code&gt;.html&lt;/code&gt;, &lt;code&gt;.js&lt;/code&gt;, &lt;code&gt;.css&lt;/code&gt; file, find anything that looks like an &lt;code&gt;assets/...&lt;/code&gt; path, and stat it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sourceFiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;walkFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relativePosix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;extname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assetRoot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sourceFile&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;sourceFiles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/assets&lt;/span&gt;&lt;span class="se"&gt;\/[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_.&lt;/span&gt;&lt;span class="se"&gt;/&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;?#&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;assetRoot&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assetRoot&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;details&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFile&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;child&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;walkFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(?:&lt;/span&gt;&lt;span class="sr"&gt;png|jpe&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;g|webp|svg|ico|woff2&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Referenced asset is missing: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assetFile&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;walkFiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assetRoot&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assetFile&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assetFile&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter more than the code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Directories are kept whole.&lt;/strong&gt; If a reference resolves to a directory, everything inside it survives. That is deliberate: some assets are loaded by naming convention (&lt;code&gt;angle-3.webp&lt;/code&gt;) inside a loop, and I would rather keep an unused folder than ship a broken angle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A missing reference is a build failure.&lt;/strong&gt; The &lt;code&gt;catch&lt;/code&gt; branch does not log a warning; it pushes an error and fails the build. A silently 404ing &lt;code&gt;webp&lt;/code&gt; is exactly the class of bug that reaches production, because nobody opens the network tab looking for the image that &lt;em&gt;didn't&lt;/em&gt; load.&lt;/p&gt;

&lt;p&gt;The first run removed 300+ files and dropped the release to a size we could keep shipping. It also produced a list of every orphan, which is a much better code review artifact than "I deleted some stuff."&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: dynamic paths are invisible to a text scanner
&lt;/h2&gt;

&lt;p&gt;The scanner's whole premise is that paths appear as literal text. Ours didn't, in one place.&lt;/p&gt;

&lt;p&gt;The product page has a thumbnail strip where each 76px tile should load a small image, not the full-size one. The original code built the path at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/gallery/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/gallery/thumb/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.jpg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is correct at runtime and completely invisible to &lt;code&gt;matchAll&lt;/code&gt;. The scanner sees no &lt;code&gt;assets/factory/XB2/gallery/thumb/01.jpg&lt;/code&gt; anywhere, deletes every thumb file, and the strip starts requesting 404s. The page still renders. The build still passes. You only find out from a console full of errors, or from a customer.&lt;/p&gt;

&lt;p&gt;The fix is to make the paths literal. The build step now generates a map as plain strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// generated, do not hand-edit&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GALLERY_STRIP_THUMBS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assets/factory/DF1/gallery/01.webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assets/factory/DF1/gallery/thumb/01.jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...one line per tile&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the runtime does a lookup with a fallback, so a missing entry degrades to the full image instead of a broken tile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thumbOf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;GALLERY_STRIP_THUMBS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;GALLERY_STRIP_THUMBS_ALT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general rule I took away: &lt;strong&gt;any asset path you construct at runtime is an asset the packager cannot see.&lt;/strong&gt; Either resolve it through a generated literal manifest, or accept that your deploy step will delete it at some point. There is no third option, because "remember to add it to the exclusion list" is not a mechanism.&lt;/p&gt;

&lt;p&gt;I also added a second guard, a stale-reference check: every key in the manifest must exist on disk, every value must be a real file, and the build fails otherwise. That catches the other direction — a manifest that grows out of sync after a directory gets regenerated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the srcset that saved zero bytes
&lt;/h2&gt;

&lt;p&gt;With the file count under control, I went after bytes. The gallery page was shipping 876 KB of images on a mid-range Android at DPR 3. The cards are 260 CSS px wide on mobile, so I generated a 720px variant and added it to the &lt;code&gt;srcset&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"assets/featured/champagne-weave/thumb.webp 560w,
             assets/featured/champagne-weave/campaign-720.webp 720w,
             assets/featured/champagne-weave/campaign.webp 1100w"&lt;/span&gt;
     &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width:760px) 260px, 545px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Saved bytes: &lt;strong&gt;zero&lt;/strong&gt;. The page still pulled the 1100w file on mobile. Here's why.&lt;/p&gt;

&lt;p&gt;A card is 260 CSS px. At DPR 3 that is &lt;strong&gt;780 device pixels&lt;/strong&gt;. The candidates are 560w, 720w, 1100w. 720 is not enough for 780, so the browser correctly picks 1100. I had added a variant that was strictly worse than an existing one: bigger than 560, still too small.&lt;/p&gt;

&lt;p&gt;The rule is a single line of arithmetic, and it is not optional:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;needed_px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;css_px&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;devicePixelRatio&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the hero image at 337 CSS px, DPR 3 needs 1,011 — so a 720w candidate is dead weight there too, and the browser jumps to 1400w. For the card at 260 CSS px you need 780, so the useful rungs are ~560 (DPR 2) and ~800 (DPR 3).&lt;/p&gt;

&lt;p&gt;Once I sized candidates against &lt;code&gt;css_px × DPR&lt;/code&gt; instead of picking round numbers, the gallery went from &lt;strong&gt;876 KB to 507 KB (−42%)&lt;/strong&gt; on mobile, with the LCP image itself dropping from 283 KB to 176 KB. Same layout, same markup, no change to how it looks. The only difference is that every candidate became reachable by some real device.&lt;/p&gt;

&lt;p&gt;Two things get confused here, so to be explicit: &lt;code&gt;sizes&lt;/code&gt; describes the &lt;strong&gt;layout&lt;/strong&gt; width, and the browser multiplies it by DPR internally. If your &lt;code&gt;sizes&lt;/code&gt; lies — says 545px when the container is actually 337 — every downstream decision inherits that lie, because the chosen candidate is computed from it.&lt;/p&gt;

&lt;p&gt;The product page had the same disease with worse symptoms: eight product photo sets at 377–1159 KB each. They are now 196–356 KB (−86% on the worst one), and the 76px thumbnail grid finally loads thumbnail-sized files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: layout shift is a packaging problem too
&lt;/h2&gt;

&lt;p&gt;The same release process exposed a third issue that I don't think is separate from the other two.&lt;/p&gt;

&lt;p&gt;The customizer page had a CLS of 0.80 on desktop. The cause was mundane: a grid of cut panels that started empty in HTML and got appended to by JavaScript, one panel at a time. The container grew by 376px after first paint and pushed everything below it down.&lt;/p&gt;

&lt;p&gt;The fix was not a CSS trick, it was &lt;strong&gt;ordering&lt;/strong&gt;: move the grid into a section that already exists in the static markup, at the end of its column, so growth happens below the fold instead of above it. Desktop CLS went 0.80 → 0.016, and at 390px wide it is now 0.&lt;/p&gt;

&lt;p&gt;One caution if you go measuring this: CLS is flaky in ways that will make you chase ghosts. The same page, same URL, four consecutive runs gave me 0 / 0.1311 / 0 / 0 — with a warm cache, the JS wins the race against first paint and there is nothing to shift. A single run is not a measurement. And measure production, not localhost: identical code gave 0.0017 locally and 0.0342 on the CDN, because locally the first paint &lt;em&gt;is&lt;/em&gt; the final state and online it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;If you ship a static site with a lot of images, these are the four rules I now apply before every deploy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count files, not just bytes.&lt;/strong&gt; We fail the build over 1,000. A file budget is as real as a byte budget, and it is the one that stops the deploy outright.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive the release from references, and fail on missing ones.&lt;/strong&gt; Deleting unreferenced assets is safe; deleting assets whose references you couldn't parse is how you ship 404s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No runtime-constructed paths.&lt;/strong&gt; Generate a literal manifest, or the packager will eventually eat your files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size every srcset candidate against &lt;code&gt;css_px × DPR&lt;/code&gt;.&lt;/strong&gt; Otherwise you are shipping variants no device can ever select.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is clever. It is just the difference between a build that tells you when it is wrong and one that quietly ships.&lt;/p&gt;

&lt;p&gt;We are at 991 files with 9 to spare, which means the next thing I build has to justify its files before it justifies its features. If you want to see what all this packaging is protecting, the thing itself is at &lt;a href="https://bukefootwear.com/" rel="noopener noreferrer"&gt;bukefootwear.com&lt;/a&gt; — upload a drawing and watch it land on a shoe.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
      <category>css</category>
    </item>
    <item>
      <title>44 seconds to render a shoe: six fixes, and the biggest one was a single toDataURL call</title>
      <dc:creator>Bo Qian</dc:creator>
      <pubDate>Sat, 24 Oct 2026 13:39:29 +0000</pubDate>
      <link>https://dev.to/bukestudio/44-seconds-to-render-a-shoe-six-fixes-and-the-biggest-one-was-a-single-todataurl-call-3b1i</link>
      <guid>https://dev.to/bukestudio/44-seconds-to-render-a-shoe-six-fixes-and-the-biggest-one-was-a-single-todataurl-call-3b1i</guid>
      <description>&lt;p&gt;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 &lt;a href="https://dev.to/bukestudio/one-image-66-shoe-panels-the-geometry-and-four-silent-bugs-behind-a-canvas-cutting-pipeline-4490"&gt;the first post&lt;/a&gt; and &lt;a href="https://dev.to/bukestudio/i-measured-how-much-66-shoe-panels-magnify-one-upload-and-the-number-changed-our-file-requirements-g0f"&gt;the second&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is about the other half: it worked, and it was unusably slow. Cold load, first preview: &lt;strong&gt;44 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, briefly
&lt;/h2&gt;

&lt;p&gt;For each of 5 shoe angles, the browser:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;downloads a 1800×1800 factory photo of the shoe from that angle,&lt;/li&gt;
&lt;li&gt;downloads one alpha mask per panel for that angle (29 of them on one model),&lt;/li&gt;
&lt;li&gt;for each panel, inverse-bilinear-maps every masked pixel back into the uploaded artwork, samples it, and multiplies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: the angle strip was decoding 16 MB of images nobody could see
&lt;/h2&gt;

&lt;p&gt;Each angle thumbnail is displayed at &lt;strong&gt;41 × 43 px&lt;/strong&gt;. The code that produced it looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;viewCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// viewCanvas is 1800 × 1800&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 1800² PNG of a shoe photo is about &lt;strong&gt;5.6 MB&lt;/strong&gt; as base64. Five angles: &lt;strong&gt;16 MB of data URLs&lt;/strong&gt;, each one an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; the layout had already reserved 41 px for. The browser dutifully decoded all of them at full resolution, on the main thread.&lt;/p&gt;

&lt;p&gt;This is the trap with &lt;code&gt;toDataURL&lt;/code&gt;: 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.&lt;/p&gt;

&lt;p&gt;Replacing it with a 256 px JPEG on a white background:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;thumbUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;side&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;side&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;side&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// JPEG has no alpha; flatten first or edges go grey&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fillRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;srcCanvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.86&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5.6 MB → 15 KB per angle.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: the source images were PNG
&lt;/h2&gt;

&lt;p&gt;Same photos, no alpha needed, 1800 px long edge: &lt;strong&gt;51.4 MB → 3.0 MB&lt;/strong&gt; as WebP, visually indistinguishable at q84. The old PNGs stayed on disk but nothing referenced them anymore, so the deploy step dropped them.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: stop downloading base images you can't draw on
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasMesh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meshViews&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;meshViews&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;meshViews&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines of guard. On one model it removed a 5 MB download from the critical path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: 29 masks awaited one after another
&lt;/h2&gt;

&lt;p&gt;The render loop did this, inside a &lt;code&gt;for&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;reconLoad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 29 iterations&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// reconLoad caches by src and returns a promise, so a second call is free&lt;/span&gt;
&lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wantedViews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;)))]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;reconLoad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the same session, request span for that batch went from &lt;strong&gt;612 ms to 0.1 ms&lt;/strong&gt; — proof it was real concurrency, not just faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 5: yielding between angles
&lt;/h2&gt;

&lt;p&gt;Five angles rendered back-to-back in one task held the main thread for &lt;strong&gt;525 ms&lt;/strong&gt; straight. Nobody can scroll during that. One line between iterations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderPartWarpView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bases&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mesh&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// let the frame paint&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Longest task: 525 ms → 181 ms.&lt;/strong&gt; Total wall time barely moved; interactivity changed completely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 6: draw one angle, then the rest
&lt;/h2&gt;

&lt;p&gt;Originally: download all 5 base images and all masks, render all 5, then show anything. So the user stared at nothing until the &lt;em&gt;last&lt;/em&gt; angle finished — 44 s on a cold cache.&lt;/p&gt;

&lt;p&gt;Split into two passes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;updateReconstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// current angle only&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;updateReconstruction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// fill in the rest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first pass touches one base image. The second pass reuses the same &lt;code&gt;reconLoad&lt;/code&gt; promise cache, so nothing downloads twice.&lt;/p&gt;

&lt;p&gt;One trap here: the empty-canvas early return in pass one means &lt;code&gt;.then()&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The optimization that made it worse
&lt;/h2&gt;

&lt;p&gt;Encouraged by fix 4, I tried prefetching &lt;em&gt;everything&lt;/em&gt; — all masks for all angles — in parallel on first paint.&lt;/p&gt;

&lt;p&gt;All angles went from &lt;strong&gt;13 s to 21 s&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;Reverting it was the right call, and the general lesson is worth stating plainly: &lt;strong&gt;parallelism helps latency-bound work and hurts bandwidth-bound work.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First preview, cold&lt;/td&gt;
&lt;td&gt;44 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.2–2.9 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All angles done&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.6–6.5 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Longest main-thread task&lt;/td&gt;
&lt;td&gt;525 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;181 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First-screen image bytes&lt;/td&gt;
&lt;td&gt;9,797 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;337 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whole page transferred&lt;/td&gt;
&lt;td&gt;9,853 KB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;583 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Measured across six models, each in a fresh browser. Not a lab number: that's the production build on the CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  The measuring mistake worth repeating
&lt;/h2&gt;

&lt;p&gt;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; &lt;code&gt;Runtime.evaluate&lt;/code&gt; simply couldn't get a slot. &lt;strong&gt;A timing-out inspector is not evidence that the page is dead&lt;/strong&gt; — it's evidence the main thread is busy. Read the DOM with one long wait and a single call instead of polling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: after speed comes layout shift
&lt;/h2&gt;

&lt;p&gt;With rendering fast, a second problem surfaced — one that is the same bug in three costumes.&lt;/p&gt;

&lt;p&gt;CLS on the designer page was &lt;strong&gt;0.80&lt;/strong&gt;. Google's "poor" threshold starts at 0.25, so this was off the chart. The cause in every case: &lt;strong&gt;a container whose height is decided by content injected after first paint.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;What it did&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Step indicator&lt;/td&gt;
&lt;td&gt;built by JS and &lt;code&gt;prepend&lt;/code&gt;ed&lt;/td&gt;
&lt;td&gt;write the skeleton in HTML, JS only fills it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Toolbar&lt;/td&gt;
&lt;td&gt;34 px → &lt;strong&gt;101 px&lt;/strong&gt; when two buttons and a language selector were appended&lt;/td&gt;
&lt;td&gt;same: static markup, JS binds handlers to existing nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Panel grid&lt;/td&gt;
&lt;td&gt;0 → &lt;strong&gt;376 px&lt;/strong&gt;, pushing everything below it&lt;/td&gt;
&lt;td&gt;move the growing block to the &lt;em&gt;end&lt;/em&gt; of the column, so growth pushes nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scroll gutter&lt;/td&gt;
&lt;td&gt;content overflowed, scrollbar appeared, inner width −16 px&lt;/td&gt;
&lt;td&gt;&lt;code&gt;scrollbar-gutter: stable&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;0.80 → 0.038&lt;/strong&gt;, and after the scrollbar fix on narrower viewports, &lt;strong&gt;0.0164&lt;/strong&gt; on desktop and &lt;strong&gt;0&lt;/strong&gt; at 390 px.&lt;/p&gt;

&lt;p&gt;Two things I got wrong on the way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A CLS of 0 measured once is not a fix.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local and production differ by an order of magnitude.&lt;/strong&gt; 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 &lt;code&gt;file://&lt;/code&gt; or localhost is a lower bound you can't ship on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;toDataURL&lt;/code&gt; is a serialization, not a thumbnail.&lt;/strong&gt; If the display size is 41 px, hand it 256 px on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribute bytes before you attribute CPU.&lt;/strong&gt; I spent a day reading flame charts for a problem that was 9.8 MB of images.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate latency-bound from bandwidth-bound before parallelizing.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yield every few hundred milliseconds.&lt;/strong&gt; Total time unchanged, perceived responsiveness transformed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything appended after first paint has a layout cost.&lt;/strong&gt; If the container's height depends on it, either reserve the height or move it to the end of the flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The customizer is live at &lt;a href="https://bukefootwear.com/designer" rel="noopener noreferrer"&gt;bukefootwear.com/designer&lt;/a&gt; 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 &lt;a href="https://bukefootwear.com/guides/panel-magnification-measured" rel="noopener noreferrer"&gt;bukefootwear.com/guides/panel-magnification-measured&lt;/a&gt;. The panel-by-panel viewer is at &lt;a href="https://bukefootwear.com/panel-preview" rel="noopener noreferrer"&gt;bukefootwear.com/panel-preview&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>canvas</category>
      <category>performance</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I measured how much 66 shoe panels magnify one upload - and the number changed our file requirements</title>
      <dc:creator>Bo Qian</dc:creator>
      <pubDate>Thu, 22 Oct 2026 14:45:21 +0000</pubDate>
      <link>https://dev.to/bukestudio/i-measured-how-much-66-shoe-panels-magnify-one-upload-and-the-number-changed-our-file-requirements-g0f</link>
      <guid>https://dev.to/bukestudio/i-measured-how-much-66-shoe-panels-magnify-one-upload-and-the-number-changed-our-file-requirements-g0f</guid>
      <description>&lt;p&gt;People send us a 1200px PNG, it looks crisp in the browser preview, and then the printed shoe comes back with a slightly soft edge on one panel. Not all panels. One.&lt;/p&gt;

&lt;p&gt;That asymmetry is the interesting part. A sneaker upper is cut into flat panels — 2 on our slipper, 4 on the blade pattern, 10 on the everyday sneaker, 14 and 18 on the two low-tops. &lt;strong&gt;66 panels across 8 styles.&lt;/strong&gt; Each panel takes a &lt;em&gt;different region&lt;/em&gt; of the uploaded artwork, so each panel magnifies that upload by a different factor.&lt;/p&gt;

&lt;p&gt;If you assume "the image is big enough", you are assuming it for the panel you happened to look at.&lt;/p&gt;

&lt;p&gt;So I stopped guessing and measured all 66.&lt;/p&gt;

&lt;h2&gt;
  
  
  The geometry that makes the numbers different
&lt;/h2&gt;

&lt;p&gt;Each panel in our data file is four numbers plus a mask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assets/panel-masks/DF2/piece-04-mask.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;702&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;752&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                  &lt;span class="c1"&gt;// production resolution&lt;/span&gt;
  &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;197.83&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;573.11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;146.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;216.91&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// region of the artwork it samples&lt;/span&gt;
  &lt;span class="nx"&gt;mirror&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;w&lt;/code&gt;/&lt;code&gt;h&lt;/code&gt; is how many pixels the printer wants for that panel. &lt;code&gt;sb&lt;/code&gt; is the rectangle the panel reads from the user's artwork, expressed in canvas units (the canvas for that style — 611×800 for the low-top, for example).&lt;/p&gt;

&lt;p&gt;The pipeline is: scale the artwork to cover the canvas, then for each panel crop &lt;code&gt;sb&lt;/code&gt;, stretch it to &lt;code&gt;w × h&lt;/code&gt;, and mask it to the panel outline. Which means:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;magnification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For that DF2 panel, &lt;code&gt;702 / 146.7 = 4.79&lt;/code&gt;. That single panel needs roughly five times the pixel density of the artwork compared to a panel that happens to sample a wide region.&lt;/p&gt;

&lt;h2&gt;
  
  
  The distribution across 66 panels
&lt;/h2&gt;

&lt;p&gt;I computed &lt;code&gt;w / sb.w&lt;/code&gt; for every panel (65 valid entries; one panel in XB2 has a dirty &lt;code&gt;sb&lt;/code&gt; of &lt;code&gt;1e22&lt;/code&gt; and is skipped in the tool):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;statistic&lt;/th&gt;
&lt;th&gt;magnification&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;min&lt;/td&gt;
&lt;td&gt;2.40×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;median&lt;/td&gt;
&lt;td&gt;2.91×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;4.79×&lt;/strong&gt; (DF2 panel 4: 146.7×216.91 → 702×752)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nothing under 2.4×. So "the artwork is roughly panel-sized" is never true — every single panel is upscaling by at least 2.4×, and the worst one by almost 5×.&lt;/p&gt;

&lt;p&gt;That number alone doesn't tell you what to &lt;em&gt;ask users for&lt;/em&gt;, because magnification is relative to canvas units. Inverting it does: for a given upload width, how many panels can be rendered at 1:1 or better?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// required artwork width for a 1:1 render, per panel&lt;/span&gt;
&lt;span class="c1"&gt;// the binding axis is the larger of the two ratios, not just the width&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Math.max&lt;/code&gt; is not cosmetic. A tall narrow panel — a heel strip, an eyestay — can be comfortably satisfied on the horizontal axis and still be short of pixels vertically, so measuring only &lt;code&gt;panel.w / panel.sb.w&lt;/code&gt; overstates coverage at small upload sizes. With both axes in play, across all 65 panels:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;upload width (px)&lt;/th&gt;
&lt;th&gt;panels rendered at 1:1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1400&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1600&lt;/td&gt;
&lt;td&gt;29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1800&lt;/td&gt;
&lt;td&gt;54%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2000&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;78%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2400&lt;/td&gt;
&lt;td&gt;98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3000&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Median requirement: &lt;strong&gt;1673px&lt;/strong&gt;. 90th percentile: &lt;strong&gt;2267px&lt;/strong&gt;. Worst case: &lt;strong&gt;2916px&lt;/strong&gt; (KJ1BM, panel 6).&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed because of this
&lt;/h2&gt;

&lt;p&gt;Before: "upload a high resolution image, at least 1000px".&lt;/p&gt;

&lt;p&gt;After: &lt;strong&gt;short side ≥ 2000px&lt;/strong&gt;, and we tell people 2400px if they want every panel sharp.&lt;/p&gt;

&lt;p&gt;2000px is the point where four out of five panels render at native resolution and the remaining ones are upscaled by 1.45× at most — which survives printing because output is leather at ~300 DPI equivalent, not a 4K display. 3000px is where the requirement stops growing; above that you are paying upload time for nothing.&lt;/p&gt;

&lt;p&gt;Two things I did &lt;em&gt;not&lt;/em&gt; do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I did not raise it to 3000px.&lt;/strong&gt; The last 2% of panels cost every user a slower upload and a bigger file, for a difference nobody can see on a curved panel under stitching. 2000px is the practical knee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I did not silently upscale in the pipeline.&lt;/strong&gt; A blurry 900px image that we quietly resample to 2900px looks &lt;em&gt;worse&lt;/em&gt; than an honest soft render, because interpolation adds ringing around edges before the print does its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The two silent failures that cost more than resolution
&lt;/h2&gt;

&lt;p&gt;Resolution problems are visible. These are not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Aspect ratio drift.&lt;/strong&gt; The artwork is stretched to cover the canvas. If the uploaded image's aspect ratio differs from the canvas by 3%, nobody notices on screen — but the distortion is applied &lt;em&gt;before&lt;/em&gt; the panel crop, so all 66 panels inherit it, and the seam between two adjacent panels stops lining up. Hard requirement: artwork ≥ canvas &lt;strong&gt;and&lt;/strong&gt; the same aspect ratio, otherwise the pipeline stretches it and does not complain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Multiplying by a dead alpha channel.&lt;/strong&gt; This one is specific to canvas: if a mask PNG's visible content lives in RGB with &lt;code&gt;alpha = 0&lt;/code&gt; (which is what a designer's visual mockup export looks like), &lt;code&gt;drawImage&lt;/code&gt; composites with premultiplied alpha and the RGB is multiplied by zero. You get a fully transparent draw. No exception, no console warning — the panel just renders empty. We hit it on 7 of 8 styles and now rebuild masks offline rather than trusting exports.&lt;/p&gt;

&lt;p&gt;Both of these are "check your inputs" problems that no amount of extra resolution fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A tool so users can check before ordering
&lt;/h2&gt;

&lt;p&gt;Measurement is only useful if it reaches the person choosing the file. So the same geometry now runs client-side as a panel preview: upload an image, see the crop for every panel, in the browser, nothing uploaded to a server.&lt;/p&gt;

&lt;p&gt;That is also how I found the dirty &lt;code&gt;1e22&lt;/code&gt; sample box — a preview that draws 65 of 66 panels correctly is a better test suite than any assertion I would have written.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;"High resolution" is not a spec. Every multi-panel print has a &lt;em&gt;distribution&lt;/em&gt; of magnifications, and the useful question is not "is the image big enough" but "for which panel is it not".&lt;/p&gt;

&lt;p&gt;If you are building anything that maps one image onto many physical pieces — shoes, garments, packaging, tile — spend an afternoon computing &lt;code&gt;outputPixels / sampledRegion&lt;/code&gt; for every piece. In our case it turned a hand-wavy guideline into a number (2000px) that a support person can state and a user can check.&lt;/p&gt;

&lt;p&gt;The full per-style table and the method behind it is published here: &lt;a href="https://bukefootwear.com/guides/panel-magnification-measured" rel="noopener noreferrer"&gt;how much a shoe panel enlarges your artwork&lt;/a&gt;. The geometry write-up, including the four pipeline bugs that failed silently, is &lt;a href="https://bukefootwear.com/guides/artwork-across-shoe-panels" rel="noopener noreferrer"&gt;mapping one image across 66 shoe panels&lt;/a&gt;. And the panel-by-panel preview tool is at &lt;a href="https://bukefootwear.com/panel-preview" rel="noopener noreferrer"&gt;bukefootwear.com/panel-preview&lt;/a&gt; if you want to see the crops for our 8 styles.&lt;/p&gt;

</description>
      <category>canvas</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>One image, 66 shoe panels: the geometry (and four silent bugs) behind a canvas cutting pipeline</title>
      <dc:creator>Bo Qian</dc:creator>
      <pubDate>Thu, 22 Oct 2026 14:06:07 +0000</pubDate>
      <link>https://dev.to/bukestudio/one-image-66-shoe-panels-the-geometry-and-four-silent-bugs-behind-a-canvas-cutting-pipeline-4490</link>
      <guid>https://dev.to/bukestudio/one-image-66-shoe-panels-the-geometry-and-four-silent-bugs-behind-a-canvas-cutting-pipeline-4490</guid>
      <description>&lt;p&gt;I make custom printed sneakers. People upload a picture, we print it onto the leather panels of a shoe, and somewhere between those two steps the picture stops being a picture and becomes a set of manufacturing instructions.&lt;/p&gt;

&lt;p&gt;That middle step is a browser-side canvas pipeline, and it is the least glamorous and most bug-prone code I have written. This post is about the geometry it has to solve and the four bugs that took me longest to find — all of which failed silently, which is the worst kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem in one sentence
&lt;/h2&gt;

&lt;p&gt;A sneaker upper is not one surface. It is a set of flat panels cut from a hide, printed, then sewn together — 2 panels on our slipper, 4 on the blade pattern, 10 on the everyday sneaker, 14 and 18 on the two low-tops. Sixty-six panels across eight styles.&lt;/p&gt;

&lt;p&gt;So "print this image on a shoe" decomposes into: for each panel, take a rectangular region of the user's artwork, scale it to that panel's production resolution, mask it to the panel's actual outline, and flip it if the pattern piece is mirrored.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;Every panel is four numbers and two files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;sampleBox&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;112.24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;29.89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;440.06&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;455.83&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// where to read from the artwork canvas&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1342&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1106&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// production raster for this panel&lt;/span&gt;
  &lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;piece-01-mask.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// the panel outline, as an alpha mask&lt;/span&gt;
  &lt;span class="nx"&gt;mirrored&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The artwork itself sits on a logical canvas — 611 × 800 for the low-top, roughly 3:4. Coordinates in &lt;code&gt;sampleBox&lt;/code&gt; are in that space, so the same layout can be reprinted at any physical scale without touching the data.&lt;/p&gt;

&lt;p&gt;The cutting step is then genuinely short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OffscreenCanvas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cut&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mirrored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;artwork&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTransform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalCompositeOperation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;destination-in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight lines. It took a week to make those eight lines trust each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: the masks looked like masks but were not
&lt;/h2&gt;

&lt;p&gt;The panel outlines came from the pattern department as PNGs. Some of them were real alpha masks — opaque inside the panel, transparent outside. Others were exported from a design file: white shape, grey backdrop, red construction lines.&lt;/p&gt;

&lt;p&gt;The second kind looks correct in any image viewer. It is not correct for canvas.&lt;/p&gt;

&lt;p&gt;Canvas compositing is &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas" rel="noopener noreferrer"&gt;premultiplied&lt;/a&gt;. An RGBA pixel of &lt;code&gt;(255, 255, 255, 0)&lt;/code&gt; is stored as &lt;code&gt;(0, 0, 0, 0)&lt;/code&gt; — the colour is discarded the moment alpha hits zero. So a "white shape on transparent" export, where the white actually carries &lt;code&gt;alpha = 0&lt;/code&gt;, arrives as pure transparent black. &lt;code&gt;drawImage&lt;/code&gt; with &lt;code&gt;destination-in&lt;/code&gt; then erases everything. No exception, no warning, just an empty canvas.&lt;/p&gt;

&lt;p&gt;I tried to patch it at runtime: read the pixels, find the bright ones, rebuild alpha from luminance. It never worked, because by then the RGB values were already gone. The information was destroyed at export time, not at draw time.&lt;/p&gt;

&lt;p&gt;The fix was to stop patching at runtime and rebuild the masks offline, once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// per panel, decide which encoding it actually uses&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasBlackCover&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;blackPixels&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// a grey overlay layer, alpha ~153, luminance &amp;lt; 70&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasBlackCover&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isBlackCover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// the cover marks what is *not* the panel&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;               &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// honest alpha mask&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;66 masks rebuilt as binary alpha, 658 KB total — smaller than the originals, and now unambiguous. The runtime code went from ~40 lines of guessing to a validity check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if a shape has to survive a compositing step, the shape has to be in alpha before it reaches the canvas. Luminance is not a mask.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: garbage coordinates fail silently
&lt;/h2&gt;

&lt;p&gt;One panel's &lt;code&gt;sampleBox&lt;/code&gt; came out of the geometry export as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.18&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.64&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;4.81&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;drawImage&lt;/code&gt; with those numbers throws nothing. It draws nothing. There is no error to catch, no &lt;code&gt;onerror&lt;/code&gt;, no rejected promise — the panel just renders blank and you assume your mask is wrong.&lt;/p&gt;

&lt;p&gt;I spent most of an afternoon on the mask before printing the source numbers.&lt;/p&gt;

&lt;p&gt;The guard is now a magnitude check rather than a sign check, because negative coordinates are legitimate — a panel can sample from outside the artwork canvas and let canvas clamp it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sane&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;mw&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;mh&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
  &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;mw&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;mh&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; validate geometry against the canvas bounds it is supposed to live in, not against "looks reasonable".&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug three: &lt;code&gt;revokeObjectURL&lt;/code&gt; kills the image you are still drawing
&lt;/h2&gt;

&lt;p&gt;The first version loaded uploads the obvious way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// "free the memory"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works exactly once. Redraw the panel grid — switch style, change the layout — and every &lt;code&gt;drawImage&lt;/code&gt; silently produces an empty canvas, because the blob URL backing the image is gone. The &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element still reports &lt;code&gt;naturalWidth &amp;gt; 0&lt;/code&gt;, which is what made it confusing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;createImageBitmap&lt;/code&gt; is the right tool here: it decodes once into an immutable bitmap that stays valid until you close it, and it is also faster to draw repeatedly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;userImage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
&lt;span class="nx"&gt;userImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bmp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; if a decoded image is going to be drawn more than once, own the bitmap, not a URL that points at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug four: fast clicks race the render loop
&lt;/h2&gt;

&lt;p&gt;Switching styles fires an async render. Switch twice quickly and the first render's results land after the second one's, painting the old style's panels into the new style's grid.&lt;/p&gt;

&lt;p&gt;The fix is a monotonic token checked at every await point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;renderSeq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mySeq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nx"&gt;renderSeq&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cell&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getMask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;panel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mySeq&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;renderSeq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// a newer render owns the canvas now&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unglamorous, and the only version that survives a user with a fast mouse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers look like
&lt;/h2&gt;

&lt;p&gt;The pipeline is not just slicing; it is upscaling, and that decides whether a user's file is good enough.&lt;/p&gt;

&lt;p&gt;To fill a 1342-pixel panel from a 440-unit window on a 611-unit canvas, the artwork needs to be about &lt;strong&gt;1,860 px wide&lt;/strong&gt; to arrive at full resolution with no interpolation. Across the library, the median enlargement is &lt;strong&gt;2.9×&lt;/strong&gt; and the worst case is &lt;strong&gt;4.8×&lt;/strong&gt; (the 360 × 566 window feeding a 1598 × 1958 panel).&lt;/p&gt;

&lt;p&gt;That is a real product constraint disguised as a geometry detail. Telling someone "your image needs to be 2,000 px on the short edge" is only credible if you can derive it from your own pipeline — which is exactly what the sampling windows give you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I shipped the tool instead of a diagram
&lt;/h2&gt;

&lt;p&gt;The pipeline runs entirely in the browser, so I exposed it: upload an image, pick a style, and see every panel of that style cut from your artwork at production size. No upload, no account, no server round trip — the same &lt;code&gt;destination-in&lt;/code&gt; compositing you just read about.&lt;/p&gt;

&lt;p&gt;It turned out to be a better explanation than any diagram I could draw, and it cost nothing extra to run: &lt;a href="https://bukefootwear.com/panel-preview" rel="noopener noreferrer"&gt;bukefootwear.com/panel-preview&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The written version, with all 66 panels and the per-style numbers, is at &lt;a href="https://bukefootwear.com/guides/artwork-across-shoe-panels" rel="noopener noreferrer"&gt;bukefootwear.com/guides/artwork-across-shoe-panels&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are doing anything with panel-based printing, garment mapping or texture atlases, I would like to hear how you handle seam continuity — that is the part I still consider an open problem rather than a solved one.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>canvas</category>
      <category>webdev</category>
      <category>graphics</category>
    </item>
  </channel>
</rss>
