Skip to main content
This page was machine-translated and may contain errors or omissions.

Build-Time Slimming

Two slimming routes: images and dictionaries

Sun Sep 27 2026
4199 words · 22 minutes

Build-Time Slimming

A static site gets fat in two ways.

The visible kind: dozens of original images at a few MB each, with half of the reader’s browser traffic spent on pictures.

The invisible kind: a 200KB translation dictionary inlined in the head, carried by every single page.

This post records two slimming routes: the image pipeline and the multilingual dictionary. The idea they share is pushing the entire conversion cost up front to build time, and letting the runtime do nothing but enjoy the results.

The two slimming tracks are told separately below, though they share one methodology: a single source of truth, build-time conversion, incremental caching, a single point of decision.

First Half: The Image Pipeline

Image optimization is one of those jobs where “redoing everything on every build is silly, but skipping it is not an option”.

This site’s answer is to push the optimization into a batch script that runs before the build: site-wide scanning, dual-format generation, content-hash caching, manifest-based distribution.

The Shape of the Pipeline

The prebuild script runs before astro build, and its image step does four things:

  1. Scan every .png/.jpg/.jpeg under public/ (skipping directories that need no optimization, such as fonts, KaTeX, and Live2D);
  2. Generate two formats: each image goes through sharp and comes out as both AVIF (quality 60) and WebP (quality 75);
  3. Cache by content hash: a SHA1 of “path + file size + modification time” serves as the key, and the image is skipped if the artifact already exists. Day-to-day builds only touch new or changed images, and the hit rate approaches 100% over time;
  4. Write a manifest: a { original path → { avif, webp, width, height } } mapping, handed to page components to look up and distribute at runtime.

A real run’s log looks like this: 262 images, originals of several MB compressed down to a fraction in AVIF+WebP combined.

Build once, the whole site benefits, and every build after that costs nearly nothing.

Why These Parameters

Why AVIF + WebP instead of AVIF alone?

AVIF compresses best, but decodes slowly (large images on mobile especially), and older browsers don’t support it. WebP has near-universal compatibility and decodes fast.

The component tries AVIF → WebP → original in browser-support order, a three-layer fallback.

AVIF’s quality is pushed down to 60 because AVIF’s encoding efficiency at low quality tiers is unusually good. AVIF at 60 usually looks better than WebP at 75, while the file is smaller.

WebP keeps 75, its perceptual comfort zone.

Why “path + size + mtime” as the hash key instead of file content?

A content hash means reading the whole file before SHA1, which is expensive across a full site scan.

A composite key of path plus metadata costs zero reads for unchanged images and is guaranteed to mismatch for changed ones (any edit changes size or mtime).

This is the classic cache-key trade of approximate uniqueness in exchange for zero-cost probing. The failure case is “content changed but neither size nor mtime did”, which in practice almost never happens.

Why do width and height go into the manifest?

With dimensions in hand, the component can emit width/height directly, so the browser lays the page out before images load.

This is the foundation of CLS optimization (images without size attributes are the number-one source of layout shift; see the rendering performance post).

One manifest field serves both format distribution and layout stability, at almost no extra cost.

One deliberate non-feature is worth recording: format conversion only, no resizing.

Every image gets AVIF/WebP at a single width, so srcset carries exactly one candidate URL.

Multi-width responsiveness means artifact bloat (each image × N widths) and cache-key complexity, while this site’s article images render at a fixed display width and the cost of the browser downscaling a large image is paid once, on the decode side.

For a personal site, the multi-width payoff cannot carry its complexity. If image size ranges ever widen (thumbnails mixed with full-screen images), adding that layer later won’t be too late.

Working with the Astro Component

prebuild only stocks the shelves; the consumer is a rehype plugin.

Raw image references in Markdown are looked up in the manifest during the render pipeline and replaced with modern-format tags carrying srcset/size attributes.

Authors never notice a thing. Keep referencing the original path, and the pipeline turns “what you wrote” into “what the browser should get”.

The rewritten tag structure is worth a look, because the format choice belongs to the browser:

<picture>
<source type="image/avif" srcset="the .avif of the same image">
<source type="image/webp" srcset="the .webp of the same image">
<img src="the original image" width="1920" height="1080" loading="lazy" decoding="async" alt=" ">
</picture>

<source> merely declares “I have this format”; the browser picks one top-down by support, and falls through to the original in <img> when nothing is supported.

Not a line of JS is involved; the static tags negotiate on their own.

width/height are hard-coded pixel values, from which the browser reserves an aspect-ratio placeholder before loading.

loading="lazy" holds images below the fold until scrolling brings them near.

