<?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: Gray Chen</title>
    <description>The latest articles on DEV Community by Gray Chen (@graychen).</description>
    <link>https://dev.to/graychen</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%2F4151286%2F7c83658b-1eb2-437a-a7bd-8b1e8f8655ca.jpg</url>
      <title>DEV Community: Gray Chen</title>
      <link>https://dev.to/graychen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/graychen"/>
    <language>en</language>
    <item>
      <title>Word doesn't rasterize your Mermaid SVG — it drops the labels</title>
      <dc:creator>Gray Chen</dc:creator>
      <pubDate>Tue, 27 Oct 2026 04:55:57 +0000</pubDate>
      <link>https://dev.to/graychen/word-doesnt-rasterize-your-mermaid-svg-it-drops-the-labels-4gi6</link>
      <guid>https://dev.to/graychen/word-doesnt-rasterize-your-mermaid-svg-it-drops-the-labels-4gi6</guid>
      <description>&lt;p&gt;Something that keeps coming up in Word support threads: you put a diagram into Word and the labels are missing. The boxes render. The arrows render. The text inside them doesn't.&lt;/p&gt;

&lt;p&gt;There's a second complaint that sounds unrelated, usually from someone who went through a converter: the drawing behaves like a photograph. You can't select a box, you can't retype a label, you scale it up and it goes soft.&lt;/p&gt;

&lt;p&gt;Those two symptoms look like unrelated bugs. They're the same bug, and it isn't the file format.&lt;/p&gt;

&lt;p&gt;It's one element inside the SVG — the element every diagram renderer uses to place a label.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two ways Word can hold a drawing
&lt;/h3&gt;

&lt;p&gt;Word has exactly two models for a drawing, and they aren't variants of each other.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;a picture&lt;/strong&gt;. One object, one blob of pixels or vectors. You can move it, resize it, and that's the whole vocabulary.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;a set of native shapes&lt;/strong&gt; — the same objects you get from &lt;em&gt;Insert → Shapes&lt;/em&gt;. Each one has a geometry, a text frame you can click into and type, and a position in a z-order. Connectors are their own object type, and the important part is that a connector's endpoints can be &lt;strong&gt;bound&lt;/strong&gt; to two specific shapes.&lt;/p&gt;

&lt;p&gt;That binding is what makes a Word diagram feel alive: drag a box, and every arrow attached to it follows, because the arrow isn't drawn — it's computed from the two shapes it points at.&lt;/p&gt;

&lt;p&gt;A picture has none of this. Which is why the difference between the two models is not "quality". It's whether the diagram survives its first revision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Mermaid hits the foreignObject wall
&lt;/h3&gt;

&lt;p&gt;Mermaid renders to SVG, and like most diagram tools it puts text inside shapes using &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt; is the standard trick for embedding a chunk of HTML inside an SVG drawing. It exists because raw SVG text layout is genuinely miserable — &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; plus manual &lt;code&gt;&amp;lt;tspan&amp;gt;&lt;/code&gt; lines with hand-computed wrapping, no automatic line height, no rich inline styling. If you want a label that wraps and respects CSS, &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt; is the only sane answer.&lt;/p&gt;

&lt;p&gt;Word does not implement it.&lt;/p&gt;

&lt;p&gt;And it doesn't degrade gracefully. The moment Word imports the SVG — before you've asked it to do anything — it translates the drawing into its own object model, keeps every geometric primitive, and &lt;strong&gt;drops the contents of the &lt;code&gt;foreignObject&lt;/code&gt;&lt;/strong&gt;. The box that held the label survives. The label doesn't. There's no error, no warning, and nothing recovers it: converting the picture into shapes afterwards hands you the same empty boxes. You find out when you're looking at a flowchart made of blank rectangles — or when someone else is.&lt;/p&gt;

&lt;p&gt;That single unimplemented element is also the reason almost every "Mermaid to Word" converter hands you a PNG at 2× and calls it done. Seen from here, that isn't laziness. Given what Word does with &lt;code&gt;foreignObject&lt;/code&gt;, a bitmap is the honest fallback: at least the labels stay readable. You just can't touch them.&lt;/p&gt;

&lt;h4&gt;
  
  
  Reproduce it yourself
