Medium wave · 540–1600 kHz58 stn
1455kHz

Your Build Time Is a Performance Budget

A colossal brass foundry conveyor stretching away into fog, each glowing casting stamped a fraction slower than the last, neon amber pressure gauges along the wall creeping toward their red limits in a dark steampunk machine hall

Nobody wakes up and decides to make the build eleven minutes long. It arrives one pull request at a time: three seconds for a new image variant, four for a remark plugin, six for a content query that got a little sloppy. Every increment is defensible. The sum is a deploy pipeline you now schedule around.

Static sites are supposed to be the ones with the fast feedback loop. That’s a large part of why we picked them. But we put budgets, dashboards, and CI gates on the runtime — LCP, bundle size, Core Web Vitals — and we put absolutely nothing on the build. The build is the one number on a static site that degrades monotonically and silently, and it’s the one number nobody owns.

Treat it like what it is: a performance budget with a threshold, a measurement, and a gate.


The cost isn’t the minutes

The CI bill is the least interesting part.

Rollback latency is the real one. On a static site, your rollback story is usually “redeploy the previous commit.” If a build takes eleven minutes, your worst-case time-to-recovery during an incident is eleven minutes plus CDN propagation — regardless of how trivial the revert is. A slow build quietly converts every production mistake into a longer outage.

Preview deploys stop being useful. The value of a per-PR preview is that a reviewer clicks it while they still have the diff in their head. Past a few minutes, they review the code and never open the link.

Contributors start batching. When a typo fix costs eleven minutes of pipeline, people stop shipping typo fixes. You feel this as “content velocity dropped” and diagnose it as a people problem.

And the ending is the worst part: someone eventually proposes moving off static rendering entirely, because “the build doesn’t scale.” Nine times out of ten the build doesn’t scale because there’s an accidental O(n²) in a content query, not because pre-rendering was the wrong architecture. You’re about to trade a fixable bug for a permanently more complicated system.


Measure before you guess

The instinct is to start deleting plugins. Don’t. Every build is dominated by one or two phases, and it’s rarely the one you’d bet on.

Most frameworks already tell you more than people read. Astro prints per-page build times in its output; Next.js prints a route table with per-route sizes and rendering strategy. Read them. Then wrap the whole thing so you have a number over time rather than a vibe:

// scripts/timed-build.mjs
import { spawn } from 'node:child_process'

const start = process.hrtime.bigint()
const child = spawn('npm', ['run', 'build'], { stdio: 'inherit', shell: true })

child.on('exit', (code) => {
  const seconds = Number(process.hrtime.bigint() - start) / 1e9
  console.log(`\nbuild: ${seconds.toFixed(1)}s`)
  process.exit(code ?? 0)
})

Run it on the last ten tags. Now you have a slope, and the slope tells you whether you have a growth problem or a one-commit problem.


Where the time actually goes

Four culprits account for nearly every slow static build I’ve looked at.

1. Image processing

Image optimization is CPU-bound, and its cost is images × variants × formats. A responsive image helper with eight widths and two formats turns 200 source images into 3,200 encodes. That’s not a bug, but it is a decision, and most people never made it consciously — they took the default widths array and moved on.

Two fixes, in order:

  • Cut the variant count. Four widths covers real device diversity for most layouts. Going from eight to four halves the most expensive phase in your build.
  • Persist the cache across CI runs. Image pipelines are content-addressed: same source, same options, same output. That work is perfectly cacheable, and by default in CI you throw it away every single run.
- uses: actions/cache@v4
  with:
    path: |
      node_modules/.cache
      .astro
    key: build-cache-${{ hashFiles('src/**', 'content/**', 'package-lock.json') }}
    restore-keys: build-cache-

The restore-keys line is what makes this work. Without it you only get a hit on an exact key match, which almost never happens, and you’ve written a cache step that caches nothing.

2. The accidental O(n²)

This is the one that turns a linear build into a wall. It usually looks like a helpful feature:

// Every page loads and scores every other page.
// 50 posts = 2,500 comparisons. 500 posts = 250,000 — and each
// call re-reads the entire collection from disk.
export async function getRelated(post) {
  const all = await getCollection('posts')
  return all
    .filter((p) => p.slug !== post.slug)
    .map((p) => ({ p, score: overlap(p.data.categories, post.data.categories) }))
    .sort((a, b) => b.score - a.score)
    .slice(0, 3)
}