The consumer also has a few quiet edge-case handlers: remote images and data URLs pass through untouched. Images missing from the manifest (new images from a skipped script run, images in excluded directories) don’t error; the plugin reads their metadata once with sharp on the spot to fill in width and height, degrading to size optimization without format optimization.

The cache’s whole mission is to push these on-the-spot reads down to nearly zero.

The value of this layering is a clean division of labor: the script owns batch processing, the plugin owns replacement, the component owns fallback.

Policy changes in any layer (adding quality parameters later, changing directory rules) never ripple into the others.

Above the Fold and the Body

The pipeline only processes <img> inside Markdown bodies; component-level images (hero images, OG images) never travel this path.

That boundary happens to align with the lazy-loading boundary: the vast majority of body images sit below the fold, so loading="lazy" captures the full benefit. Hero images are often the LCP element, and lazy-loading them slows the first screen instead; the browser should fetch them immediately.

So “lazy on every image site-wide” is a crude policy; “lazy in the body, eager above the fold” is the right shape.

And the pipeline’s natural layering lands exactly on that shape.

Why Not astro:assets

Astro ships astro:assets, which can also convert formats and measure dimensions at build time, but it only recognizes images that are imported or live under src/.

This site’s images all live in public/, and articles reference them with root-absolute paths. Content (Markdown) and assets (images) are stored independently, and image URLs don’t change with build output.

Under that organization the built-in solution can’t help, while the batch script works on any image in public/.

The other consideration is portability: a prebuild script plus a rehype plugin binds to no framework; swap the static site generator and the pipeline moves over as-is.

Build-Time Compression Yields to the Edge

Early on, the pipeline also pulled in @playform/compress to compress HTML/CSS/JS once more at build time.

Most of it was later switched off, leaving only light HTML-level processing, with the reasoning written into a config comment: Cloudflare and Vercel both already do gzip/brotli compression in real time at the edge.

Compressing again at build time is duplicated work, and one of the slowest links in the whole build chain (the sharp image re-compression pass especially so).

The decision principle: if the platform already does a thing, don’t do it again at build time.

Build time is money too; CI queue minutes cost more than a few dozen bytes of HTML whitespace.

The decision also draws a boundary: only format conversion (original → AVIF/WebP) must happen at build time (edge services don’t offer transcoding), while lossless compression of text resources goes to the edge.

To tell which side an optimization belongs on, ask whether it needs domain knowledge: transcoding does, compression doesn’t.

Why transcoding can’t be handed to request time the way compression is deserves a proper accounting.

AVIF’s encoder is brutally expensive. Encoding one large image can take hundreds of milliseconds to seconds, while decoding is comparatively cheap.

Transcode at the edge per request and you either make every visitor pay an encoding cost (too slow to be usable) or cache transcoded results at the edge, which moves cache invalidation and storage costs from build time to runtime — complexity goes up, not down.

In the build-time scheme, encoding is paid exactly once, artifacts are distributed statically, and the CDN caches them end to end.

Expensive and deterministic things are worth prepaying in one lump; cheap and stateless things can go to the edge in real time.

Orphan Artifacts and Reconciliation

Run the pipeline long enough and one kind of garbage accumulates: a source image gets deleted or renamed, and the .avif/.webp generated from the old file still sits in the artifact directory.

No page references them, but nothing deletes them either.

These orphan artifacts never raise an error, just quietly occupy space, and anything mixed into the deploy directory gets uploaded along with everything else.

The countermeasure is to treat the manifest as the master list: any file in the artifact directory not registered in the manifest is an orphan.

A reconciliation step is worth adding: after each batch scan, walk the artifact directory and delete anything absent from the current manifest.

The logic is one unremarkable line; what it cures is the chronic disease of a deploy directory that only grows because nobody dares delete anything.

By the same token, the path + size + mtime key misses on renames (the path changed), turning old artifacts into orphans — the reconciliation step catches those too.

Playing Nice with HTTP Caching

If artifact filenames carry a content hash, the HTTP layer can ship long-lived Cache-Control: immutable caching outright.

The URL never changes, so a cache hit is always correct. Original image URLs stay constant, with moderate durations plus revalidate.

This arrangement leaves exactly one entrance for the “image updated but users see the old one” trap: replacing the original’s content without changing its filename.

And that is a usage-discipline problem (content changed, so run the batch pass and get a new hash), not a pipeline defect.

The pipeline’s job is to translate “content changed” into “URL changed”, so that every correctness assumption HTTP caching makes actually holds.

Intake Discipline for Originals

The pipeline’s payoff ceiling is set by the quality of the originals, so a few intake rules are worth writing down.

