<?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>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>