&lt;/h4&gt;

&lt;p&gt;Worth doing before you trust any of this, including the parts I got wrong at first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Save an &lt;code&gt;.svg&lt;/code&gt; containing one plain &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; line and one &lt;code&gt;&amp;lt;foreignObject&amp;gt;&lt;/code&gt; holding two &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Insert it into Word (&lt;em&gt;Insert → Pictures&lt;/em&gt;). It arrives as a single picture — &lt;strong&gt;and the damage is already done&lt;/strong&gt;: the &lt;code&gt;foreignObject&lt;/code&gt; box is empty, while the plain &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; line is right there.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Right-click → &lt;strong&gt;Convert to Shape&lt;/strong&gt;, to rule out "it's in there and just not drawn". It stays empty.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Click the text. The plain &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; line became a text box you can retype. The &lt;code&gt;foreignObject&lt;/code&gt; block never contained anything to click.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first time I ran this I expected a slightly soft version of my labels, since "unsupported element" usually means it degrades to pixels. What came back was nothing — the box, the border, the rounded corners, and no text at all. No conversion, no intervention: that's what the import alone produced.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three ways out, and what each one costs
&lt;/h3&gt;

&lt;p&gt;Once you frame the problem as "the text lives somewhere Word can't read", the options become clear, and all three are defensible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Rasterize.&lt;/strong&gt; Render at 2× or 3× and embed the image. Cheap, universal, works for every diagram anyone will ever draw. Cost: zero editability, ever. It's also the option that quietly guarantees the diagram goes stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Keep the SVG, avoid foreignObject.&lt;/strong&gt; If you export with text converted to &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;tspan&amp;gt;&lt;/code&gt;, Word reads the drawing as real vector shapes and you can scale it losslessly. Cost: the text becomes fixed geometry. You can move the whole label, you can't retype it. For a diagram that will never change, that's a fine trade. For an architecture doc, it isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Drop the SVG entirely and emit native shapes.&lt;/strong&gt; Walk the layout yourself, and produce one Word shape per node, one Word connector per edge, with the connector's &lt;code&gt;stCxn&lt;/code&gt; / &lt;code&gt;endCxn&lt;/code&gt; pointing at the real shape ids. Nothing is an image, and the text lives in a text frame.&lt;/p&gt;

&lt;p&gt;Option 3 is the only one that survives someone saying "change this box". It's also the one almost nobody ships, because it means re-implementing the layout pass instead of calling a renderer.&lt;/p&gt;

&lt;h3&gt;
  
  
  The wall behind the wall
&lt;/h3&gt;

&lt;p&gt;Escape &lt;code&gt;foreignObject&lt;/code&gt; — export with the labels flattened to &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;tspan&amp;gt;&lt;/code&gt; — and the drawing inserts looking correct. Every label sits exactly where the browser put it. Option 2 appears to work.&lt;/p&gt;

&lt;p&gt;Then someone right-clicks and chooses Convert to Shape, and it comes apart. The boxes keep their width. The text inside them doesn't: labels that fit a second ago run past the shape edge, or lose their trailing characters to a clip. Nothing was dropped this time. It's all there, and it's all in the wrong place.&lt;/p&gt;

&lt;p&gt;The reason a &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; element is only half a fix: it carries an anchor point and a font size, but no &lt;strong&gt;box width&lt;/strong&gt;. In SVG, how wide a string is belongs entirely to the renderer's font metrics. Before conversion Word isn't typesetting anything — it copies each coordinate and each font size into DrawingML, which is why the preview matches the browser exactly. After conversion, every shape owns its own text frame, and Word measures the string with its own metrics. Different engine, same box.&lt;/p&gt;

&lt;p&gt;Our DOCX writer hit the same class of bug on a real Word build. We had written a node's font size as 24 half-points — 16px — against a live Chromium measurement of 14px. That's &lt;strong&gt;14.3% wider than the box had been sized for&lt;/strong&gt;, and Word doesn't shrink text to fit a fixed frame; it clips. A node reading &lt;code&gt;Manual approval&lt;/code&gt; came through showing only &lt;code&gt;Manual&lt;/code&gt;, its second line cut off by the fixed line height. An edge label reading &lt;code&gt;No&lt;/code&gt; came back as &lt;code&gt;N&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So "flatten to &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; and it scales losslessly" holds only while nobody edits it. The moment they do, you're back to predicting a text-measurement engine you don't own — which is precisely why option 3 can't just hand Word an SVG and hope for the best.&lt;/p&gt;

