<?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>Why your design looks fine on screen and broken on a shoe: three constraints, three consequences</title>
      <dc:creator>Bo Qian</dc:creator>
      <pubDate>Sun, 01 Nov 2026 04:28:39 +0000</pubDate>
      <link>https://dev.to/bukestudio/why-your-design-looks-fine-on-screen-and-broken-on-a-shoe-three-constraints-three-consequences-3ji6</link>
      <guid>https://dev.to/bukestudio/why-your-design-looks-fine-on-screen-and-broken-on-a-shoe-three-constraints-three-consequences-3ji6</guid>
      <description>&lt;p&gt;I build a browser-side customizer for printed sneakers. You upload artwork, it gets sliced across the shoe panels, and you preview it on the actual shoe before ordering: &lt;a href="https://bukefootwear.com/" rel="noopener noreferrer"&gt;bukefootwear.com&lt;/a&gt;. The slicing geometry is 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 render pipeline 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;, the deploy budget in &lt;a href="https://dev.to/bukestudio/nine-files-of-headroom-what-a-1000-file-deploy-limit-taught-me-about-srcset-and-dead-assets-56lm"&gt;the fourth&lt;/a&gt;, and the routerless storefront in &lt;a href="https://dev.to/bukestudio/eight-products-one-url-what-breaks-when-a-storefront-has-no-router-o9d"&gt;the fifth&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This one is different. It is not about a bug I fixed. It is about three physical properties of a shoe that decide what the software can and cannot promise, and the three places where ignoring them produced a page that looked correct and was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode is not ugly, it is plausible
&lt;/h2&gt;

&lt;p&gt;Every bad custom-print result I have seen looked acceptable on a laptop. That is the problem. When a canvas render is wrong in an obvious way — black output, a stretched image, a 404 texture — you fix it in a minute. When it is wrong in a plausible way, it ships.&lt;/p&gt;

&lt;p&gt;A design that loses four millimetres of its border at the seam still looks like a design. A motif that breaks across the toe cap still reads as a motif. A palette that shifts half a shade on a fabric substrate still reads as the same palette. You only find out after the pair is made.&lt;/p&gt;

&lt;p&gt;So the useful framing is not "how do I rasterise artwork onto a shoe". It is "which constraints can the browser check, and which ones can only the person looking at it check". Three constraints turned out to matter more than everything else I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constraint one: the surface is not developable
&lt;/h2&gt;

&lt;p&gt;A t-shirt is a flat rectangle. You can print it in one pass and the artwork behaves like a poster glued to cardboard. A shoe upper is a curved shell. There is no isometric mapping from a plane onto a doubly-curved surface that preserves both distances and angles — this is Gauss's &lt;em&gt;theorema egregium&lt;/em&gt;, and it is the reason a basketball cannot be wrapped in gift paper without creases.&lt;/p&gt;

&lt;p&gt;The engineering consequence is that you do not flatten the shoe. You decompose it. A pair of low-tops in our pipeline resolves into dozens of separate cut pieces, each with its own outline, its own safe area and its own transform back into shoe space. The user's single upload becomes dozens of independent renders.&lt;/p&gt;

&lt;p&gt;The consequence inside the code is that &lt;strong&gt;there is no single "the design" object after slicing&lt;/strong&gt;. There is an array. Anything that assumed one image, one bounding box, one transform is now wrong, and it fails quietly: each panel renders fine in isolation, and the composition is broken.&lt;/p&gt;

&lt;p&gt;If you are building anything similar, the data model has to be decided before the renderer. Ours looks roughly 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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;toe-cap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Path2D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;safeArea&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Path2D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;warp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Float32Array&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;warp&lt;/code&gt; is the part people skip. A panel outline tells you &lt;em&gt;where&lt;/em&gt; a piece is on the shoe. It does not tell you how the artwork bends to sit on it. Those are two different pieces of data and conflating them is the single most expensive mistake in this class of project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constraint two: the seam eats the edge
&lt;/h2&gt;

&lt;p&gt;Panels are joined by stitching, and stitching needs material to hold. Every panel therefore carries a seam allowance — a strip along its outline that will be folded under or consumed by the join. Artwork placed there is not printed, or is printed and then hidden.&lt;/p&gt;

