·3 min read

How we cut our blog build time from 9s to 1.4s

A walkthrough of the bottlenecks we found in our MDX pipeline and the cache strategy that fixed them.

BA
Beka A.
Founder
npm run publish

When we launched the public blog, our first build took 9.2 seconds to process 12 MDX files. That's slow enough to be annoying in development and slow enough to push us past Vercel's free-tier build time budget at scale. Here's how we got it to 1.4s.

The three bottlenecks

1. Cold Shiki startup

rehype-pretty-code uses Shiki for syntax highlighting. Shiki loads grammar files from disk on first use — and it was doing that separately for every file in our pipeline.

The fix: hoist the highlighter instance out of the per-file pipeline and reuse it across calls. Shiki's createHighlighter is async and expensive once; subsequent calls against a warm instance are near-instant.

import { createHighlighter } from 'shiki'
 
const highlighter = await createHighlighter({
  themes: ['one-dark-pro'],
  langs: ['typescript', 'javascript', 'bash', 'json', 'mdx'],
})

That dropped cold-start overhead from ~6s to ~1s.

2. Re-parsing frontmatter on every hot-reload

In development, gray-matter was parsing every .mdx file on every request — even for files that hadn't changed. We added a simple mtime-based cache:

const cache = new Map<string, { mtime: number; post: Post }>()
 
function parseWithCache(filepath: string): Post {
  const stat = fs.statSync(filepath)
  const hit = cache.get(filepath)
  if (hit && hit.mtime === stat.mtimeMs) return hit.post
  const post = parsePost(filepath)
  cache.set(filepath, { mtime: stat.mtimeMs, post })
  return post
}

This is the simplest possible content cache. It doesn't invalidate on tag changes or cross-file dependencies, but our posts are independent enough that this doesn't matter.

3. Unnecessary remark/rehype plugin overhead

We were running remark-math and rehype-katex on every file, even posts with no math. remark-math is fast, but it still costs ~15ms per file to parse LaTeX delimiters that aren't there.

The lazy fix: skip math plugins if the source doesn't contain $. Not principled, but it saved 180ms across our 12-post corpus.

What we didn't do

We did not add a full content layer with a build-time cache (à la Contentlayer or Velite). For a blog with hundreds of posts, that's probably worth it. For us at 12 posts, the build-time overhead of running a separate codegen step exceeded the cost we were paying.

Results

PhaseBeforeAfter
Cold parse (12 files)9.2s1.4s
Hot reload (1 file changed)3.1s0.3s
Build (all static params)18s4.2s

The changes are all in the pipeline layer, not in the content itself. Your mileage will vary based on post count, plugin set, and whether you're using math/citations.

posts/how-we-cut-blog-build-time.mdx