&lt;h3&gt;
  
  
  What emitting native shapes actually involves
&lt;/h3&gt;

&lt;p&gt;This is where the work is, so it's worth being concrete about three things that surprised me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The shape count is not the node count.&lt;/strong&gt; In Mermaid, &lt;code&gt;([...])&lt;/code&gt; is a stadium/capsule node, and the renderer gives it a separate text box layered on the shape. So a capsule node costs you &lt;strong&gt;two&lt;/strong&gt; &lt;code&gt;&amp;lt;wps:wsp&amp;gt;&lt;/code&gt; elements rather than one. A seven-node flowchart containing a single capsule node therefore yields &lt;strong&gt;17&lt;/strong&gt; shapes instead of 16 — 7 nodes, 7 connectors, 2 edge labels, plus that extra text box. Switching the terminal node from &lt;code&gt;([...])&lt;/code&gt; to &lt;code&gt;[...]&lt;/code&gt; brings it to 16.&lt;/p&gt;

&lt;p&gt;The lesson isn't Mermaid trivia. It's that "the counts match" is a weak assertion unless you know which renderer artifact you're counting. Ours carried a stale number in a handful of places for a while — the example diagram changed, the figure in the copy didn't. That's exactly the failure mode an assertion like this exists to catch, and it only catches it when the number is read out of the artifact rather than maintained by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Connector binding is the whole feature.&lt;/strong&gt; A connector that merely looks attached is drawn line art and will sit still when you drag a box. Real binding means writing both ends into the XML — start and end connection ids — and then verifying all of them resolved. In my case, 7 of 7, and that assertion is in the test suite, because a silent 6-of-7 is worse than a visible failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layout has to be reimplemented, not reused.&lt;/strong&gt; The renderer's &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; is the wrong abstraction for Word connectors. Connectors need to route between two shape boundaries, which means working from node geometry and edge routing rules, not from a flattened path. Anything you can't express that way — custom edge styling, hand-tuned routing, decorative markers — doesn't come through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it breaks
&lt;/h3&gt;

&lt;p&gt;A 7-node flowchart with straight-ish edges &lt;strong&gt;emits&lt;/strong&gt; cleanly — the word matters here, because that's the shape-writing path, not Word's own Convert to Shape. Whether a 60-node hairball with custom class styling survives is a different question, and I'd rather flag it as the place I'd expect it to fail than imply I've proven otherwise — custom class styling and curved edge routing are the two things that have no clean equivalent in a Word connector.&lt;/p&gt;

&lt;p&gt;I think that boundary is worth stating plainly, because the alternative is a tool that quietly lies about complex input. The honest version is: native shapes work well for diagrams whose value is that people need to edit them — process flows, org charts, architecture sketches — and poorly for diagrams whose value is that they look exactly a certain way.&lt;/p&gt;

&lt;p&gt;That's most of the difference between "I exported my diagram" and "my diagram is now in the document".&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I ended up building the option-3 path for Mermaid, running in the browser with no upload: &lt;a href="https://filecabin.app/document/mermaid-to-word/" rel="noopener noreferrer"&gt;filecabin.app/document/mermaid-to-word&lt;/a&gt;. The mechanism section above is the part I'd want regardless of which converter you use — what Word does with &lt;code&gt;foreignObject&lt;/code&gt; is a Word behavior, not a tool's choice.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>mermaid</category>
      <category>docx</category>
      <category>svg</category>
      <category>webdev</category>
    </item>
    <item>
      <title>7.2 KB of my first-paint script wasn't mine — and my own size budget was hiding it</title>
      <dc:creator>Gray Chen</dc:creator>
      <pubDate>Wed, 30 Sep 2026 14:47:40 +0000</pubDate>
      <link>https://dev.to/graychen/72-kb-of-my-first-paint-script-wasnt-mine-and-my-own-size-budget-was-hiding-it-2pfc</link>
      <guid>https://dev.to/graychen/72-kb-of-my-first-paint-script-wasnt-mine-and-my-own-size-budget-was-hiding-it-2pfc</guid>
      <description>&lt;p&gt;This site is a browser-side toolkit. You pick a file, it gets resized or converted, and nothing is uploaded anywhere.&lt;/p&gt;