&lt;p&gt;This converts a design problem into a clipping problem, and the clipping is where the silent failure lives: &lt;strong&gt;an out-of-bounds design gets cropped, and cropping is not an error&lt;/strong&gt;. A &lt;code&gt;drawImage&lt;/code&gt; call whose destination rectangle extends past the canvas does not throw, does not warn, and does not return a status. It draws what fits.&lt;/p&gt;

&lt;p&gt;I have written about the mask layer specifically elsewhere, but the general lesson is worth stating plainly: in a canvas pipeline, &lt;em&gt;geometry violations are not exceptions&lt;/em&gt;. The only way to catch them is to compare the intended area against the safe area yourself, before drawing, and to surface the result to the user as a number or a highlighted region — not as a console message nobody reads.&lt;/p&gt;

&lt;p&gt;The practical rule I ended up with: never let the renderer be the first thing that discovers a bounds problem. Validate the placement against the safe area on input, and treat the renderer as an optimiser for a decision already made. This is the same reasoning as validating a form on submit rather than trusting a database constraint to reject it later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constraint three: the substrate is not paper
&lt;/h2&gt;

&lt;p&gt;Paper holds ink on the surface. A shoe upper is a textile or a synthetic leather: it absorbs, it stretches over a last, and it moves with the foot. Three things follow, and none of them are visible in a canvas preview:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Colour shifts.&lt;/strong&gt; Ink on a woven or microfibre surface sits differently than ink on a coated paper. A mid-tone that reads neutral in sRGB can go warm or muddy on a warm-grey substrate. There is no browser API for "how will this CMYK-adjacent stack land on this material" — the material sample is the oracle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Edges soften.&lt;/strong&gt; Small type and one-pixel hairlines do not survive the transfer intact. This is not a resolution problem in the usual sense; it is a material problem. Our answer was to stop specifying DPI and start specifying pixels against a measured magnification factor, which is what &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 post&lt;/a&gt; covers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The preview can only be honest about the first two constraints.&lt;/strong&gt; A canvas render can show you cropping and composition truthfully. It cannot show you colour on material or edge softening. Any claim otherwise is a lie dressed up as a feature, so the preview needs to say what it does not model — and that statement belongs on the page, not in a FAQ.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the constraints cost in pixels
&lt;/h2&gt;

&lt;p&gt;Once the surface is non-developable and the pieces are small, magnification does the budgeting for you. Measured across panels, a single uploaded image is stretched by a factor between roughly 2.9× and 4.8× depending on where the panel sits. Working backwards from a target print resolution gives a hard floor on upload size rather than a preference: around 2,000 px lands near 78% of target, 2,400 px near 98%, and about 2,916 px clears it.&lt;/p&gt;

&lt;p&gt;That number is not interesting on its own. It is interesting because it is derivable, and because it can be shown to a user before they upload anything. "Minimum 2,900 pixels on the long edge" is a rule a person can act on. "High resolution required" is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The acceptance problem
&lt;/h2&gt;

&lt;p&gt;Put the three constraints together and you get a class of defect that no unit test catches: each panel is individually correct, the composition is wrong. The only reliable check is a human looking at the assembled result.&lt;/p&gt;

&lt;p&gt;So the thing I actually shipped for this is not a spec sheet, it is an acceptance surface. There is a page that renders one artwork across the panel set at real proportions, side by side with an assembled preview, so a person can see whether the pattern breaks at a seam or loses its border: &lt;a href="https://bukefootwear.com/panel-preview" rel="noopener noreferrer"&gt;panel-preview&lt;/a&gt;. It is the same data the customizer uses, presented so that the failure is visible instead of plausible.&lt;/p&gt;

&lt;p&gt;The written version of the same reasoning — why an eighteen-panel curved shell is a genuinely different problem from a printed t-shirt, and what that means for what you can promise a customer — is at &lt;a href="https://bukefootwear.com/guides/why-shoes-are-harder-than-shirts" rel="noopener noreferrer"&gt;why shoes are harder than shirts&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes about the build
&lt;/h2&gt;

