An eleven kilobyte home page: the case for static sites
What pre rendering buys you, what it costs, and the build pipeline, page weight budget and caching rules that keep a static site small and boring.
The home page of this site is about eleven kilobytes over the wire, including the CSS. There is no process running to produce it, no database behind it and nothing to restart when something goes wrong, because nothing about it happens at request time. That is not nostalgia and it is not a limitation I am working around. For a page whose content is the same for every visitor, a file on disk is the correct implementation, and most of the engineering effort goes into keeping it that way.
What you actually get
Pre rendering removes a category of problem rather than making it smaller. There is no runtime, so there is no memory leak, no worker that wedges, no process manager to configure and nothing to keep alive across a reboot. There is no database, so there is no connection pool to exhaust and no migration that has to line up with a deploy. Hosting is whatever can serve files, which means a CDN, an object bucket or the cheapest machine you can rent, and the load profile is flat because serving a file a thousand times costs a thousand times almost nothing.
The security story is the part people underrate. Most web vulnerabilities need a request handler to reach: a query built from input, a template rendered with user data, a session to steal, an upload path to abuse. A directory of files has none of those. What remains is the server configuration itself, which is a small and well understood surface, though not a zero one, as a misconfigured root exposing your .env shows clearly enough.
Rollback is the last one, and it changes how a deploy feels. The previous build is still a directory. Going back is moving a symlink, which takes a millisecond and cannot half succeed. There is no state to migrate backwards and no cache to warm, so the decision to revert costs nothing and gets made early, which is exactly when reverting is cheap.
What you give up
- Anything that differs per visitor. A name in the corner, a cart, a price that depends on who is asking. You can paint it in afterwards with a request from the browser, but then you have a runtime again, just one you can see less clearly.
- Forms. Something has to accept a POST. A hosted service, a small function, or one endpoint on a server you already run.
- Freshness bounded by build time. If a build takes twenty minutes, a typo fix is an event rather than a keystroke, and people stop fixing typos.
- Search, unless you ship an index. For a few hundred pages a prebuilt JSON index is fine. For tens of thousands it is not, and that is a real fork in the road.
The line is not about the size of the site or how modern it is. It is about whether two visitors asking at the same second would receive the same bytes. If they would, that page can be a file. If they would not, that page needs a server, and it can have one without dragging every other page along with it.
The build that produces it
Three things make a static build worth having, and all three are easy to get wrong.
The first is that the content has to be in the HTML. A page that ships an empty container and fills it from the browser has the cost of a dynamic site with none of the benefits: the visitor waits for a script, the crawler may or may not wait at all, and a failed request shows a blank page instead of an old one.
The second is content hashed asset names, so that caching can be aggressive without a stale file ever reaching anybody:
import { createHash } from 'node:crypto';
function emit(name, body) {
const hash = createHash('sha256').update(body).digest('hex').slice(0, 8);
const [base, ext] = [name.slice(0, name.lastIndexOf('.')), name.slice(name.lastIndexOf('.'))];
const out = `/a/${base}.${hash}${ext}`;
files.set(out, body);
return out;
}
const cssUrl = emit('site.css', css); // /a/site.b3f19a27.cssThe third is a budget that fails the build, because a target nobody enforces drifts upward by a kilobyte a month:
const BUDGET = { '/index.html': 14 * 1024, '/blog/index.html': 20 * 1024 };
for (const [route, limit] of Object.entries(BUDGET)) {
const size = Buffer.byteLength(files.get(route));
if (size > limit) {
throw new Error(`${route} is ${size} bytes, budget is ${limit}`);
}
}Fourteen kilobytes is not a magic number, but it is close to what fits in the first few round trips of a new connection, which is the difference between a page that appears and a page that arrives. Inlining the critical CSS into the head is what lets the first screen render without a second request, and that trick interacts with your security headers in a way worth reading about separately in a strict content security policy with an inline first paint.
Caching rules
The whole caching story fits in two rules, and getting them backwards is the most common mistake on an otherwise fast static site:
# HTML: the address never changes, so it must be revalidated
location / {
add_header Cache-Control "public, max-age=0, must-revalidate" always;
}
# hashed assets: new content gets a new address, so never revalidate
location /a/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}must-revalidate with a zero lifetime does not mean the file is downloaded every time. It means the browser asks, and with an ETag in place the answer is usually a 304 with no body, which is a few hundred bytes. immutable on a hashed asset means the browser does not even ask, which is the single biggest win for a returning visitor.
Fonts and images belong in the hashed bucket too. Subsetting the fonts and matching the fallback metrics is what keeps them from causing a layout shift while they load, which I went through in subset the fonts, match the fallback metrics.
How to check it worked
Measure the page as delivered, not as it exists on disk, because compression changes the answer by a factor of three:
curl -so /dev/null -w 'bytes=%{size_download} ttfb=%{time_starttransfer}\n' \
-H 'Accept-Encoding: br, gzip' https://example.com/
# bytes=11204 ttfb=0.061
curl -sI https://example.com/a/site.b3f19a27.css | grep -i cache-control
# cache-control: public, max-age=31536000, immutable
curl -sI https://example.com/ | grep -i "cache-control\|etag"
# cache-control: public, max-age=0, must-revalidate
# etag: "a91c4e2f"Then confirm the content is really there, without a browser running any script:
curl -s https://example.com/ | grep -c "<h1"
# 1A zero there means you built a dynamic site that happens to be hosted on files.
What to watch out for
- Inlined CSS is paid for on every page. One stylesheet across ten pages is cheaper in total than ten copies of it, so inline only the rules the first screen needs and load the rest with a hashed file. Measure the crossover on your own site rather than trusting a rule of thumb.
- A slow build is a quality problem, not just an annoyance. Past a few minutes, people batch their changes, stop fixing small things and review less carefully because the feedback loop is too long to use.
- Static plus two hundred kilobytes of script for an animation is not static in any way a visitor can feel. The runtime moved from the server to the phone, where it is slower and harder to see.
- A form that posts to somebody else's endpoint sends your visitors to them. That is a decision with legal weight in several places, and it is often more expensive than the few lines an endpoint of your own would cost.
- Deleting a page is not enough. The old address is still in caches, in links and in search results, so leave a real response there for a while rather than a redirect you might reverse later.
This approach survives because the list of things that can go wrong is short enough to hold in your head, which has nothing to do with static sites coming back into fashion. A file server does not have a bad night. The work moves to the build, where it is deterministic, testable and cheap to run twice, and what reaches the visitor is the result rather than the recipe. Start every project by asking which pages genuinely differ per visitor. On most sites that list is much shorter than the architecture suggests, and everything not on it can be a file.
Questions and answers
- Is a static site still a reasonable choice for a business site?
- For a site whose pages look the same to every visitor, yes, and it is usually the cheapest correct answer. Marketing pages, documentation, portfolios, catalogues and blogs all fit. The moment a page has to show a logged in person their own data, that page needs a server, and you can add one for that page without giving up the rest.
- How do forms work without a backend?
- Something has to accept the POST. That can be a hosted form service, a small function, or a single endpoint on a server you already run. The last option costs a few lines and keeps visitor data in your own hands, which is often cheaper than the privacy review that a third party endpoint triggers.
- What cache headers should a static site send?
- Send HTML with a short lifetime and a validator so the browser asks every time and usually gets a 304, because the address of a page never changes while its content does. Send assets whose file name contains a content hash with a one year lifetime and immutable, because a change produces a new file name. Mixing the two up is the most common static site performance bug.
- How small should a page be?
- Small enough that the first screen arrives in one round trip on a slow connection, which in practice means the HTML plus the inlined CSS fitting in roughly fourteen kilobytes. That is a target rather than a law, but a budget that fails the build is worth more than a target nobody checks.