Code Highlighting at Build Time: Shiki vs Prism and the Death of Runtime Tokenizers

Open a dev blog from 2018 and watch the network tab. There's a 60KB JavaScript bundle whose entire job is to look at code that has already been rendered, run a tokenizer over it, and re-paint it with colors. The HTML arrived correctly. The CSS arrived. Then the browser downloads, parses, and executes a syntax highlighter to do work that could have happened once, on a server you control, before anyone visited the page.
That pattern made sense when client-side rendering was the default and Markdown was processed in the browser. It doesn't now. Highlighting code is a pure function of input — language, theme, and source — and pure functions of static input belong at build time. The runtime tokenizer is a vestigial limb from an earlier era of the web, and it's worth being explicit about why it should come off.
What Runtime Highlighting Actually Costs
The visible cost of Prism.js or highlight.js is the bundle. The default Prism build is around 25–30KB minified, plus a stylesheet and any language packs you load on top. Highlight.js with its common-language bundle lands closer to 80KB. Neither is huge in isolation. Both are pure overhead on a static blog where the highlighting could have already happened.
The less visible cost is the flash. The HTML ships with <pre><code> blocks containing raw text. Stylesheet loads. JavaScript loads. JavaScript parses. JavaScript walks the DOM, finds the code blocks, tokenizes them, and rewrites their innerHTML with <span> elements wrapping each token. For a long technical post with twenty code blocks, you can see this. Code is monochrome for a beat, then snaps into color. It's the syntax-highlighting equivalent of FOUC, and it's avoidable.
There's a correctness cost too. Runtime tokenizers run in the user's browser, which means they fail in the user's browser. If the script is blocked, slow to load, or throws on an edge case in your code sample, the page renders unstyled code. The author can't see this happen — the cache and the dev environment hide it.
What Build-Time Highlighting Looks Like
The alternative is straightforward: tokenize once at build time, write spans into the static HTML, and ship CSS that styles them. No JavaScript. No flash. The code in the user's browser is already colored when the HTML hits the screen.
Two tools dominate this space, and they take opposite approaches.
Prism ships as a JavaScript library you can run in a build step. Plug it into a remark or rehype pipeline and it tokenizes during the static build. The output is the same <span class="token keyword"> markup it would have produced in the browser, except now it's baked into the HTML. You still ship the CSS theme, but you no longer ship the tokenizer. Same library, different runtime — that's it.
Shiki is a fundamentally different approach. It uses TextMate grammars — the same grammars VS Code uses — and bakes the colors directly into inline style attributes. There's no theme stylesheet at all. The output looks identical to your editor because it is using your editor's grammar and your editor's theme. The tradeoff is that Shiki only runs at build time. It can't run in the browser without shipping the grammars and the regex engine, which would be massively heavier than Prism.
Both eliminate the runtime cost. The interesting question is which one you should pick.
Shiki: VS Code Accuracy, at a Build-Time-Only Cost
Shiki's appeal is fidelity. TextMate grammars are far more accurate than the regex-based tokenizers Prism and highlight.js use. The difference shows up in long, real code samples — TypeScript with generics, JSX with template literals, SQL inside a tagged template. Prism's tokenizer trips on these. Shiki doesn't, because it's running the same parser your editor runs.
The output is also visually richer. Themes like Nord, One Dark Pro, Tokyo Night, and GitHub Dark render exactly as they do in the editor — same colors, same italic styling, same subtle distinctions between local and imported identifiers. Code blocks on a Shiki blog look like screenshots from VS Code. That visual continuity is part of the pitch.
The cost is build-time-only. Shiki's grammars and themes are not small — a typical install is 2–3MB on disk — and tokenizing every code block adds latency to the build. For a blog with a hundred posts and several code blocks per post, this is a few extra seconds. For a documentation site with thousands of pages, it's longer. None of this affects the runtime payload, which is the entire point.
The other cost is rigidity. Because colors are baked into inline styles, switching themes at runtime — say, light mode and dark mode — is awkward. The standard answer is shiki-twoslash or rehype-pretty-code, which generate dual-theme output by emitting both light and dark colors and toggling them with CSS variables. It works, but it's not free, and it's a real consideration if you care about themed code blocks following the user's color scheme.
Prism: Lighter Output, Looser Tokenization
Prism at build time is the boring choice, and that's not a criticism. The tokenizer is small, the output is plain <span class="token keyword"> markup, and switching themes is just swapping a stylesheet. If you've ever worked with Prism's CSS, you know exactly how to restyle it.
The tradeoff is the tokenizer itself. Prism's grammars are regex-based, and they fall over on edge cases that TextMate grammars handle correctly. Most posts won't notice. A post that includes complex TypeScript or unusual syntax mixes will. The bug surface is real, but bounded.
The other Prism advantage is theming flexibility. Because the colors live in CSS, you can ship one set of HTML and swap themes with a stylesheet. Light/dark mode is trivial. User-selectable themes are trivial. If your design system relies on token classes that match other UI elements — a .token.string color that pairs with a primary brand color, for instance — Prism is the natural fit.
For most dev blogs, neither tokenizer is meaningfully wrong. The choice is aesthetic and practical: do you want VS Code-grade fidelity at the cost of inline styles, or do you want flexibility and lighter output at the cost of slightly less accurate tokenization?
The Common Setup, Both Ways
Here's what build-time highlighting looks like in practice. The integration pattern is the same shape regardless of which tool you pick.
For Shiki via rehype-pretty-code:
import rehypePrettyCode from "rehype-pretty-code";
const options = {
theme: { light: "github-light", dark: "github-dark" },
keepBackground: false,
};
// In your remark/rehype pipeline:
.use(rehypePrettyCode, options)
The output is HTML with inline styles for both themes, switched via a data-theme attribute or CSS variables. Your light/dark toggle just changes which colors are visible.
For Prism via rehype-prism-plus:
import rehypePrism from "rehype-prism-plus";
.use(rehypePrism, { showLineNumbers: true });
The output is <span class="token keyword"> markup. You add prismjs/themes/prism-tomorrow.css (or whatever theme) to your global stylesheet. Light/dark is two stylesheets and a class toggle.
In both cases, what ships to the browser is HTML + CSS. No tokenizer. No tokenizer bundle. No runtime work. The first paint is correctly colored.
What About Highlighting in MDX or Interactive Demos?
There's one legitimate reason to keep a runtime tokenizer around: code that the user edits and the page re-highlights as they type. Live playgrounds, sandbox-style tutorials, anything where the source changes after page load. Build-time highlighting can't help there because the source isn't known at build time.
For that case, you load Prism (or a smaller alternative like lowlight) on the demo page only. The rest of the site stays static. This is the right shape — pay the runtime cost on the one page that needs it, not on every post in the archive.
What you should not do is keep Prism loaded site-wide because of one interactive demo on one page. That's the same anti-pattern as installing MDX everywhere because of one post that needed it. Scope the runtime cost to the route that actually requires it.
The Migration Is Smaller Than You Think
If you're sitting on a blog that runs Prism at runtime, here's the practical migration:
-
Move tokenization into the build step. If you use
remark/rehype, addrehype-prism-plus(Prism) orrehype-pretty-code(Shiki) to your pipeline. The output HTML is now pre-tokenized. -
Remove the runtime Prism bundle. Delete the
<script src="prism.js">tag, thePrism.highlightAll()call, and the import. The HTML already has the spans. -
Keep the theme CSS. Prism's
prism-tomorrow.css(or whatever you used) still works exactly the same — it's just styling the spans that now arrived pre-rendered. -
For Shiki, drop the theme CSS entirely. Inline styles handle it. You can delete the old highlighter stylesheet.
That's the whole migration for most sites. A few config lines added, a script tag removed, a theme stylesheet either kept or deleted. The bundle gets lighter, the first paint gets correct, and nothing else changes.
The reason this migration sits undone on so many blogs is not difficulty — it's that nobody profiles their own site. The Prism bundle is small enough to be invisible in casual testing, and the FOUC on code blocks is fast enough to feel like the page just rendered. But you're paying it on every page, every visit, for work that didn't have to happen.
The Pattern Generalizes
Syntax highlighting is the easiest case of a broader pattern: anything that's a pure function of static input belongs at build time, not in the user's browser. Pre-rendered code is one example. Pre-rendered Markdown is another. Pre-rendered Open Graph images. Pre-rendered RSS. Pre-rendered search indexes.
The runtime web won this argument by default, mostly because tooling matured around the assumption that work happens in the browser. That assumption is no longer true for content sites. The build is fast, the toolchain is mature, and shipping work to users you could have done once on your machine is a choice — usually the wrong one.
For a dev blog, the choice between Shiki and Prism is real but secondary. The choice between highlighting at build and highlighting at runtime is the one that matters. One is the modern static web. The other is a bundle the user downloads to do work you already finished.