&lt;p&gt;That promise is the product. But it comes with a cost: it moves the work from my machine to yours. No server pays for the CPU, and no server pays for the download either. Whatever a tool needs has to arrive before the tool can be used.&lt;/p&gt;

&lt;p&gt;That is easy to forget on a laptop on wifi. On a phone on mobile data, it is the difference between a tool and a spinner. And this is a site people use once: no account, no sign-up, no reason to come back and wait a second time. So the bytes a page needs before it works &lt;strong&gt;are&lt;/strong&gt; the first impression.&lt;/p&gt;

&lt;p&gt;I wanted that number written down, instead of estimated by feel. The plan for the site has the real budgets: &lt;strong&gt;50 KB of first-paint JavaScript&lt;/strong&gt;, 100 KB for HTML + CSS + JS together, and 500 KB for the full first load including fonts and images.&lt;/p&gt;

&lt;p&gt;On top of those, I kept a stricter number for myself. "The limit you must not cross" and "the early warning that fires first" are two different jobs, and I wanted both. My early warning sat at &lt;strong&gt;20 KB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Where did 20 KB come from? I measured the tool pages, then added room for the tool I was about to build. The comment next to the constant said it left "about 108% headroom for new components," which sounded reasonable at the time. It took me one day to ask the only question that mattered: &lt;strong&gt;headroom above what?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Opening the box
&lt;/h2&gt;

&lt;p&gt;Each tool on this site is an "island": a chunk of HTML that arrives already rendered, plus a small client script that later attaches behavior to it. The pattern is called islands — render everything up front, then add interactivity only where it is needed. The page is usable before any JavaScript runs.&lt;/p&gt;

&lt;p&gt;The first version built those islands with Preact. Tool pages shipped &lt;strong&gt;up to 11.2 KB across five files&lt;/strong&gt; — four of them framework, one of them mine:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;chunk (one file each)&lt;/th&gt;
&lt;th&gt;gzip&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;preact.module&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4,412 B&lt;/td&gt;
&lt;td&gt;4.3 KB&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;client.*&lt;/code&gt; — Astro hydration client&lt;/td&gt;
&lt;td&gt;1,405 B&lt;/td&gt;
&lt;td&gt;1.4 KB&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hooks.module&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1,161 B&lt;/td&gt;
&lt;td&gt;1.1 KB&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsxRuntime.module&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;399 B&lt;/td&gt;
&lt;td&gt;0.4 KB&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;framework subtotal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7,377 B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.2 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;64%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Tool.*&lt;/code&gt; — my own island&lt;/td&gt;
&lt;td&gt;4,100 B&lt;/td&gt;
&lt;td&gt;4.0 KB&lt;/td&gt;
&lt;td&gt;36%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11,477 B&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11.2 KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;The two bolded rows are sums of the rows above them — the &lt;code&gt;gzip&lt;/code&gt; column adds to 11,477 B once, and &lt;code&gt;share&lt;/code&gt; to 100%.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that table again. Two thirds of the first paint was not the product.&lt;/p&gt;

&lt;p&gt;Worse, the part that could actually regress — the compress / convert / resize logic — was the &lt;em&gt;smaller&lt;/em&gt; half. I was measuring my own code with the same ruler I used for 7.2 KB of code I had not written and could not fix.&lt;/p&gt;

&lt;p&gt;That answers my own question. I had set the headroom on top of a baseline that was &lt;strong&gt;64% framework&lt;/strong&gt;. With four tools shipped, first paint was already 11.2 KB — past the halfway mark of a 20 KB ceiling, and two thirds of it still not mine. Adding a tool properly grows first paint by roughly 1.5 KB, so 20 KB would have kept passing while the framework quietly ate a budget I thought was mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Deleting the framework was the easy part
&lt;/h2&gt;