Shrink before intake: photos straight off a phone run 4000px wide without effort, while body display width is fixed, so anything past a long edge of two or three thousand pixels is pure waste of transfer and storage. The intake script (or your hands) compresses to a target long edge first.

Unify the color space: images mixing P3 and sRGB show color shifts in some browsers, and normalizing everything to sRGB is the least worrisome route (sites with professional color needs are another story).

EXIF orientation: phone photos record rotation in an EXIF flag, and transcoding without honoring it produces output rotated the wrong way. Libraries like sharp support auto-orientation from EXIF, and the pipeline should explicitly confirm that step is on.

Directory and naming conventions: this site creates a directory per article and numbers the images (e.g. /260927/1.png); URLs stay short and stable, and per-directory exclusion and cleanup become easy.

These rules are not written into code, but they decide whether the artifact directory is still clean after the pipeline has run for ten years.

Setting the Quality Parameters

The numbers AVIF 60 / WebP 75 should not be copied from someone else’s config, and deriving them is nothing mysterious: one disciplined blind test.

Pick three categories of representative samples (a few photos, screenshots, and charts each: the three differ completely in content structure, and their optimal tiers diverge a lot), export a row of outputs at different qualities, strip quality information from the filenames, shuffle them, compare side by side, and pick the lowest tier at which you can’t tell the difference.

A few empirical patterns: photos tolerate compression far better than charts. AVIF 40 photos are often indistinguishable, while charts and text-heavy screenshots blur at the strokes first when compressed hard. “Zoom to 200% and look” is a stricter bar than “looks fine”, because text edges are the first place compression artifacts show. Once decided, write the parameters into the script comments with the test date, and rerun the round when the encoder version changes.

One reverse-order idea is also worth recording: get the pipeline working end to end at the highest quality tier first, then step the compression down.

The biggest trap while tuning is “compression artifacts” and “pipeline bugs” blaming each other. Only after confirming that one image is correct all the way from source file to on-page display, and only then introducing compression loss, can problems be located.

The quality parameter is the only knob in the pipeline that demands human-eye acceptance.

A Reusable Checklist

To move this pipeline to another static site, the skeleton needs only five things:

  1. Scan directories (with an exclusion list);
  2. An artifact cache keyed by content hash (the crux of incremental builds);
  3. Two formats plus quality parameters (AVIF 60 / WebP 75 as a starting point);
  4. A manifest recording artifact paths and dimensions;
  5. Manifest-driven replacement in the render pipeline plus an original-image fallback.

About two hundred lines of script in all; the payoff is site-wide image traffic cut by at least half and build time kept under control.

For a content site, this is the highest return-on-investment performance infrastructure there is.

Second Half: The Inlined Dictionary

The images’ fat is visible; the dictionary’s is not.

A bilingual site carries its UI strings twice, and interactive components need to look words up instantly in the browser, so the translation dictionary has to go into the HTML.

Fully expanded, this site’s dictionary approaches 200KB. If every page carried a copy, users would be downloading an entire dictionary for nothing on every navigation.

The slimming target, once again, lives at build time.

The Shape of the Problem

Sort the site’s strings by consumer and they should never get equal treatment:

  1. Core strings: navigation, footer, buttons, form hints. Used on every page, small (a few KB), and must be inlined with the page, or components can’t find their words on the first frame;
  2. Widget strings: the thousands of entries inside the 284 game/tool components. Used only by their own pages, large (hundreds of KB), but genuinely needed on game pages (the components must switch languages instantly);
  3. Server-only strings: the “How to Play & Controls” body on detail pages. Rendered into the HTML by Astro on the server at build time, and no code in the browser ever reads it.

The third kind is the slimming target.

It amounts to 568 slug entries, roughly 200KB across the two languages, previously mixed into the dictionary and inlined along with everything else.

Yet the body text already exists in the HTML as visible content, so a second client-side copy is pure double storage.

First cut: strip the guides subtree out of the client dictionary (a stripGuides function that deletes those top-level keys before serialization), keeping it only in the server-side dictionary.

