The goal of this work was to make xiyo.dev feel as fast as possible as a blog. First, I removed SvelteKit runtime-based hover preloading and replaced it with Speculation Rules. Then I applied an SWR policy through _headers and fixed a Cloudflare header serialization issue that appeared during verification. Finally, I removed web fonts, which were a large part of the first-visit transfer size.
At the time of these measurements, this blog was built with SvelteKit adapter-static + Cloudflare Workers Static Assets + csr = false. It is now deployed as a Cloudflare adapter-based Worker with CSR enabled again. At that time, there was no runtime JavaScript; the menu toggle was handled by the HTML Popover API, and page transitions were handled by CSS @view-transition.
Measurement conditions and summary
I recorded the live production site, xiyo.dev, with Playwright under Chrome DevTools Slow 3G emulation and a 375 × 812 mobile viewport (iPhone-sized).
| Metric | Before (before applying changes, partly estimated) | After (production at measurement time) |
|---|---|---|
| Main CSS raw | 195 KB | 40 KB |
| Main CSS gzip | ~61 KB | 7.4 KB |
| Number of woff2 files in build output | 210 | 0 |
| Font size in build output | 7.5 MB | 0 |
| Total first-visit transfer on home | ~230 KB (estimated) | 8.7 KB |
| Home DOMContentLoaded (Slow 3G) | several seconds (estimated) | 510 ms |
Actual _headers immutable max-age |
60 seconds (neutralized) | 31,536,000 seconds (1 year) |
The CSS and font numbers are measured from the build output before the change. The Slow 3G load time before the change is an estimate because I skipped remeasuring the old version. The sections below explain the three changes in order.
Part 1. Speculation Rules
Moving from SvelteKit runtime preloading to Speculation Rules
Initially, the site used the SvelteKit client runtime to preload data on hover. The default attribute attached to <body> handled this.
<body data-sveltekit-preload-data="hover">
SvelteKit's runtime interprets this attribute and preloads the next page's data for hovered links. But keeping the entire client runtime only for this one preload behavior felt hard to justify. The menu toggle could be replaced with the HTML Popover API, page transitions could be handled with CSS @view-transition, and the goal of being listed on nojs.club made the reason to remove the runtime clear.
At that time, I removed runtime JavaScript with csr = false. As a result, data-sveltekit-preload-data was no longer interpreted. I replaced it with the Speculation Rules API. Browsers that do not support it fall back to ordinary navigation.
In practical use, Speculation Rules are mainly supported in Chromium-based browsers. Firefox is still implementing both prefetch and prerender (Bugzilla 1969396, 1969838). Safari has had same-origin prefetch work in WebKit / Safari Technology Preview, but I could not confirm stable-channel shipping from official release notes. In non-Chromium environments, this remains a progressive enhancement that falls back to normal navigation.
Speculation Rules — browser-standard preloading
Chromium-based browsers can use a declarative JSON rule, interpreted without JavaScript, to request preloading.
<script type="speculationrules">
{
"prefetch": [...],
"prerender": [...]
}
</script>
Difference between prefetch and prerender
| Mode | Scope | Behavior on click | Memory cost |
|---|---|---|---|
| prefetch | Stores only HTML bytes in disk cache | Parsing, CSS, and image downloads happen after the click | Low |
| prerender | Completes DOM, CSS, JS, and paint in a hidden process | The browser activates the prerendered document. PerformanceNavigationTiming.activationStart points to the activation moment |
Tens to hundreds of MB per page |
Prerender keeps a completed document tree in memory and switches the window to that context when the user clicks. In measurements, the delay from activation to first paint is only a few milliseconds.
Trigger conditions by eagerness
| Value | Trigger (desktop) | Touch devices |
|---|---|---|
conservative |
pointerdown |
pointerdown |
moderate (default) |
Hover held for at least 200 ms, or pointerdown |
pointerdown |
eager |
As soon as the link enters the viewport | Same |
immediate |
All matching links on page load | Same |
moderate wastes less bandwidth because it preloads only links where intent has been shown. Touch devices do not have hover, so pointerdown applies.
Configuration used on this blog
There are many post paths, so putting all of them under prerender would create high memory cost. By contrast, there are only a few landing pages such as the root, /about, and /portfolio; they are image-heavy and bounded, so prerendering them gives a clear benefit. I split the two groups like this.
<script type="speculationrules">
{
"prefetch": [
{
"where": {
"and": [
{ "href_matches": "/posts/*" },
{ "not": { "href_matches": "/nav" } }
]
},
"eagerness": "moderate"
}
],
"prerender": [
{
"where": {
"and": [
{ "href_matches": "/*" },
{ "not": { "href_matches": "/posts/*" } },
{ "not": { "href_matches": "/nav" } }
]
},
"eagerness": "moderate"
}
]
}
</script>
/nav is a popover-dialog-only route and does not need preloading, so it is excluded from both rules.
In that configuration, the blog served static HTML that the server had already prerendered. Because there was no client-side hydration, the screen swapped immediately on activation. In terms of user experience, the implementation resembled the instant transition that AMP aimed to provide.
AMP (Accelerated Mobile Pages) is a mobile web framework that Google announced in 2015. Google Search could expose AMP pages quickly through AMP Viewer and Signed Exchange paths, and the AMP Viewer path displayed URLs in the form google.com/amp/.... That path was criticized for platform dependency because it hid the publisher's domain in the address bar. In 2021, Google announced through the Page Experience update that AMP would no longer be required for Search features such as Top Stories. Publisher adoption has declined since then, but I could not confirm an exact inflection point from an official primary source.
The experience AMP provided — instant display of a prerendered document — can now be achieved by Speculation Rules on top of the publisher's own URL and a browser standard.
Part 2. Stale-While-Revalidate
Behavior
The Cache-Control header combines two directives.
Cache-Control: public, max-age=0, stale-while-revalidate=604800
The response is interpreted in three phases.
max-ageperiod — returned immediately from cache. In this example, 0 seconds.stale-while-revalidateperiod (604,800 seconds = 7 days) — an expired cached response is returned immediately while the origin is revalidated in the background.- After those two periods — normal behavior, where origin revalidation happens synchronously.
The result is that returning visitors get the previous response without waiting for the network within seven days, and the latest version is reflected on the next visit.
Policy by path
Cloudflare Workers Static Assets natively supports the static/_headers file.
| Path | Cache-Control | Intent |
|---|---|---|
/_app/immutable/* |
max-age=31536000, immutable |
Hashed URLs, immutable for one year |
/posts/*, /en-us/posts/*, /ja-jp/posts/* |
max-age=0, SWR 7 days |
Immediate return on revisit + background refresh |
*.png, *.jpg, *.webp, *.avif, *.svg, *.ico |
max-age 7 days, SWR 30 days |
Low update frequency |
/feed.xml, /sitemap.xml |
max-age 1 hour, SWR 1 day |
XML for crawlers |
/, /about, /portfolio, /nav, /404, locale roots |
max-age 60 seconds, SWR 1 hour |
Keep the delay for new posts bounded |
Problem — the /* fallback neutralized immutable
The initial _headers configuration included this catch-all rule.
/*
Cache-Control: public, max-age=60, stale-while-revalidate=3600
While checking each path with curl -I under wrangler dev --local, I found that headers were serialized differently from what I intended.
$ curl -I http://localhost:8787/_app/immutable/chunks/B4d5FnLc.js
Cache-Control: public, max-age=31536000, immutable, public, max-age=60, stale-while-revalidate=3600
Cloudflare Workers Static Assets documentation defines _headers behavior this way: when a request matches multiple rules, it inherits headers from all matching rules; if the same header appears multiple times, the values are comma-combined. Cloudflare Pages follows the same rule. However, _headers does not apply to responses generated directly by Worker code or Pages Functions. This blog serves only static assets, so the rule applies as-is.
RFC 9111 §4.2.1 specifies that when the same cache directive is duplicated, an implementation should either use the first value or treat the entire response as stale. Either way, the immutable policy intended to provide a one-year cache can no longer be relied on in a standards-compliant way. The immutable keyword also loses practical effect when it conflicts with other directives in the same response header. Images (max-age=604800 + max-age=60) and posts (max-age=0 + max-age=60) were exposed to the same structural problem, so the intended cache policy was not guaranteed.
Fix — guarantee one rule per path
I removed the /* fallback and explicitly listed the HTML pages. The design intent is recorded in the comment at the top of _headers.
# If multiple rules match, identical headers are joined with commas.
# Configure paths so that each request matches exactly one rule;
# otherwise duplicate directives can invalidate `immutable` and long-cache policies.
# Therefore, do not use a `/*` fallback.
/_app/immutable/*
Cache-Control: public, max-age=31536000, immutable
/posts/*
Cache-Control: public, max-age=0, stale-while-revalidate=604800
# ... image, feed, and localized HTML rules ...
/
Cache-Control: public, max-age=60, stale-while-revalidate=3600
/about
Cache-Control: public, max-age=60, stale-while-revalidate=3600
/portfolio
Cache-Control: public, max-age=60, stale-while-revalidate=3600
After the fix, each path returns a single header value.
$ curl -I https://xiyo.dev/_app/immutable/assets/0.D9SxcKrv.css
Cache-Control: public, max-age=31536000, immutable
$ curl -I https://xiyo.dev/posts/<slug>
Cache-Control: public, max-age=0, stale-while-revalidate=604800
$ curl -I https://xiyo.dev/
Cache-Control: public, max-age=60, stale-while-revalidate=3600
Part 3. Removing web fonts
Background — analyzing the 195 KB CSS
After applying Speculation Rules and SWR, I analyzed the remaining major transfer size. The raw size of the main CSS file was 195 KB. That is unusually large for Tailwind v4 output. The woff2 files included in the build output looked like this.
| Family | Number of subsets (observed in this build) | Total |
|---|---|---|
| Pretendard Variable | 92 | 2.9 MB |
| Pretendard JP Variable | 117 | 4.6 MB |
| Neo둥근모 Code | 1 | 39 KB |
| Total | 210 | 7.5 MB |
A large part of the 195 KB CSS consisted of @font-face declarations and unicode-range specifications referencing those 210 files.
Pretendard Variable — an open-source web font released by Hyungjin Kil (orioncactus) in 2021. It is designed to be visually compatible with Apple's system-ui environment, supports variable weights from 45 to 920, and provides dynamic subsets.
Pretendard JP Variable — a Japanese-environment variant of the project above. It provides glyphs suited for Japanese text and can select Korean-style glyphs through the OpenType ss05 feature.
Neo둥근모 Code — a coding variant in the neodgm family, which ports the Korean pixel font lineage of "둥근모꼴" to modern Unicode. It adds coding ligatures to Neo둥근모. This blog used it for code blocks.
The subset count in the table is not the official distribution spec; it is the number of files observed in this build.
The subset system reduces actual downloads, but not CSS size
Pretendard is split into subsets by Unicode range, and browsers download only the subsets required by characters that appear on the page, based on the unicode-range declarations in @font-face. Individual visitors do not download all 210 files.
However, the @font-face declarations themselves are still included in the CSS. Regardless of whether the woff2 files are actually downloaded, CSS parsing cost and first-visit transfer bytes remain. Returning visits make zero requests thanks to SWR and the immutable policy, but first-time visitors still had to receive the 195 KB raw CSS.
Font as design element vs speed — choosing speed
Fonts are a design element that determine the consistency of body typography and directly affect the visual identity of the whole blog. Pretendard's consistent glyph width and height ratios were also part of the typographic assumptions behind the blog's base layout. There is a real design loss here.
At the time, keeping 7.5 MB of web fonts after removing runtime JavaScript was inconsistent with the site's direction. Since perceived speed was the top priority, I prioritized loading performance over typographic uniformity.
I removed the three font @import lines from src/app.css, and changed --font-sans and --font-mono to OS default stacks. I also removed pretendard, pretendard-jp, and @kfonts/neodgm-code from package.json. This was the same kind of decision as the csr = false choice at the time: instead of adding an alternative on top of that layer, remove the layer itself.
Result
| Item | Before | After |
|---|---|---|
| Main CSS raw | 195 KB | 40 KB (-79%) |
| Main CSS gzip | ~61 KB | 7.4 KB (-88%) |
| Number of woff2 files in build output | 210 | 0 |
| First-visit font network requests | Variable (unicode-range dependent) |
0 |
Trade-off
The Korean font rendered by default now differs by operating system.
| OS | Korean font rendered |
|---|---|
| macOS · iOS | Apple SD Gothic Neo |
| Windows | Malgun Gothic |
| Android · ChromeOS | Noto Sans CJK KR |
- Lost: Pretendard's consistent glyph width and visual identity, and typographic uniformity across the project
- Gained: no FOIT/FOUT (OS fonts are available immediately), 88% reduction in main CSS gzip size, 7.5 MB reduction in build output, and shorter build time because Tailwind has less CSS input to process
Closing
All three changes are consistent in that they remove things rather than add them. I removed the SvelteKit preload attribute together with the runtime and replaced it with Speculation Rules; I removed the /* fallback from _headers to guarantee a single rule per path; and I removed all web font dependencies to reduce first-visit CSS to one fifth of its previous size. On the production site at measurement time, under Slow 3G and a mobile viewport, the home page's first-visit transfer size was 8.7 KB and DOMContentLoaded was 510 ms.