&lt;p&gt;None of this is exotic graphics programming. It is constraint accounting. The three things I would tell anyone starting a project that maps flat artwork onto a curved, seamed, non-paper object:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Decide the panel data model before the renderer.&lt;/strong&gt; Outline, safe area and warp are three separate facts. Conflating them produces a renderer that cannot be fixed, only rewritten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat geometry violations as validation, not exceptions.&lt;/strong&gt; Canvas will happily crop your design out of existence and return success.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be explicit about what the preview does not model.&lt;/strong&gt; Colour on substrate and edge softening are outside the browser's knowledge. Say so.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The results, if you want to see whether the constraints are actually being respected rather than just described, are four prints on four different base shoes — a woven geometric, a botanical, a chain motif and a tonal Art Deco fan — each with the full seven-view set on its own page. They are collected at &lt;a href="https://bukefootwear.com/gallery" rel="noopener noreferrer"&gt;the featured designs gallery&lt;/a&gt;; &lt;a href="https://bukefootwear.com/design-onyx-deco" rel="noopener noreferrer"&gt;Onyx Deco&lt;/a&gt; is the clearest test of the third constraint, because a tonal print with almost no contrast is exactly the case where a colour shift stops being subtle.&lt;/p&gt;

&lt;p&gt;And if you want to test the whole pipeline against your own artwork, the customizer is the same one I have been describing: &lt;a href="https://bukefootwear.com/designer" rel="noopener noreferrer"&gt;design your own&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The numbers in this post come from measurements on our own panel set, not from a general model. If your panel count, seam allowance or substrate differ, the constants move — the constraints do not.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>design</category>
      <category>ux</category>
    </item>
    <item>
      <title>Eight products, one URL: what breaks when a storefront has no router</title>
      <dc:creator>Bo Qian</dc:creator>
      <pubDate>Sat, 31 Oct 2026 17:09:23 +0000</pubDate>
      <link>https://dev.to/bukestudio/eight-products-one-url-what-breaks-when-a-storefront-has-no-router-o9d</link>
      <guid>https://dev.to/bukestudio/eight-products-one-url-what-breaks-when-a-storefront-has-no-router-o9d</guid>
      <description>&lt;p&gt;I build a browser-side customizer for printed sneakers. You upload artwork, it gets sliced across the shoe panels, and you preview it on the actual shoe before ordering: &lt;a href="https://bukefootwear.com/" rel="noopener noreferrer"&gt;bukefootwear.com&lt;/a&gt;. The geometry is 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 render pipeline 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;, the deploy budget in &lt;a href="https://dev.to/bukestudio/nine-files-of-headroom-what-a-1000-file-deploy-limit-taught-me-about-srcset-and-dead-assets-56lm"&gt;the fourth&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This one is about the storefront shell around it. Our release is a folder of static files on Cloudflare Pages. There is no router, no server, no &lt;code&gt;/product/[slug]&lt;/code&gt; route. Eight shoe styles are served by one HTML file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://bukefootwear.com/product?product=KJ1
https://bukefootwear.com/product?product=MT1
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That works fine for humans and it is a trap for everything else. Four things broke, and none of them threw an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Canonical collapse: eight pages that all claim to be the same page
&lt;/h2&gt;

&lt;p&gt;The first version had no canonical at all. Then I added one — statically, into the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; of &lt;code&gt;product.html&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;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://bukefootwear.com/product"&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;Eight product URLs, one canonical target. To a crawler that says "these eight pages are duplicates of one page, index that one instead." Every individual style gets dropped. The page that ranks is a page with no product on it until JavaScript runs.&lt;/p&gt;