&lt;p&gt;I removed the framework integration and split the same UI into two halves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;build time&lt;/strong&gt; renders the complete UI into HTML, so there is a working interface with zero JS, and something for a crawler to read;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runtime&lt;/strong&gt; attaches behavior to it — events, state, text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same pages went from &lt;strong&gt;11.2 KB across five files to 1.2 KB in one.&lt;/strong&gt; That was a one-day change, and it is the part everyone writes about.&lt;/p&gt;

&lt;p&gt;The part that took longer was making the number honest. Start with one detail: both readings above came from a ruler that could not see the inline scripts on the page. The honest per-page figure today is 3.4 KB (section 4).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you want the opposite direction — building small on purpose from the first commit — there is a good write-up here on DEV: &lt;a href="https://dev.to/thegdsks/i-shipped-a-working-landing-page-in-14-kb-here-is-every-byte-8p3"&gt;I shipped a working landing page in 14 KB. Here is every byte.&lt;/a&gt; Mine was the other direction: bytes already in the file, wearing my name, that I had not written.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Bug #1: I was counting the wrong thing, twice
&lt;/h3&gt;

&lt;p&gt;My first collector walked the HTML, grabbed every &lt;code&gt;&amp;lt;script src="…"&amp;gt;&lt;/code&gt;, and added them up. The number was wrong by roughly &lt;strong&gt;3×&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is why. The entry point of a script is not always a &lt;code&gt;src&lt;/code&gt; attribute. A bundler can emit a module script whose &lt;em&gt;body&lt;/em&gt; imports the real chunk, and a scan that only looks at &lt;code&gt;src&lt;/code&gt; sees none of it. Two hand-written inline scripts on the page — 2 KB gzipped between them — were invisible for the same reason.&lt;/p&gt;

