Return to text
In today's day and age, it's difficult to justify hand-coding a blog website. For one, platforms like WordPress, Wix, and Medium are designed to abstract away the code entirely, letting you focus on content and design. Or, if design is not your forte, you can skip that part entirely and leave it to AI. Having seen many personal websites of peers and Texas Luminescence applicants, I have witnessed AI being used for this to great effect. Many standout design elements, like animations, gradients, and shaders, and design trends, like brutalism, neumorphism, and glassmorphism, can all be wished into existence via a variety of AI genies.
There is, however, one thing you lose when you stick your blog into a premade HTML, PHP, or Next.js template: control. After building a previous iteration of rogerwang.dev that let visitors cycle through multiple templates, I came to the conclusion that, while modern design elements look nice, they no longer express much for the aspiring CRUD specialist. Thus, spurred on by an appreciation for minimalism and emboldened by existing proofs-of-concept in the Motherf*cking Website and its sequel, the Better Motherf*cking Website, I decided to rebuild rogerwang.dev from scratch in a way that lets the content speak for itself.
1. Static is fast
While hardcore minimalism limits the budget for fanciness, it affords much in the way of speed. Vibe-coded applications often struggle with performance, as they download a host of resources and rely on hydration to supply content, which clogs the main bottleneck of web performance: network latency. A static website—essentially a collection of text documents—suffers no such weakness. Optimizing this is trivial: just load everything at once, which means server-rendering HTML, inlining all CSS and JS, and relying on system fonts.
However, some things should not or cannot be inlined. To protect my email address from scrapers, I added a Cloudflare Turnstile, which uses an external script from Cloudflare. Although the script weighs only 0.2 kilobytes, it loads and runs almost 20 additional kilobytes of other scripts, which dominated the total blocking time (TBT) of my website. To address this, we just load the script in the background, which we can get away with since it is for an invisible feature independent of content (and therefore not urgent):
requestIdleCallback(() => { // only run on idle
const s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
document.head.appendChild(s); // hack to send requests by appending to the document
});
Now we have something that loads quickly, with 0 cumulative layout shift (CLS), enabled by the static nature of our content. But this is just the tip of the iceberg.
2. Anticipated is fast
The background-load trick with the Cloudflare script was neat; why not do this for full pages? The issue is there may be many linked pages in any given page, so loading all of them would risk needlessly wasting a user's internet bandwidth. Instead of doing that, we can compromise by only prefetching pages we anticipate the user will navigate to. There are a few good heuristics for this, including checking for links that are currently visible. For the sake of simplicity, we can prefetch links on hover:
const preloadCache = new Set();
function preloadPage(url) {
if (!preloadCache.has(url)) {
preloadCache.add(url);
const prefetch = document.createElement('link');
prefetch.rel = 'prefetch';
prefetch.href = url;
prefetch.as = 'document';
document.head.appendChild(prefetch);
}
}
// for each link, add prefetch handler to hover event
document.querySelectorAll('a').forEach(link => {
if (link.href.startsWith(window.location.origin)) {
link.addEventListener('mouseenter',() => { preloadPage(link.href) });
link.addEventListener('focus',() => { preloadPage(link.href) });
}
});
This enables us to hide much, if not all, of our ugly network latency in the delay between hovering and clicking, so that by the time the user releases the mouse button, the request is almost, if not already, done.
Depending on the user's browser, we may be able to do even better. Prefetching saves us a network round-trip, but prerendering saves us the entire load, including parsing the document and fetching its subresources. We can achieve this with the experimental Speculation Rules API:
<script type="speculationrules">
{
"prerender": [{
"where": {"href_matches": "/*"},
"eagerness": "moderate"
}]
}
</script>
However, since it is not always supported, we can keep the prefetching logic to guarantee some "pre" speedup.
3. Close is fast
Now we've reduced the amount of data that is requested, and we're requesting that data earlier, but these are merely ways to work around latency; clicks may feel more instant, but direct visits will still feel the load. What if we could actually reduce the latency? The only way to do that is to reduce the distance that the data has to travel—which we actually can do.
First, instead of having every request make a full trip to our server in Sweden, we can cache content in Cloudflare's edge network, so for subsequent requests from anyone in the same region, our server might as well reside in that region. This reduces request time (under certain conditions) from 200ms to sub-50ms. To maximize this effect, we crank up the cache time-to-live (TTL) as much as possible, with Cloudflare's limit being 31,536,000 seconds, or one year. Since users will rarely be making full trips to our server, many performance-impacting parts of the website are made insignificant, including the server location and web framework (as long as the website is server-rendered, which, as a static website, it is).
But we can cache requests somewhere even closer: on-device. Disk reads reduce request time again from 50ms to sub-10ms. To implement this, browsers already support it and will cache content on disk based on whatever policy is specified in the response headers. So, of course, we will specify the maximum TTL of one year:
Cache-Control: public, max-age=31536000
No free lunch
There is, however, a fatal flaw in our formidable 'formance fortress. Since users will cache pages for an entire year, they will only be able to see updates once per year! The browser will follow the cache policy obediently, so it is entirely up to the user to clear their cache when they want to check for updates. This is tedious and hardly common knowledge—not an option for a website that seeks to offer content delivery superior to that of a carrier pigeon.
And so we must decide on a harsh tradeoff. If we shorten the TTL, users will lose out on the speed of disk reads; if we lengthen the TTL, users will lose out on insightful content being published. What if there was a way to fetch updates outside of regular browser requests?
Meet stale-while-revalidate. This is a cache policy that serves stale, cached content while sending revalidating requests in the background, allowing users to check for fresh content no matter how long the TTL is. The "only" downside is that they will have to make 2 requests to see the changes: one stale request to trigger revalidation and one more request to see the new cached content.
In fact, requiring two clicks is not the only downside. While stale-while-revalidate works on paper, in practice, browsers don't respect it for HTML documents, as it is intended for subresources.
The solution? We can bypass the browser's behavior by writing the policy ourselves.
4. Background revalidation is fast
To do this, we need a way to manage the cache. This can be done with a service worker, which is a proxy server that is installed by the browser and can intercept requests for a website, even if offline. For our purposes, we will use it to manually simulate the stale-while-revalidate logic. On each request, if it is cached, serve the cached response and initiate a revalidation request in the background. If it is not cached, forward the request as normal.
const CACHE = 'pages';
self.addEventListener('fetch', event => {
event.respondWith(caches.open(CACHE).then(async cache => {
const revalidate = fetch(event.request, { cache: 'no-cache' })
.then(response => {
if (response.ok && response.url === event.request.url)
cache.put(event.request, response.clone()); // update with new content
return response;
});
const cached = await cache.match(event.request);
if (!cached) return revalidate; // return revalidation response
event.waitUntil(revalidate);
return cached; // otherwise use cached response
}));
});
This can be further improved upon with the fact that prefetches, which are not urgent, do not need the speed of a cached response. Therefore, we can always skip the cache on prefetch, which enables users to get fresh content in one click: the hover will trigger a cache update and the click will fetch from the updated cache.
Thus, we are able to have our cache and eat it too, utilizing maximum cache TTL while still getting fresh content relatively quickly.
Results
All of these optimizations combine to create practically unbeatable performance. rogerwang.dev achieves desktop metrics of sub-600ms largest contentful paint (LCP) on cold load, sub-10ms LCP on link navigation, and 0 TBT. In layman's terms, this translates to minimal loading on first visit, instant click navigation, and no delay in input responsiveness.
Don't just take it from me; rogerwang.dev achieves a 100/100 Cloudflare Observatory speed score on mobile, 100/100 Google PageSpeed Insights score on mobile, and a 100/100 GTMetrix Performance score. At this point, the bottlenecks are the latency of Cloudflare's edge servers and the execution time of your browser extensions.
This website is freaking fast—probably the fastest website you've ever visited.
So why bother with any of this? The point was not just to chase Lighthouse scores. The point was to demonstrate the power of minimalism with intention, to make my website stand out without a flashy UI. Minimalism does not entail a lack of engineering. As with all tradeoffs, it has its own direction for depth. And after all, exploring a design space by balancing constraints is what problem solving is all about!