&lt;p&gt;The fix is to make the canonical a function of the query string, and to accept that it is now JavaScript's job:&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;code&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product&lt;/span&gt;&lt;span class="dl"&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;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canonical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://bukefootwear.com/product?product=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules I now follow because of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never ship a static canonical on a query-param page.&lt;/strong&gt; A wrong canonical is worse than a missing one, and it is invisible in the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The static HTML must never contradict what the JS writes.&lt;/strong&gt; If a &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; is already in the document, &lt;code&gt;append&lt;/code&gt; gives you two canonicals and Google picks the first. The static one wins, and it is the wrong one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same shape of bug hit our designer page: it had &lt;code&gt;&amp;lt;html lang="zh-CN"&amp;gt;&lt;/code&gt; and a Chinese &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, and &lt;code&gt;designer.js&lt;/code&gt; overwrote the title at runtime. Editing the HTML changed nothing for real visitors because the JS ran a millisecond later. Whatever the crawler reads statically and whatever the visitor sees have to be the same claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Zero structured data, on the pages that sell
&lt;/h2&gt;

&lt;p&gt;Eight commercial pages, and the count of JSON-LD blocks across all of them was:&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;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"application/ld+json"&lt;/span&gt; product.html
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not "malformed", not "warned in Search Console" — simply absent. Meanwhile the blog pages had it. The money pages were the only ones without a machine-readable name, price, or availability.&lt;/p&gt;

&lt;p&gt;The reason was structural, not laziness: JSON-LD is per-product data, and a single static file cannot hold eight products' prices honestly. So it has to be generated where the product is known:&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;ld&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="s1"&gt;@context&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;https://schema.org&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;@type&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;Product&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`BUKE &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="nx"&gt;code&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="na"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&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;@type&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;Brand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BUKE STUDIO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;material&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;material&lt;/span&gt; &lt;span class="o"&gt;||&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;[\r\n]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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="nx"&gt;images&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="nx"&gt;ld&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;6&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;fixedPrice&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixedPrice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ld&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;offers&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="s1"&gt;@type&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;Offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixedPrice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;priceCurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://bukefootwear.com/product?product=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fixedPrice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;purchasable&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://schema.org/InStock&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;https://schema.org/PreOrder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;itemCondition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://schema.org/NewCondition&lt;/span&gt;&lt;span class="dl"&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="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;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/ld+json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ld&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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 schema itself:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;offers&lt;/code&gt; block is conditional.&lt;/strong&gt; If the price is missing, emitting &lt;code&gt;"price": undefined&lt;/code&gt; gives you &lt;code&gt;{"@type":"Offer"}&lt;/code&gt; with no price — an invalid Offer that is worse than no Offer. Wrap the whole object, not the field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;material&lt;/code&gt; came from a text field with newlines in it.&lt;/strong&gt; It arrived as &lt;code&gt;"Upper: microfiber\nSole: rubber"&lt;/code&gt;. Valid JSON, valid string, and a value no consumer wants to display. Normalise at the boundary, not at the renderer.&lt;/p&gt;

&lt;p&gt;Verification has to be done in a real browser, because the block does not exist in &lt;code&gt;curl&lt;/code&gt; output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Product  sku=KJ1    price=98  USD  InStock  imgs=6
Product  sku=MT1    price=68  USD  InStock  imgs=6
Product  sku=DF1    price=88  USD  InStock  imgs=6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the same lesson as the canonical: &lt;strong&gt;if JavaScript writes it, only a JS-executing check counts.&lt;/strong&gt; I have wasted more time on &lt;code&gt;curl | grep&lt;/code&gt; returning empty while the browser was correct than on any other class of bug in this project.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Social cards never render, because crawlers do not run your JS
&lt;/h2&gt;

&lt;p&gt;This one is specific and it bit us during a campaign. We were pushing a design challenge on Threads, X and Instagram. When someone shares a link, the platform fetches the URL, reads &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt; from the raw HTML — and &lt;strong&gt;does not execute JavaScript&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Result: the links we were paying attention to, shared all day, rendered as bare text. No image. The tags we had were set by JS, therefore invisible to every scraper:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://bukefootwear.com/challenge | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'og:image'&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of our most-promoted pages (&lt;code&gt;/product&lt;/code&gt;, &lt;code&gt;/designer&lt;/code&gt;, &lt;code&gt;/challenge&lt;/code&gt;) had zero or partial Open Graph tags. The homepage had them. The campaign pages did not.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous: static tags in the HTML, per page, with a real 1200px image, plus the Twitter variants because X does not read Open Graph:&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;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://bukefootwear.com/assets/studio-v4/XB1-pair-1200.webp"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://bukefootwear.com/assets/studio-v4/XB1-pair-1200.webp"&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;And here is the honest trade-off: on a query-param storefront the static &lt;code&gt;og:image&lt;/code&gt; is &lt;strong&gt;one image for all eight products&lt;/strong&gt;. Scrapers cannot see the per-product image the JS picks. You either accept a generic card, or you generate eight real HTML files at build time.&lt;/p&gt;

