Why Is the New Blog So Fast? From Stack Choice to Every Byte

Translated from the Chinese original. Read the original in 中文

The polar bear mark on a deep blue gradient
Contents

I rebuilt the blog. The most obvious change is that any page appears almost the moment you open it. That did not come from a more expensive server. It came from removing, at every layer, everything that was not needed, starting with the choice of stack. This article explains the trade-offs: why a static site and why Astro, what the build pipeline looks like, what CSS, JavaScript, fonts, images, page transitions and SEO each do, and the numbers that show the effect.

The result first

Production numbers for a typical post page (mobile Lighthouse with simulated slow network):

MetricValue
HTML (gzipped)16–18 KB, styles included
CSSabout 20 KB, inlined in the page, zero extra requests
JavaScript on first paintabout 6 KB (theme switch, search launcher, copy button, link prefetch)
Web fonts0 bytes
Third-party requests on first paint0
LighthousePerformance 100 · Accessibility 100 · Best Practices 100 · SEO 100
CLS (layout shift)0

None of this comes from one trick. It is the sum of doing a little less at every layer.

Why a static site, and why Astro

A blog is finished writing handed to readers; the content is fixed the moment I commit it. So there is no reason to render it on a server for every visit, and even less reason to make the reader's browser download an application framework and "hydrate" the article. The first decision was therefore: generate all HTML at build time and let the server, really a CDN, only send files.

While choosing, I built several candidates and measured how much JavaScript each ships on a prerendered post page:

StackJS on a post page (gzip)Notes
Astro 7 (static output)0 Bzero JS by default; "islands" load on demand when interaction is needed
TanStack Start 1.0 (React)about 108 KBstill hydrates the whole page after prerendering; no zero-JS mode
TanStack Start (Solid)about 48 KBsame, with a smaller runtime
Next.js App Routertens of KB plus the RSC payloadan archived page of a well-known blog weighed 645 KB of HTML, 61% of it inline component data

The TanStack family is excellent for applications, but its Markdown package has only just reached 1.0 and its official example parses Markdown in the browser, the opposite of what a content site wants. In Astro, content collections, image optimisation, Shiki highlighting, sitemap and RSS are first-class, and a page ships not a single byte of JavaScript unless asked. That is exactly what I needed.

I also measured a React component library in the HeroUI style: one button island costs 96 KB, about 70 KB of which is React. In the end I borrowed only its visual language; every button, chip and card is a zero-JS Astro component.

The build pipeline

