Shiki Eliminated My 52 kB Chunk and Tripled My Build Time

Shiki promises build-time highlighting and zero browser JavaScript. The migration is clean. Rsdoctor told a different story: the 52 kB runtime chunk is gone, but build time tripled and the total bundle grew. Here's what the data actually showed and whether it's worth it.

Read time is about 12 minutes

Alexander Garcia is an effective JavaScript Engineer who crafts stunning web experiences.

Alexander Garcia is a meticulous Web Architect who creates scalable, maintainable web solutions.

Alexander Garcia is a passionate Software Consultant who develops extendable, fault-tolerant code.

Alexander Garcia is a detail-oriented Web Developer who builds user-friendly websites.

Alexander Garcia is a passionate Lead Software Engineer who builds user-friendly experiences.

Alexander Garcia is a trailblazing UI Engineer who develops pixel-perfect code and design.

Every blog post on this site with a code block was shipping a 52.2 kB chunk of JavaScript to the browser just to color some text. I wanted that gone. Shiki promised build-time highlighting with zero browser JavaScript, and the migration turned out to be straightforward.

Then I ran Rsdoctor and the numbers complicated the narrative.

This post covers the migration, the actual benchmark results, and an honest assessment of whether the trade-off was worth it (spoiler: it wasn't)

Why look at alternatives?

I was already using Rsdoctor to inspect the build for another post in this series. One of the first things it surfaced was the syntax-highlighter chunk sitting at 52.2 kB minified, 11.2 kB gzipped.

The react-syntax-highlighter setup I had was doing its best. I was using prism-async-light (its the async variant that loads language support on demand.) I wrapped it in React.lazy() so it only loaded when a visitor actually hit a post with code blocks. That's a reasonable pattern, and for a while it was good enough.

But the more I thought about it, the less it made sense for this site. These are MDX blog posts. They're compiled at build time. The content is static. There is no good reason the syntax highlighting should happen at runtime in the browser. Every byte in that chunk represents a trade-off the reader is making on my behalf, and they never agreed to it.

The question became: is there a way to highlight at build time and ship zero JavaScript for this feature? Shiki answers that with a yes.

What is Shiki and how does it differ?

Shiki is a syntax highlighter that uses TextMate grammars which is the same grammars that powers VS Code. Its output is a <pre> element with <span> tags carrying inline color styles. There is no class-based theming and no client-side runtime. The result is just HTML.

The @shikijs/rehype package integrates Shiki into the unified/rehype pipeline as a plugin. When you're compiling MDX, your pipeline already runs remark and rehype transforms over the abstract syntax tree before generating the final JavaScript module. rehypeShiki hooks into that step and replaces each fenced code block node in the HAST with Shiki's pre-rendered HTML. By the time Rsbuild emits the bundle, the highlighting is already done.

Compare that to the runtime approach: with react-syntax-highlighter, the MDX output includes a <code className="language-typescript"> node. The browser downloads the JavaScript bundle, parses the source, and runs Prism against it when the component mounts. The reader waits.

With Shiki, the MDX output includes fully highlighted HTML that was computed once during the build. The browser renders it immediately. Nothing to download, nothing to execute.

The migration

Step 1: Swap the packages

pnpm remove react-syntax-highlighter @types/react-syntax-highlighter pnpm add shiki @shikijs/rehype

Removing react-syntax-highlighter also eliminated the separate @types package that was sitting in devDependencies. Shiki ships its own TypeScript definitions.

Step 2: Add the rehype plugin in rsbuild.config.ts

The pluginMdx call in rsbuild.config.ts already had remarkPlugins and recmaPlugins. Adding Shiki was one line:

// rsbuild.config.ts pluginMdx({ mdxLoaderOptions: { providerImportSource: "@mdx-js/react", remarkPlugins: [remarkGfm, remarkCalculateWordCount], recmaPlugins: [recmaInjectReadTime], rehypePlugins: [[rehypeShiki, { theme: "night-owl" }]], }, }),

The night-owl theme is built into Shiki so you don't have to import, nor bundle the asset. You pass it by name.

I also removed the syntaxHighlighter cacheGroup from the splitChunks configuration. That group was specifically isolating react-syntax-highlighter into its own named chunk. With the package gone, the config entry was just dead weight:

// rsbuild.config.ts // Removed: package no longer exists syntaxHighlighter: { test: /[\\/]node_modules[\\/]react-syntax-highlighter[\\/]/, name: "syntax-highlighter", priority: 20, reuseExistingChunk: true, },

Step 3: Clean up markdownComponents.tsx

This is where the old setup lived. The entire LazyCodeBlock definition was a React.lazy() call that imported prism-async-light and the night-owl style object:

// markdownComponents.tsx const LazyCodeBlock = lazy(() => Promise.all([ import("react-syntax-highlighter/dist/esm/prism-async-light"), import("react-syntax-highlighter/dist/esm/styles/prism/night-owl"), ]).then(([{ default: SyntaxHighlighter }, { default: nightOwl }]) => ({ default: ({ children, language, ...props }: SyntaxHighlighterProps) => ( <SyntaxHighlighter {...props} PreTag="div" children={children} language={language} style={nightOwl} wrapLongLines className="max-w-[800px]" /> ), })) );

All of that is gone. The code component used to check whether a language-* class was present and branch into LazyCodeBlock via Suspense if it matched. Now the code component only handles inline backtick code because Shiki has already consumed the fenced code blocks before MDX generates the React component tree.

There's one important nuance here. After @shikijs/rehype transforms the HAST, fenced code blocks become Shiki's pre-rendered HTML: a <pre> wrapping styled <code><span> trees. The MDXProvider's code component would only receive inline code at this point, not fenced blocks. To apply styling to the Shiki-generated <pre> blocks, the right hook is the pre component:

// markdownComponents.tsx export const markdownComponents = { pre: ({ children }: TagProps) => ( <pre className="max-w-[800px] overflow-auto rounded p-4 text-wrap my-4" > {children} </pre> ), code: ({ children }: TagProps) => ( <code className="font-mono text-sm font-semibold"> {children} </code> ), // rest of markdown components }

The lazy and Suspense imports were removed from the top of the file. The SyntaxHighlighterProps type import from react-syntax-highlighter was removed. The file got shorter.

What Rsdoctor actually showed

After migrating, I ran Rsdoctor on both versions to get real numbers. Here's what came back:

Metricreact-syntax-highlighterShiki
Total bundle (uncompressed)4.42 MB4.97 MB
Total bundle (gzip)2.4 MB2.2 MB
Compile time2.6s7.3s
Minify time370ms614ms
Runtime JS (syntax highlight)52.2 Kb (11.2 Kb gzip)0

The 52.2 kB runtime chunk is genuinely gone. That part of the hypothesis held. But a few other things happened that I did not expect.

The total bundle got larger. Shiki runs TextMate grammar parsing during the MDX loader step so it never ships to the browser however every code block across every post now has inline style="color:#82aaff" on each token. That adds up. The uncompressed output grew by ~450 kB across the whole dist.

Gzip tells a different story. Those repetitive inline color strings compress extremely well, and the gzip size actually dropped by 200 kB. So readers collectively download slightly less.

Build time tripled. Compile went from 2.6s to 7.3s. This is the biggest cost. Shiki has to run grammar parsing for every fenced code block in every MDX file on every production build. Minify time also grew because there's more string content to process.

The visual output is identically the same, the same night-owl colors, same theme, however the build experience is noticeably slower.

Is it worth it?

The model is conceptually right. Build-time highlighting is the correct approach for a static MDX blog build it once, serve it many times. The 52.2 kB runtime chunk is a real cost that every reader was paying.

But for a personal blog, the math doesn't obviously make sense in this case. That chunk was 11.2 kB gzip and lazy-loaded, so it never blocked the initial page render. The readers who actually triggered it were already on a post and waiting for content, not a spinner. Saving them 11 kB on a connection fast enough to load a blog is a low-stakes win.

The 3 times build regression, on the other hand, is felt on every deploy and every pnpm build run. For a high-traffic site where you're serving millions of page views, the reader-side savings compound and justify the build cost. For a personal blog, the math is closer and the developer experience hit is real.

If your blog has a lot of code-heavy posts and you care about reader performance at scale, Shiki is the right call. If you're optimizing for dev experience on a low-traffic site, the original lazy-loaded approach was already a reasonable solution. So until I get more traffic react-syntax-highlighter will have to do.

Lessons learned

1

Measure before declaring a win

The premise of build-time highlighting ships less JavaScript is true. But Rsdoctor showed the full picture: gzip improved by 200 kB while build time tripled. The theoretical win and the measured outcome are not the same thing.

2

Context determines whether a tradeoff is worth it

Shiki is the right choice at scale. For a personal blog, saving 11 kB gzip per blog reader at the cost of 3× slower builds is a poor exchange. The same migration can be the right call or the wrong call depending on traffic and team size.

3

The rehype layer is still the correct integration point

Even if the outcome is debatable, the architecture is right. Syntax highlighting belongs in the compiler pipeline, not in a React component that lazy-loads a 52 kB bundle at runtime. The question is whether the cost of doing it correctly is justified.

4

Rsdoctor catches what intuition misses

Without measuring, I would have shipped this as a pure win. The build-time cost and the bundle size increase would have been invisible. Run the tool before and after any dependency change you're planning to write a post about.