The second cut is tiered injection: ordinary pages (blog, archive, about) get only the core dictionary; the full dictionary (core + widgets + games + tools) is injected only when visiting /games/** or /tools/**.

The check is one path regex in the layout layer. The head size of most pages immediately shrinks back to a few KB.

That regex is worth pasting here, because its edge handling is the entirety of tiered injection’s details:

const needsFullI18n = /(^|\/)(games|tools)(\/|$)/.test(Astro.url.pathname);

Each of the three segments has a job: (^|\/) keeps a prefix collision like nogames/ from being misjudged. (games|tools) is the two full-dictionary directories. (\/|$) anchors the end of the path (both /games itself and /games/2048/ must match).

Miss any one segment and the symptom is “some class of pages carries a dictionary it shouldn’t” or, the other way round, “game pages have no dictionary”. And it only reproduces on specific URLs, nearly impossible to catch by spot checks.

Unlike normalized approaches such as new URL(pathname, base).pathname, this regex runs on the raw string, so beware trailing-slash differences. Local dev and the deployed environment may disagree about slashes.

Result: ordinary pages carry no widget strings at all; even on widget pages, the server-rendered body is no longer stored twice.

The total inlined JSON in heads across the site drops by an order of magnitude.

The Design Constraint

One might ask: why not ship the dictionary as JSON files loaded on demand?

Technically possible, but it introduces the classic static-site timing problem: components render their first frame before the fetch returns.

Users see a flash of raw key names, or components must wait for the dictionary before hydrating.

For a game site built on “switch page, play immediately”, that wait is unacceptable.

An inlined dictionary arrives with the HTML and is readable synchronously, the sturdiest choice for a static multilingual site. It only requires the two cuts above to keep the size in check.

From the caching angle, inlining loses nothing either: the HTML already goes through SWR caching (see the SW caching post), and the dictionary is cached at both the browser and the SW along with the page, so repeat visits cost nothing.

The Lookup Chain

Slimming changes the dictionary’s size; the shape of the chain is worth laying out too, because both cuts were made along it.

The dictionary’s top level is language: a zh subtree and an en subtree. When BaseLayout renders, it serializes the parts the current page needs into JSON and inlines them in a <script> attached to window.__FROSTI_I18N__.

Inside components, ts(key) first picks the subtree by language, then drills down the dot-separated path level by level: ts("games.2048.title") looks up games → 2048 → title in the zh subtree.

The fallback chain on a miss is current language → English → the key itself, a deliberately designed failure signal: when a new component ships with a mistyped key or a missing entry, the page shows not blanks but English copy or the key name.

Visible at a glance, no console needed.

The sentinel check in the pitfall section below only works because of this chain: when dictionary injection fails, every ts() degrades uniformly into key names, a symptom that cannot hide.

Variable interpolation uses {name} placeholders: the translation writes “found the path, length {n}”, and the code passes n.

The language itself is never written into keys, nor read from localStorage: the language ts() uses comes from the value SSR writes into <html lang>, read once when an island hydrates and re-read in astro:after-swap after ClientRouter navigations.

The URL switches language, and the lookup subtree follows.

The URL is the single source of truth; the dictionary merely follows its lead.

Pitfall: define:vars Double Serialization

Stripping surfaced one extremely subtle pitfall that deserves its own entry.

The standard way to inline the dictionary:

<script is:inline define:vars={{ i18nDictJson }}>
window.__DICT__ = JSON.parse(i18nDictJson);
</script>

define:vars applies one more layer of JSON serialization to strings, keeping them literal-safe inside the HTML.

I had added my own XSS hardening upstream, JSON.stringify(dict).replace(/</g, "\\u003c").

With both layers applied, \u003c got escaped a second time into \\u003c; the browser-side JSON.parse hit this invalid escape and threw, and the entire dictionary injection failed.

The symptom is deeply misleading: the page looks normal (server-rendered parts intact), but every client component’s strings have turned into key names. games.2048.title flying all over the screen.

The lesson splits in two:

  1. Choose one: XSS escaping or the serialization framework: either trust the framework’s define:vars, or take full control yourself (a hand-written is:inline script plus your own escaping). Do both and they fight each other;
  2. Put a sentinel check around dictionary injection: wrap JSON.parse in a try-catch and console.error one clear message on failure. The gap between shipping this bug and discovering it can be very long. Without a sentinel log, all you ever see is the unactionable symptom of copy turning into key names.

Size Guardrails

The enemy of slimming is backsliding: three months later, some component casually stuffs a long body back into the client dictionary and nobody notices.

The page works as always; every visitor just carries an extra 30KB.

So the guardrails must be automatic, at two levels:

  • Build-time reporting: wherever the dictionary gets serialized, console.log each subtree’s byte count (or write it into a build summary file), so every build shows “guides 0KB / widgets 84KB / 
”. Only when size becomes a visible number can it be controlled;
  • Budget thresholds: give each subtree a budget (say, the widgets subtree ≀ 100KB) and warn or fail the build when it is exceeded. The numbers need not be precise. The point of a budget is that anyone adding to it must consciously cross a threshold.

Pair this with one code habit: before adding a new top-level key to the dictionary, answer whether its consumer is the client or the server.

If you can’t answer, treat it as server-only first and move it into the client dictionary when needed.

The more carefully this upfront check is done, the fewer entries need to be moved back out of the client dictionary later.

Adding a Third Language

The current mechanism is hard-coded for zh/en: the dictionary’s top level splits into per-language subtrees, and the lookup chain is “current language → English → key”.

The mechanism itself can absorb a third language (one more subtree in the dictionary, one more entry in the SUPPORTED array), but the fallback chain has to be redesigned.

Falling back to English when the current language lacks a word means a Japanese page will mix in English copy for strings that neither Chinese nor English has.

In the bilingual era, “fall back to English” roughly equaled “fall back to the other complete language”. With three languages, the fallback strategy becomes a product decision (back to English? back to the source language? show the key to nag for translations?), worth settling before the language is added rather than letting the default behavior decide for you.

The inlined-size ledger has to be redone too: every language is a full subtree, so a trilingual dictionary’s size triples outright.

Tiered injection gets promoted from optimization to necessity.

Two Known Limitations

This scheme of nested YAML + dot-separated keys + {n} interpolation is simple and reliable, but two boundary cases deserve to be kept in mind.

Nested structure, the pros and cons.

Nesting by page/component → element (games.2048.title) keeps the YAML readable, movable as whole subtrees, and aligned with the component directory. The cost is serializing the whole object before injection, and renaming a deep key requires a global search.

A flat structure ("games.2048.title": "2048", one entry per line) is friendlier to grep and diff and slightly smaller on the wire (no object braces), but loses the operational granularity of stripping by subtree.

The stripGuides stripping in this post depends on nesting.

Both work; choosing one comes down to whether you need moving subtrees or searching keys more.

Plurals and grammatical inflection.

{n} interpolation only substitutes numbers; it cannot handle plural inflection like “1 file / 2 files”.

Copy written as “found {n} files” is simply wrong at n=1.

Chinese has no inflection, so this site escapes by luck. Doing English seriously requires a format with plural rules (ICU MessageFormat and friends), with dictionary values upgraded from strings to conditional objects.

The limitation doesn’t hurt a small bilingual site, but it’s worth thinking of ahead of the moment a count shows up in copy: either write the copy plural-neutral (“found: {n}”), or pick a scheme with plural support from the start.

Key Naming and File Organization

A dictionary’s key names are an API for humans, and the convention should be nailed down on day one.

This site’s convention is path alignment: a key’s prefix corresponds to the consuming component’s location (games.2048.title is used only by the 2048 component).

The benefit is that finding the key is finding the file. See a component and you know what its keys look like; see a key and you know which component to change. The side effect: when a component moves, its keys move with it.

The other school is semantic naming (common.save, errors.network), which reuses nicely but easily grows a junk drawer. Everything gets stuffed into common, until nobody dares touch it.

Small sites can pick either; for large sites I’d suggest path-aligned keys plus a small, explicit shared zone (global words like label.), written into the conventions.

File organization is a similar dilemma.

Split by language (one whole zh.yaml, one whole en.yaml): convenient for comparing translations, missing ones comparable at a glance, at the cost of extremely long single files and easy conflicts in multi-person collaboration.

Split by module (translations.yaml / games.yaml / tools.yaml, this site’s shape): subtrees map one-to-one onto files, and stripping and tiered injection benefit directly (stripGuides operates at the file level), at the cost of assembling across files to check for missing translations.

The module split naturally cooperates with the tiered injection above.

File boundary = injection boundary. That is the most substantive architectural payoff of splitting by module.

The Shared Methodology

With both tracks done, extracting the overlap yields the methodology of static-site slimming proper:

  • Single source of truth: the master of images is the originals in public/; the master of strings is a few YAML files. Every derivative is generated from the masters, products never hand-edited;
  • Prepay at build time: expensive conversions (AVIF encoding, dictionary serialization) happen exactly once, at build time; the runtime only distributes results;
  • Incremental and tiered: unchanged images skip recompression (hash keys), unchanged pages carry less dictionary (tiered injection). “On demand” saves real traffic every single time;
  • A single point of decision: whether an image changed is decided by one hash key; whether a page carries the full dictionary is decided by one boolean. The same question always has exactly one decision point, or the two sides drift apart;
  • Failures must be visible: orphan artifacts are exposed by reconciliation, dictionary injection failures by sentinel logs. Silent failure is the greatest enemy of slimming work.

The third (font subsetting, JSON compression, or whatever asset comes next) can be handled the same way.


Thanks for reading! Follow me if you'd like~

Build-Time Slimming

Sun Sep 27 2026
4199 words · 22 minutes