Post
Most Websites Are Documents. Stop Shipping Them as Applications.
Here is the claim, stated early so you can disagree with it. Most websites answer questions whose answers were known when the page was written, such as what the product costs or how to install it. A page like that does not need a process per request, a database behind it, or a JavaScript runtime in the browser to draw text that was already text.
Most of those pages are shipped as if they did. That choice is paid for in bytes, in build minutes, in code an attacker can reach, and in errors nobody sees. All four can be measured, so this post measures them on this site, which ssg builds from its own repository.
Bytes: what the generator wrote, and what we added
The median mobile home page weighed 2,311 KB in October 2024, according to the HTTP Archive Web Almanac. JavaScript alone was 558 KB of that.
This is what a phone downloads for this site's home page:
| What | KB on the wire |
|---|---|
| HTML, CSS and JS written by ssg and the theme (gzip) | 24 |
| Two logo images | 23 |
| Hero photo, phone size, WebP | 164 |
| Web fonts, four files | 96 |
| Analytics tag | 179 |
| Whole page on a phone | 486 |
| Median mobile home page, 2024 | 2,311 |
The median is not the uncomfortable line. Ours is. The largest file on this home page is the analytics tag: 179 KB compressed, more than seven times everything the generator wrote. We chose that tag. We also chose the fonts, and they outweigh the markup four to one.
A static generator does not make a page light. It makes the weight visible, because nothing arrives that somebody did not add on purpose. A client-side framework rendering a page of prose works the other way round. Its runtime is the floor, paid on every page before the first word appears, whether that page has a button on it or not.
Time: a build nobody waits for
A build that takes minutes changes how people work. They stop previewing, and they merge a typo fix without looking, because looking costs a coffee.
make bench generates a synthetic blog with a fixed random seed and times a
full build. On a 16-core Ryzen 9 7950X under WSL2, with ssg built from this
commit, best of three runs:
| Posts | Pages written | Build | Per page |
|---|---|---|---|
| 100 | 106 | 0.07 s | 0.66 ms |
| 500 | 526 | 0.19 s | 0.35 ms |
| 2,000 | 2,101 | 0.62 s | 0.30 ms |
| 5,000 | 5,251 | 1.46 s | 0.28 ms |
The cost per page falls as the site grows. The real site does more work per page: WebP images, a feed, a search index, a sitemap, a Markdown copy of every page for agents, and a strict check of every internal link. With this post it has 112 HTML pages and builds in 1.61 s on the same machine.
A CMS on a database skips the build and pays at request time instead, on every uncached request, for as long as the site exists. Caching fixes that. A full-page cache in front of a CMS is a static site with a database attached to it that the visitor never needed.
Attack surface: nothing of ours runs when a page is read
A folder of HTML on a CDN has no process of ours that runs when a visitor asks for a page. There is no login form to brute-force and no database to inject into. Nobody has to patch a server that does not exist.
Two caveats, because they are true. This site does run code on requests: two
small functions, for comments and for cookie consent, both opt-in, both in the
repository's workers/ directory where anyone can read them. And a static build
still has a supply chain: the generator and everything it pulls in. We learned
that on our own package.
| Artifact | MB |
|---|---|
| snap, 1.8.62 | 82.8 |
| snap, 1.8.63 | 23.3 |
| Linux amd64 tarball, 1.8.63 | 13.9 |
In 1.8.63 the snap went from 82.8 MB to 23.3 MB. The cause was cwebp. Ubuntu's
webp package ships an OpenGL image viewer, and that viewer pulled 50 packages
into the snap, Mesa, LLVM and libxml2 among them, for a tool that never opens a
window and never parses XML. The Snap Store then flagged libxml2 as outdated.
cwebp is now built from libwebp 1.6.0 with only JPEG and PNG input, and the snap
workflow fails if any of that stack comes back. The same release cleared the 12
findings gosec reported and made gosec a gate: a new finding now fails CI
instead of sitting in a report.
The general rule is plain. Every dependency is attack surface you did not write. A plugin-driven CMS asks you to install dozens of them, run them on every request and keep all of them patched. A static site asks you to trust yours once, at build time, on a machine you control.
Silence costs more than slowness
The last cost is the one nobody benchmarks. A slow tool wastes minutes, and you can see it doing so. A tool that hides errors wastes days, and you hear about it from a visitor.
1.8.62 fixed fourteen bugs of that kind, and every one of them had a green build. Pages were dropped without a word because a description contained an unquoted colon. English visitors got a Romanian 404 page. On a three-language site, 21 of 27 sitemap URLs carried language alternates, and the six without them were the front pages and blog listings, the URLs search engines weight most. Nothing failed. All of it was wrong.
So ssg treats silence as a bug. --check-links=strict fails the build on a
dead internal link. shortcode_errors: strict fails it on a shortcode that
could not render. An unknown key in the config file is reported by name rather
than ignored. The workflow that publishes this site runs the strict link check
on every pull request, so this post could not have merged with a broken link
in it.
Move rendering into the browser and a whole class of those errors moves with it. The build still passes. The failure happens on a visitor's phone, and the only log you get is an email asking why the page is blank.
When this is the wrong tool
If the page changes per visitor, it is an application. Build an application. Dashboards, carts, anything behind a session: a static generator cannot help there, and forcing it leads to a site rebuilt every minute by a cron job.
If your editors need a web form and will never open a Markdown file, that is a real constraint. Solve it without putting a database on the path every reader takes.
Documentation, blogs, product pages and release notes are documents. Ship them as documents.
Check the numbers
Every figure above comes from something you can run:
1make bench # build times, synthetic corpus
2go build -o build/ssg ./cmd/ssg
3./build/ssg --config docs-site.yaml --check-links=strict # this site, 112 pages
Page weights are the built files in .site/: gzip level 9 for text, raw bytes
for images, the phone-size hero because that is the one the stylesheet serves
below 768 px. The fonts and the analytics tag were fetched from their hosts as
a browser would request them; the 6 KB font stylesheet is left out of the
total. The 23.3 MB is the Snap Store's download size for the current snap,
82.8 MB is what the 1.8.62 snap weighed before the fix, and the tarball size is
from the GitHub release. If your machine gives different build times, post
them in the comments. A laptop will be slower, and that is worth knowing too.