25 September 2023
by
Hector Sosa

Caching Shiki for Faster Build Times

react
typescript
shiki
Shiki is arguably the most popular syntax highlighter out there. It uses the TextMate grammar system with the VS Code Oniguruma library to tokenize strings which then can be stylized using VS Code themes or CSS variables. Shiki is the way to go if you want your code to look exactly like that in VS Code.
Using Shiki is practical because as your favorite languages and themes evolve, your syntax highlighting will evolve as well.
If you're here for the faster build times, go to faster build times. If you've never heard of or used Shiki before, here's a great introduction:

How does it work?

Although Shiki has a lot of customization options (themes, languages, and custom renderers), at its core, Shiki's highlighter is incredibly easy to work with:
  1. Call getHighlighter with the theme and languages you need to create a highlighter instance, and then
  2. Use the highlighter methods, such as codeToHtml, to take a string of code and transform it to syntax-highlighted HTML.
Of course, there are more details, so make sure to check out Shiki's configuration and options. However, if you want to keep things simple, here's how you'd get started:
1import { getHighlighter } from "shiki";
2
3// Create a highlighter instance
4const highlighter = await getHighlighter({
5  theme: "nord",
6  langs: ["ts"],
7});
8
9const code = `console.log("Here is your code.");`;
10
11// Call a highlighter method to get syntax-highlighted HTML
12const output = highlighter.codeToHtml(code, { lang: "ts" });

What are the easiest customizations you can use?

Banner

Do you need a reliable partner in tech for your next project?

Using CSS variables for theming

One of the biggest things missing in Shiki is the lack of support for light/dark or dual-theming. While there are several solutions and alternatives out there, none are directly addressed by the library. If you're not restricted to using VS Code themes, you can use CSS variables for theming.
1import { getHighlighter } from "shiki";
2
3// Create a highlighter instance
4const highlighter = await getHighlighter({
5  // ✅ Use CSS variables for theming
6  theme: "css-variables",
7  langs: ["ts"],
8});
Then use CSS to style your code and create the theming implementation of your choice. Here's a safe starting point for this:
1:root {
2  --shiki-color-text: #24292f;
3  --shiki-color-background: #ffffff;
4  --shiki-token-constant: #0550ae;
5  --shiki-token-string: #24292f;
6  --shiki-token-comment: #6e7781;
7  --shiki-token-keyword: #cf222e;
8  --shiki-token-parameter: #24292f;
9  --shiki-token-function: #8250df;
10  --shiki-token-string-expression: #0a3069;
11  --shiki-token-punctuation: #24292f;
12  --shiki-token-link: #000012;
13}
14
15:root .dark-theme {
16  --shiki-color-text: #c9d1d9;
17  --shiki-color-background: #0d1117;
18  --shiki-token-constant: #79c0ff;
19  --shiki-token-string: #a5d6ff;
20  --shiki-token-comment: #8b949e;
21  --shiki-token-keyword: #ff7b72;
22  --shiki-token-parameter: #c9d1d9;
23  --shiki-token-function: #d2a8ff;
24  --shiki-token-string-expression: #a5d6ff;
25  --shiki-token-punctuation: #c9d1d9;
26  --shiki-token-link: #000012;
27}
Note: If you prefer to stick with VS Code Themes you might want to check out Anthony Fu's Shikiji, which is an ESM-focused rewrite of Shiki.

Using custom renderers and CSS counters for line numbers

Custom renderers allow you to define your own custom rendering rules per each element type. The easiest way to understand how custom renderers work is by creating a renderer for line numbers. Instead of using codeToHtml, you can use codeToThemedTokens from your highlighter instance to obtain an array of tokens and then call renderToHTML, passing your tokens and your custom renderer.
1import { getHighlighter, renderToHTML } from "shiki";
2
3// Create a highlighter instance
4const highlighter = await getHighlighter({
5  // ✅ Use CSS variables for theming
6  theme: "css-variables",
7  langs: ["ts"],
8});
9
10const code = `console.log("Here is your code.");`;
11
12// ✅ Tokenize your code to apply your custom renderer
13const tokens = highlighter.codeToThemedTokens(code, "ts");
14
15// ✅ Use your custom renderer to get syntax-highlighted HTML
16const output = renderToHTML(tokens, {
17  elements: {
18    line({ children, className, index }) {
19      return `<span data-line=${index}
20        class=${className}>${children}</span>`;
21    },
22  },
23});
You can combine renderers with CSS to create any implementation you might need. Here is an example using CSS Counters (with a touch of TailwindCSS) for this purpose:
1.shiki code {
2  @apply grid p-5 font-mono text-sm leading-normal;
3}
4
5code {
6  counter-reset: line;
7}
8
9code > [data-line]::before {
10  counter-increment: line;
11  content: counter(line);
12  @apply mr-6 inline-block w-4 text-right text-gray-500/50;
13}
14
15code[data-line-numbers-max-digits="2"] > [data-line]::before {
16  @apply w-8;
17}
18
19code[data-line-numbers-max-digits="3"] > [data-line]::before {
20  @apply w-12;
21}
A font pairing that I've enjoyed recently is Schibsted Grotesk with IBM Plex Mono. Alright, now let's get to what we're here for How to get faster build times using Shiki?