Called from 500 page templates, getCollection runs 500 times and the scoring runs 250,000 times. The fix isn’t cleverness, it’s hoisting: compute the relationship map once for the whole build, then index into it.

// Built once at module scope, shared by every page that imports it.
const index = (async () => {
  const all = await getCollection('posts')
  const byCategory = new Map()
  for (const p of all) {
    for (const c of p.data.categories) {
      byCategory.set(c, [...(byCategory.get(c) ?? []), p])
    }
  }
  return { all, byCategory }
})()

export async function getRelated(post) {
  const { byCategory } = await index
  const candidates = post.data.categories.flatMap((c) => byCategory.get(c) ?? [])
  return dedupeBySlug(candidates, post.slug).slice(0, 3)
}

Module scope is the whole trick. Your build imports that module once, so the expensive work happens once, and every page pays a map lookup instead of a full scan.

The tell for this class of bug: build time grows faster than page count. If doubling your posts more than doubles your build, there’s an n² in there somewhere.

3. Serial network calls

Build-time data fetching is the right pattern — it’s the reason the runtime is fast. It’s also the easiest place to accidentally serialize hundreds of round trips, because await inside a loop looks completely normal.

A 200ms API call made once per page across 300 pages is a minute of pure waiting, on a machine doing nothing. Fetch once at module scope and let every page read from the resolved result. If the data is genuinely per-page, batch it or bound the concurrency — but check first, because it usually isn’t.

Anything you fetch from a third party is also a build-time dependency on someone else’s uptime. A slow build is annoying; a build that fails because an upstream API is down is an outage you don’t control.

4. Plugin chains that re-parse

Every remark or rehype plugin walks the tree again. Most are cheap and a few are not — syntax highlighting in particular does real work per code block. That cost is usually worth paying. What’s not worth paying is doing it twice because two plugins each make their own pass over the same content, or because a plugin you added for one page runs on all 500.


Set the budget, then enforce it

A number nobody checks is a wish. Commit the threshold and fail the build when it’s exceeded, the same way you’d fail on a type error or invalid frontmatter:

// scripts/check-build-budget.mjs
const BUDGET_MS_PER_PAGE = 120
const BUDGET_TOTAL_S = 180

const { totalSeconds, pageCount } = JSON.parse(
  await readFile('build-stats.json', 'utf8')
)
const perPage = (totalSeconds * 1000) / pageCount

if (perPage > BUDGET_MS_PER_PAGE || totalSeconds > BUDGET_TOTAL_S) {
  console.error(
    `Build budget exceeded: ${totalSeconds.toFixed(1)}s total, ` +
      `${perPage.toFixed(0)}ms/page ` +
      `(budget: ${BUDGET_TOTAL_S}s, ${BUDGET_MS_PER_PAGE}ms/page)`
  )
  process.exit(1)
}

Milliseconds per page is the metric that matters. Total build time is allowed to grow — you’re adding content, that’s the point of the site. Per-page cost is not. When per-page cost rises, something structural changed, and you want to hear about it on the pull request that changed it rather than six months later.

Set the initial numbers from your current build plus roughly 25% headroom. The budget’s job isn’t to be aspirational, it’s to catch the day a single commit adds 40%. Ratchet it down when you land a real improvement, so the gains don’t get silently spent.


Cacheable requires deterministic

Every one of these fixes leans on caching, and caching only works if the build is reproducible. If your build embeds a timestamp, iterates an unordered map, or resolves an unpinned dependency, your cache keys are lying to you — and worse, you’ll get intermittent cache hits that produce different output than a cold build.

Build it twice and diff the output before you invest in caching infrastructure. Determinism first, then speed. In the other order you’ve just built a fast way to ship the wrong artifact.


Know when to stop

The goal is a bounded build, not a zero-second one. If a full cold build lands under a couple of minutes and the dev server is instant, you’re done — go work on something a user can see. Grinding 90 seconds down to 60 is a hobby, not an engineering priority.

What matters is that the number is measured, has a ceiling, and fails loudly when something crosses it. On most static sites today it has none of those. It’s the one performance metric in the entire stack that’s permitted to get worse forever, on a site whose whole pitch is that it’s fast.

Give the build a budget. It’s the cheapest gate you’ll add this quarter, and it’s the one that stops somebody from proposing an architecture rewrite to fix a nested loop.