Performance
Syntax lives in the plugin READMEs. This is about which knob to reach for and what it costs you.
Minify
Off by default:
plugins.writeFilesPlugin({ clean: true, minify: { enabled: true } });Still worth it behind gzip. Gzip is good at repetition, so it already handles your indentation, but it cannot shorten a variable name or drop a dead comment. Minifying first cuts another 15-25% off the wire. More importantly, gzip only shrinks the transfer. The browser still parses the full decompressed text, and minification is the only one of the two that makes that smaller.
It does not have to be all or nothing. include and exclude globs scope it to specific files. Use them to leave something readable in the output, or to skip a vendor file that is already minified:
plugins.writeFilesPlugin({ minify: { enabled: true, exclude: ["**/vendor/**"] },});And a single inline <script> or <style> can opt in on its own, whether or not the rest of the build is minified:
<style minify> /* This will be minified */</style>
<script minify> // This will be minified</script>Styles and scripts loading
A dependency is only discovered once the file that names it has arrived and parsed. @import and import both work this way. One stylesheet that imports another which imports a third, or one module that does the same, is four sequential round trips before anything appears:
time ▸ each request starts only once the one above it finishes
styles html ███ global.css ███ @import "base.css" base.css ███ @import "theme.css" theme.css ███
scripts html ███ main.js ███ import "./utils.js" utils.js ███ import "./format.js" format.js ███CSS costs you more here, since it blocks the first paint outright, but a deep module graph is the same staircase.
bundle is the fix. It flattens the import tree into one file, so the whole staircase collapses to a single request:
<link rel="stylesheet" href="@styles/global.css" bundle /><script type="module" src="@scripts/main.ts" bundle></script>styles html ███ global.bundle.css ███
scripts html ███ main.bundle.js ███If you would rather keep the files separate, because they are shared across pages and you want them cached individually, preload solves the same problem from the other end. It walks the import tree at build time and emits a preload tag for every file it finds, so the browser starts fetching them immediately instead of discovering them one parse at a time. The requests stay separate but they stop being sequential:
<link rel="stylesheet" href="@styles/global.css" preload /><script type="module" src="@scripts/main.ts" preload></script>generates:
<link as="style" href="./styles/base.css" rel="preload" /><link as="style" href="./styles/theme.css" rel="preload" /><link as="style" href="./styles/global.css" rel="preload" />
<link as="script" href="./scripts/utils.js" rel="modulepreload" /><link as="script" href="./scripts/format.js" rel="modulepreload" /><link as="script" href="./scripts/main.js" rel="modulepreload" />Dependencies first, the file itself last, and the whole block is appended to the end of <head>. Module graphs get modulepreload rather than preload, because a plain preload does not record the file in the module map and the browser ends up fetching the whole graph a second time.
The imports are now named in the initial HTML, so they all start together instead of one parse at a time:
styles html ███ global.css ███ base.css ███ theme.css ███
scripts html ███ main.js ███ utils.js ███ format.js ███Inline
inline is a different problem, not the waterfall but a file small enough that the request costs more than the bytes do:
<link rel="stylesheet" href="@styles/pricing.css" inline /><script src="@scripts/theme-toggle.ts" inline></script>Inlined content is not a file, so nothing caches it. That is fine for something one page uses and wrong for anything shared. A 60 KB design system inlined into forty pages is 60 KB re-downloaded on every navigation.
Two things to watch. Inlining a module destroys its exports, since there is no longer a file for anything to import from. Inline entry points rather than modules that other code depends on. And bundle inline on one tag needs the bundle plugin registered before the inline one, or inline runs first and embeds the unbundled file with its imports intact.
Fonts
WOFF2 over TTF, about 30% smaller. Fonts are already compressed, so your server's gzip does nothing for them and the container is the only compression you get.
font-display is a choice about which failure you prefer:
swapshows fallback text immediately, then shifts when the font lands. Right for body copy.optionalwaits ~100 ms, then commits to the fallback for that load. No shift, but slow visitors never see your font.blockhides text for up to 3s. Only for a logotype.
Two CLI plugins cover both steps:
plugins.convertFontsCliPlugin();plugins.generateFontFacesCliPlugin();Convert first. Input must be TTF:
npx staticbolt convert-fonts --fonts "sources/fonts/**/*.ttf" -o "sources/assets/fonts"Then generate the @font-face rules, rather than writing them by hand:
npx staticbolt fontface --fonts "sources/assets/fonts/**/*.woff2" \ -o "sources/styles/fonts.css" --font-display swapIt writes one rule per file it finds, so keep that directory to the weights you actually use.
Finally, preload the one face the first paint needs, never all of them. Fonts are discovered late by definition, which is what makes preload worth it here and nowhere else:
<link rel="stylesheet" href="@styles/fonts.css" preload="font" preload-include="**/Inter-Regular*" />generates:
<link type="font/woff2" as="font" crossorigin href="./assets/fonts/Inter-Regular.woff2" rel="preload" />preload="font" keeps it to font dependencies, so the stylesheet itself is not preloaded, and preload-include narrows it to the single face. The crossorigin is not optional: without it the browser fetches the font twice and the hint buys you nothing.
Images
Set a quality. The default of 100 barely compresses, and 75-85 is invisible on photos:
plugins.convertImagePlugin({ format: "webp", quality: 80, preset: "photo" });Per-image override without touching config: diagram-q:95-p:drawing.png. preset only affects WebP output, so it does nothing if you switched format to AVIF.
Then three things that staticbolt passes straight through:
widthandheighton every<img>. Reserves the space, kills the reflow.fetchpriority="high"on the hero.loading="lazy"below the fold only. Lazy-loading the hero defers it until layout.
Build time scripts
The cheapest script is one that never ships. A build-time script runs once during the build with access to the page's DOM, its changes are baked into the HTML, and the tag itself is stripped from the output. The browser downloads nothing, parses nothing, executes nothing.
<script build-time> const toc = document.createElement("ul");
for (const heading of document.querySelectorAll("h2")) { const item = document.createElement("li"); item.innerHTML = `<a href="#${heading.id}">${heading.textContent}</a>`; toc.appendChild(item); }
document.querySelector("#toc").replaceWith(toc);</script>Anything that computes the same answer on every load qualifies.
src works too, and context passes JSON in as globals so one script can serve many pages:
<script build-time src="@scripts/build-nav.ts" context='{ "active": "docs" }'></script>The default parser is fast but has a limited DOM API. Add full-dom for jsdom when you need Canvas or getComputedStyle, at a real cost in build time.
Node modules
bundlePackagesPlugin() gives every package its own file that contains only what your pages actually import. Four lodash-es functions cost 26 KB instead of 277 KB.
How much that saves is a property of the package, not of the plugin. lodash-es ships one module per function, so taking four of them takes four. React is a single CommonJS file, so importing one hook costs the same as importing twenty.
One file per package means one cache entry per package, which is right when they change at different times, and wasteful when a page pulls in six small ones. Group those:
plugins.bundlePackagesPlugin({ chunks: { utils: ["lodash-es", "dayjs", "nanoid"] },});Never chunk a package you load with import(). Grouping it in means it is fetched with everything else on first paint, which is the opposite of what you deferred it for.
Budgets
None of this stays done, and nothing tells you when it stops being true.
plugins.analyzeOutputPlugin({ skipUnusedFiles: true, maxFileSize: { ".js": 150, ".css": 50, ".woff2": 100, ".webp": 200 },});The default only covers .js. These are warnings, not gates: the build still passes and the exit code stays 0, so check sizes yourself if you want CI to stop.
Caching and service workers
A service worker does nothing for the first visit. It is not installed yet, so that page is served from the network like any other, and the precache runs in the install event once the page has already loaded. The payoff is entirely on repeat visits and offline.
Two things it costs you:
- Traffic on every deploy. Each precached file is a request on install, and it is re-fetched in full whenever it changes.
- Late deploys. A cached page keeps being served until the worker updates, so a release does not reach visitors the moment it lands.
So globPatterns is a decision, not a default to accept. Worth precaching:
- A site installed as an app, where offline is the point and being a version behind is expected.
- Pages that are large, visited often, and rarely edited.
Not worth it:
- Anything small, rarely visited, or frequently changed.
- Images. Widening past workbox's
**/*.{js,wasm,css,html}default fetches every one of them on that first visit, whether or not the visitor ever opens the page that uses it.
If you need same-minute updates, a service worker is the wrong tool.
Planning it
Do not plan a page on its own. Plan it by where it sits in the site, because what it should do depends on what the visitor already has cached by the time they reach it.
Entry pages are the ones people arrive on cold. Nothing is cached yet, so every request is a round trip they pay for. Inline what this page needs and it arrives in a single request, then paints with no round trips at all.
This is the one place the rule against inlining shared CSS does not apply. It normally costs you the cache entry, but there is no cache yet, and the visitor may never load a second page.
Then prefetch the shared files the rest of the site uses. Prefetch is low priority and runs after the page is done, so it costs the entry page nothing, and the visitor who clicks through finds them already cached. There is no attribute for this, so write the tags yourself:
<link rel="prefetch" as="style" href="/styles/global.bundle.css" /><link rel="prefetch" as="script" href="/scripts/main.bundle.js" />Interior pages are reached by clicking through, so the shared CSS and JS are already in cache. Linking them is free. Inlining them here is the worst thing you can do, since you re-send bytes the browser already has on every navigation.
Then read the analyze output. The biggest thing in the list is where the work is, and it is rarely what you assumed. Images dominate → quality and format. JS dominates → what can move to a build-time script. Neither, but it still feels slow → that is a waterfall, not weight.
Frequent deploys veto precaching regardless, since visitors stay a version behind.
Change one thing, rebuild, look again.