graph LR
  A[content/posts/*.md] --> B[content collection + Zod validation]
  B --> C[remark plugins<br/>excerpt · reading time]
  C --> D[rehype plugins<br/>heading anchors · code-block chrome · tables · sections]
  D --> E[Shiki highlighting<br/>light + dark]
  E --> F[page HTML<br/>CSS inlined]
  F --> G[dist/ + Pagefind index + OG images + sitemap]

A post is a Markdown file under content/posts/, and its frontmatter is validated with Zod: a malformed date or a slug containing Chinese or spaces fails the build instead of surfacing after deployment. Markdown goes through a remark/rehype pipeline: everything before <!-- more --> becomes the excerpt; reading time counts Chinese at 300 characters per minute and English at 200 words per minute, separately; every heading gets a stable id and an anchor; code blocks are wrapped in a shell with a language label and a copy button; each h2 section becomes a <section> with content-visibility: auto, so a long article only lays out what is visible.

Syntax highlighting happens at build time. Shiki emits both the light and the dark palette as CSS variables on every token, so switching themes only swaps a set of variables and no highlighter ever runs in the browser.

CSS: 20 KB, inlined, no flash

Styles use Tailwind v4 with design tokens (colours, type sizes, radii) in @theme; components reference tokens only. At build time Tailwind emits only the utilities actually used, about 20 KB for the whole site, and inlineStylesheets: 'always' writes them straight into every page: one request fewer, and the first paint waits for no external file.

I set a hard budget: the inlined CSS of a post page must stay under 20 KiB, re-measured after every style change. It nearly broke twice, and both causes are instructive. Tailwind scans source files for anything that looks like a class name, so property names such as transition or grid written inside component styles were emitted as utilities into the global stylesheet; the fix is an exclusion list for those words. The other time, styles needed only on post pages ended up in the global stylesheet, because Astro bundles component styles by the import graph, not by whether a component rendered.

Dark mode is applied by an inline script of under 1 KB in <head> that sets data-theme before the first paint, so there is no light-then-dark flash. The dark background is a near-black #121214 rather than pure black, which is easier on the eyes for long reads, and hierarchy is expressed with lighter surfaces instead of outlines.

JavaScript: on demand, and small

A post page carries four small scripts: the theme switch (1.0 KB), the search launcher (1.4 KB), code copying (0.5 KB) and link prefetching (2.4 KB). Everything heavy is deferred:

  • Comments (Disqus): the embed script is injected only when the comment area comes within 600 px of the viewport.
  • Search (Pagefind): the static index is built at build time; the UI and index chunks load when the search box opens.
  • Diagrams (Mermaid): the library is several megabytes, so it is dynamically imported only on pages that contain a diagram, when the diagram nears the viewport, and it re-renders after a theme switch.

No React, no client-side router, no Markdown parsing in the browser.

Fonts: not a single byte downloaded

Chinese web fonts easily reach several megabytes, and even subset they weigh on the first paint. The site uses the device's own font stack instead: SF Pro and PingFang on Apple platforms, Microsoft YaHei on Windows, Noto Sans CJK on Linux and Android. Icons are inline SVG, not icon fonts.

Images: prepared at build time

Images in a post folder are converted to WebP by sharp at build time, with a srcset at several widths from 640 to 1280 px; sizes matches the 832 px text column, so a phone never downloads the desktop size. Every image carries its intrinsic width and height, so the page never jumps while images load, which is what keeps CLS at zero. The cover is the largest element above the fold (the LCP), so it loads eagerly with fetchpriority="high"; body images use native lazy loading.

Page transitions: like turning a page

Three things stack up:

  1. Links are prefetched on hover (Astro's prefetch, 2.4 KB).
  2. Chromium's Speculation Rules: a block of JSON that lets the browser prerender the next page in the background on hover, with zero JavaScript.
  3. Cross-document View Transitions: pure CSS, @view-transition { navigation: auto }, for a crossfade between pages.

The third one hid a trap. The browser captures the new page at its first rendering opportunity, and Chromium renders before the <body> is fully parsed, so the crossfade landed on a blank page and the content popped in afterwards. The fix is a single tag that makes rendering wait until the end of the page has been parsed:

html
<link rel="expect" href="#page-end" blocking="render">

Pages are only a few dozen kilobytes, so this line costs no first-paint time and buys a complete transition.

SEO and URLs

  • ASCII URLs only: Chinese titles become pinyin slugs automatically; the old Hexo links (with spaces and Chinese) are kept in the frontmatter aliases. The build writes a redirect page with a canonical link for each old URL, and on Vercel they become real 308 redirects, so search rankings migrate smoothly.
  • An Open Graph image per post is rendered at build time with satori and resvg (the Chinese font is downloaded as a per-text subset and cached), so shares on social platforms get a picture.
  • Sitemap with lastmod, full-content RSS, Atom and JSON Feed, and JSON-LD BlogPosting and breadcrumb markup.
  • Chinese and English: the English version is a .en.md file next to the source, with hreflang and its own feeds. Translations can be written by hand or generated in bulk through DeepL, Google or a language-model API; each one records a hash of the source and is flagged as stale when the source changes.

Hosting

The site is deployed on Vercel, with static files served from the CDN. Hashed assets under /_astro/* are cached for a year and marked immutable; HTML uses must-revalidate; security headers and the trailing-slash policy live in vercel.json. CI runs pnpm build together with type checking and more than five hundred unit tests.

Trade-offs and things left undone

  • Disqus's iframe prevents bfcache (the browser's back/forward cache). That is the price of third-party comments; loading it only when scrolled into view keeps the impact minimal.
  • Mermaid is large, so it loads on demand; if diagrams become common, rendering them to SVG at build time would be the next step.
  • Pagefind has no Chinese stemming; substring search is good enough.
  • Pinyin slugs read worse than English ones, so there is a command that generates English slugs with a language model and turns the old slugs into aliases.
  • No runtime UI library at all. Should a HeroUI-style interactive component ever be needed, it will live in an island that loads only after a click.

How it is measured

Every change is followed by a mobile Lighthouse run, plus a script that measures the inlined CSS bytes and the total script size of a post page; exceeding the budget counts as a failure. The tools are simple. The hard part is keeping "do less" as a rule.

The whole codebase is open source. If you want to build your own, a single pnpm build turns the Markdown in content/ into a site like this one.

Comments