&lt;p&gt;I accepted the generic card for now. It is a small loss and an enormous improvement over nothing. The general rule: &lt;strong&gt;anything that must be readable by a machine that does not run JS (crawlers, social scrapers, link previews, RSS, some ad platforms) cannot be produced by client-side logic.&lt;/strong&gt; Audit those separately; they are not part of your "the page works" test.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The price lived in four places
&lt;/h2&gt;

&lt;p&gt;This is the one that would eventually have cost real money.&lt;/p&gt;

&lt;p&gt;Prices for the eight styles were written into the product page markup, into a cart script, into a checkout calculation, and into one more place I found by grepping. They agreed, because I had been careful, which is exactly why the bug had not surfaced yet.&lt;/p&gt;

&lt;p&gt;The data does not look risky:&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;// one module, the only place a price is allowed to be typed&lt;/span&gt;
&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BukePricing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;KJ1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;98&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;purchasable&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="na"&gt;MT1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;purchasable&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="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;But a made-to-order shop has a twist: &lt;strong&gt;price and freight are different rules that both change.&lt;/strong&gt; A pair costs $98. Shipping is charged once for the first pair and 40% for each additional one in the same order — so the number a customer sees depends on order composition, not on the product. Two formulas, two owners, and if either one is duplicated you get a cart that disagrees with the product page and nobody notices until a customer does.&lt;/p&gt;

&lt;p&gt;What made this safe was not discipline. It was making the number impossible to type twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The product page renders price from the module.&lt;/li&gt;
&lt;li&gt;The cart reads the same module.&lt;/li&gt;
&lt;li&gt;The JSON-LD reads the same module.&lt;/li&gt;
&lt;li&gt;Freight lives in exactly one function, and its constants are named, so "we changed the EU rate" is a one-line diff.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I now test pricing the same way I test pixels: change one value in the source of truth, reload three pages, and confirm all three moved. If one of them does not, I found a duplicate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a router would and would not have fixed
&lt;/h2&gt;

&lt;p&gt;The reflexive answer is "use a framework with real routes." Eight static HTML files generated from one template would genuinely fix three of the four problems: real canonicals, real per-product Open Graph, no JS-dependent metadata. That is a build step, not a rewrite, and it is the correct destination.&lt;/p&gt;

&lt;p&gt;It would not fix the fourth. A router does not create a single source of truth for price; it just moves the duplication. The rendering layer is the easy part — it is the data ownership that decides whether a storefront can be trusted with money.&lt;/p&gt;

&lt;p&gt;Until then, the checklist I actually use before shipping a page that sells something:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; the URL and confirm canonical, description, title and Open Graph are all present &lt;strong&gt;in the raw HTML&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Render it in a browser and confirm the JS-written versions match what &lt;code&gt;curl&lt;/code&gt; returned, or that you accept the difference knowingly.&lt;/li&gt;
&lt;li&gt;Grep for the number. If the price appears in more than one file, that is the bug, whether or not it is wrong today.&lt;/li&gt;
&lt;li&gt;Count JSON-LD blocks. Zero on a commercial page is a decision you did not make on purpose.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The product pages these lessons came from are here, if you want to see the result: &lt;a href="https://bukefootwear.com/product?product=KJ1" rel="noopener noreferrer"&gt;KJ1 low-top&lt;/a&gt;, &lt;a href="https://bukefootwear.com/product?product=MT1" rel="noopener noreferrer"&gt;MT1 slipper&lt;/a&gt;, and &lt;a href="https://bukefootwear.com/designer" rel="noopener noreferrer"&gt;the designer that does the slicing&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>seo</category>
      <category>architecture</category>
    </item>
    <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>
