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
| Phase | Before | After |
|---|---|---|
| Cold parse (12 files) | 9.2s | 1.4s |
| Hot reload (1 file changed) | 3.1s | 0.3s |
| Build (all static params) | 18s | 4.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.