Faster build times

Regardless of how you use Shiki, the more code you highlight, the more likely it is that you will run into performance issues. After testing, our team concluded that (a) creating Highlighter instances, (b) loading languages, and (c) generating token explanations are Shiki's most resource-intensive operations.
You should make sure that the Highlighter instance is only created once, and that it is bootstrapped asynchronously before calling any of the exposed functions
Shiki provides you with the flexibility to create the implementations you need to meet your syntax highlighting requirements, but you should exercise caution when using this flexibility. There's no built-in caching system or checks to make sure that the Highlighter instance is only created once, so you'll need to create your own.
Creating a caching system doesn't have to be overly complicated. By leveraging Map and abstracting the getHighlighter method, you can achieve this.
1import shiki, { type Highlighter, type Theme, type Lang } from "shiki";
2
3// ✅ Use Map to store Highlighter instance promises
4const highlighterCache = new Map<string, Promise<Highlighter>>();
5
6export async function getHighlighter({
7  theme,
8  langs,
9}: {
10  theme: Theme;
11  langs: Lang[];
12}) {
13  // ✅ Generate identifiers for your instances, check and return if found
14  const key = [theme, ...langs].join("-");
15  const highlighter = highlighterCache.get(key);
16  if (highlighter) return await highlighter;
17
18  // ✅ If an instance hasn't been created, store it and return it
19  const highlighterPromise = shiki.getHighlighter({ theme, langs });
20
21  highlighterCache.set(key, highlighterPromise);
22  return await highlighterPromise;
23}

A word on Shiki's explanations

Shiki's explanations are the biggest hidden bottleneck, though. Understanding Shiki's explanations is key to optimizing performance. Explanations are set to false if you're using codeToHtml, but with codeToThemedTokens you'll find the option: includeExplanation, which annotates this option and describes explanations as:
Whether to include an explanation of each token's matching scopes and why it's given its color. It defaults to false to reduce output verbosity.
Interestingly enough, explanations are considered and included in the source code for both methods. They are exposed to the developer only with codeToThemedTokens and documented with a default value of false. However, it turns out that the default value for this option is set to true, so if performance is your concern and you do not need to make use of explanations, set this option to false.
1import { getHighlighter } from "@lib/shiki"; // Shiki Cache Abstraction
2import { renderToHTML, type Theme, type Lang } from "shiki";
3
4/** ✅ Config */
5const theme: Theme = "css-variables";
6const lang: Lang = "ts";
7
8const highlighter = await getHighlighter({
9  theme,
10  langs: [lang],
11});
12
13const code = `console.log("Here is your code.");`;
14
15// ✅ Avoid getting explanations if you do not need them
16const tokens = highlighter.codeToThemedTokens(code, lang, theme, {
17  includeExplanation: false,
18});
19
20const output = renderToHTML(tokens, {
21  elements: {
22    line({ children, className, index }) {
23      return `<span data-line=${index}
24        class=${className}>${children}</span>`;
25    },
26  },
27});

Final thoughts

If you're using any flavor of markdown and do not need control over Shiki, plugins like Rehype Pretty Code, which integrate custom renders and caching systems, are excellent choices that align with the concepts explored in this article.
This simple caching and setup solution made our build times a remarkable 80% faster. We tried various other suggestions, but none worked as effectively as creating a caching system for Shiki Highlighter instances. By implementing these strategies, you can ensure a smoother and faster syntax highlighting experience for your projects.
Here are a couple of things to keep in mind:
  • The keys used to identify instances are prone to duplicates if Highlighter instances are created using different aliases, i.e. typescript and ts. Check Shiki's language aliases and make sure your team has a convention for their use.
  • You might not need to build your own caching system. If you aren't using Shiki directly, chances are that this has already been addressed by the libraries you're using.
  • Using Shiki in the browser might require a different approach, make sure to check Shiki's architecture to learn more.
Huge thanks to: @ChcJohnie and @ericvalcik for these findings.
Share post

Let’s stay connected

Do you want the latest and greatest from our blog straight to your inbox? Chuck us your email address and get informed.
You can unsubscribe any time. For more details, review our privacy policy