&lt;p&gt;I fixed that and thought I was done. Then I added a mobile nav toggle: about 0.3 KB typed straight into &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. The reading did not move. The collector had the same blind spot in a second place: it only followed &lt;code&gt;_astro/*.js&lt;/code&gt; &lt;strong&gt;files&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is the failure mode I keep coming back to. A size guard that cannot see the code you write by hand does not protect your budget — it protects the framework's share of it. On a small site, the thing that grows fastest is exactly those small snippets you paste in while building features.&lt;/p&gt;

&lt;p&gt;So the rule became: &lt;strong&gt;the guard must not be narrower than the thing being measured.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap #2: don't punish the correct answer
&lt;/h3&gt;

&lt;p&gt;Here is the subtle one — and this one I did not walk into. Heavy code — an image decoder, an audio encoder — is loaded with a dynamic &lt;code&gt;import()&lt;/code&gt;. That puts it in its own async chunk, which never touches the first paint. That is the whole point of the architecture.&lt;/p&gt;

&lt;p&gt;A guard that counts every import would flag the one technique that keeps first paint small. You would have a robot failing the build for doing the right thing, and after the second false alarm someone turns the guard off.&lt;/p&gt;

&lt;p&gt;So the collector follows &lt;strong&gt;static&lt;/strong&gt; imports only. The regex matches &lt;code&gt;from"…"&lt;/code&gt; and &lt;code&gt;import"…"&lt;/code&gt; — both followed by a quote. &lt;code&gt;import(&lt;/code&gt; is followed by a parenthesis, so dynamic imports never match. The difference is one character wide, and it decides whether the guard helps you or fights you.&lt;/p&gt;

&lt;p&gt;I am not claiming this is airtight. A module can call &lt;code&gt;import()&lt;/code&gt; at the top level: nothing in the static graph points to it, and a byte ceiling cannot see what is not in the graph. That gap is real. I would rather write it down than pretend otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Make "stricter" something the machine enforces
&lt;/h2&gt;

&lt;p&gt;With the framework gone, the warning line came down from 20 KB to 4 KB. There are two numbers in the repo now:&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;BUDGET_FIRST_JS&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// the plan's hard limit&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FIRST_JS_CEILING&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;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// the regression guard — stricter, on purpose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A stricter number only means something if it &lt;em&gt;stays&lt;/em&gt; stricter. So the script refuses to run when the internal number has been loosened past the plan's budget:&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="nx"&gt;FIRST_JS_CEILING&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;BUDGET_FIRST_JS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;✗ the regression guard is looser than the plan budget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that four-line check, "we are stricter than we need to be" is a sentence in a document. With it, it is a property of the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The number is also a proxy — that's why it's 4 KB
&lt;/h2&gt;

&lt;p&gt;I also do not ship WebAssembly. Nothing needs it: image work goes through &lt;code&gt;createImageBitmap&lt;/code&gt;, canvas, and &lt;code&gt;OffscreenCanvas&lt;/code&gt; inside a worker.&lt;/p&gt;

&lt;p&gt;The "no WASM" rule and the size ceiling turn out to be the same check. The smallest WASM image decoder worth using starts around &lt;strong&gt;200 KB&lt;/strong&gt;. libheif is closer to 1 MB, and the common HEIC wrappers land near 500 KB. Those are uncompressed numbers; even the smallest of them is still well over &lt;strong&gt;10×&lt;/strong&gt; a 4 KB ceiling once compressed. So there is no gray zone: if someone statically imports a decoder, the build breaks the same day.&lt;/p&gt;

&lt;p&gt;One number enforcing both "the page stays light" and "no WASM sneaks into the critical path" is a better deal than two policies somebody has to remember.&lt;/p&gt;

&lt;p&gt;And you can check it from the outside, which I like. On the deployed site, the only script the build controls has &lt;strong&gt;no static imports at all&lt;/strong&gt; — five dynamic ones:&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;compress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../tools/image-compress-to-size/mount.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nx"&gt;convert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../tools/image-convert/mount.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The site has gone from one island to nine since then. The deployed build lags a couple of releases behind, which is why the script above lists five. Largest first-paint script, measured per page: still &lt;strong&gt;3.4 KB&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;One boundary on that number, since I am asking you to verify it. The guard counts what &lt;strong&gt;our build&lt;/strong&gt; emits: the &lt;code&gt;_astro&lt;/code&gt; chunks reachable from the import graph, plus the inline scripts we hand-write. It does not count the analytics beacon Cloudflare injects at the edge, because that is third-party code we do not bundle. I would rather name the edge of the measurement than imply the number covers the whole page.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What the 7.2 KB was buying
&lt;/h2&gt;

&lt;p&gt;Deleting the framework was not free. The bill just does not arrive in kilobytes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;skeleton and behavior are connected by a &lt;strong&gt;hand-written contract&lt;/strong&gt; — &lt;code&gt;data-role&lt;/code&gt; attributes and class names. Change one side and you must change the other. A runtime assertion throws the moment they disagree, and that assertion is the only reason this stays survivable.&lt;/li&gt;
&lt;li&gt;there is &lt;strong&gt;no diffing&lt;/strong&gt;. List updates are written by hand.&lt;/li&gt;
&lt;li&gt;the initial text in the skeleton must match the first render &lt;strong&gt;word for word&lt;/strong&gt;, or the page visibly twitches the instant it attaches.&lt;/li&gt;
&lt;li&gt;adding a tool is one extra step compared to writing a component.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is what 7.2 KB buys: a fair price for a site that adds new screens faster than it adds new pages.&lt;/p&gt;

&lt;p&gt;I was not buying that. This site adds &lt;em&gt;tools&lt;/em&gt;, which are mostly logic, and it reuses the same few interaction patterns. I was paying a framework tax every week for something I needed once — out of a budget I had told myself was mine.&lt;/p&gt;

&lt;p&gt;If your first paint is mostly React and you are fine with that, this is not an argument with you. The lesson I would pass on is smaller and more annoying: &lt;strong&gt;a size budget is only as good as the thing it measures.&lt;/strong&gt; Mine stayed green the whole time and told me nothing about the code I was writing.&lt;/p&gt;

&lt;p&gt;The tools run at &lt;a href="https://filecabin.app" rel="noopener noreferrer"&gt;https://filecabin.app&lt;/a&gt; if you want to read the headers yourself — &lt;code&gt;connect-src 'self'&lt;/code&gt;, no &lt;code&gt;'wasm-unsafe-eval'&lt;/code&gt;, and the same script quoted above.&lt;/p&gt;

&lt;p&gt;If you have had a size guard turn out to be measuring the wrong thing, I would like to hear about it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
      <category>astro</category>
    </item>
  </channel>
</rss>
