JavaScript syntax highlighter in 12.7 kB of WebAssembly

js-syntax-highlight-html.wasm highlighted 5.56 MB of three.min.js in 265 ms in Chrome and 263 ms in Node. This was 2.0 times faster than the impressively-language-agnostic small model gpu-lexer at 533 ms on the same Apple M5. Chrome executed the component on the CPU, while gpu-lexer used WebGPU. js-syntax-highlight-html.wasm labels agreed with Shiki on 99.97% of non-whitespace characters.

The component accepts raw text/javascript and returns escaped text/html. The benchmark calls its QIP Content ABI directly from JavaScript.

265 ms10× three.min.js in Chrome
99.97%agreement with Shiki
12.7 kBWasm module

Try it #

Edit the JavaScript. The page uses qipx to load and run the same Wasm component used in the benchmark. The component escapes the input before the preview inserts its HTML.

Highlighted result — ms
Loading WebAssembly…
Loading 12.7 kB Wasm module…

Download js-syntax-highlight-html.wasm (12.71 kB)

Run in qipx cli #

npx @qip.dev/qipx qip.dev run -i input.js \
  text/javascript/js-syntax-highlight-html.wasm \
  -o highlighted.html

Run in JavaScript #

With a bundler that supports WebAssembly ES module integration, wrap the component in a JavaScript module:

import {
  memory,
  input_ptr,
  input_utf8_cap,
  render,
} from "./js-syntax-highlight-html.wasm";

const encoder = new TextEncoder();
const decoder = new TextDecoder("utf-8", { fatal: true });

export function highlightJavaScript(source) {
  const input = new Uint8Array(memory.buffer, input_ptr(), input_utf8_cap());
  const { read, written } = encoder.encodeInto(source, input);
  if (read !== source.length) {
    throw new RangeError(`Input exceeds the component capacity of ${input.byteLength} bytes`);
  }

  const result = render(written);
  const output = new Uint8Array(memory.buffer, Number((result >> 32n) & 0x7fff_ffffn), Number(result & 0xffff_ffffn));
  return decoder.decode(output);
}

Speed comparison #

Time to highlight 10× three.min.js · lower is better
js-syntax-highlight-html.wasm
265 ms gpu-lexer
533 ms Sugar High
1.25 s Prism
1.83 s Starry Night
13.19 s Shiki
34.59 s

The input was ten copies of three@0.97.0/build/three.min.js, or 5,556,500 bytes. The browser test used CPU WebAssembly in headless Chrome 152 with V8 15.2.124.21. The Node tests used Node.js 26.8.1 and V8 14.6.202.34. All tests ran sequentially on a MacBook Air with an Apple M5, a 10-core GPU, 24 GB of memory, and macOS 26.6.2. Each highlighter had one warm-up run. The Wasm component and gpu-lexer had 40 measured runs in four batches. The batch means were 238 to 301 ms for the Wasm component and 497 to 548 ms for gpu-lexer. Sugar High and Prism had ten measured runs. Starry Night and Shiki had three because each run took more than ten seconds. The gpu-lexer test used version 0.0.2.

HighlighterMeanRuntimeInputReturned value
js-syntax-highlight-html.wasm265 msChrome/WebAssembly CPU5.56 MB raw JavaScript26.09 MB HTML
js-syntax-highlight-html.wasm263 msNode/V85.56 MB raw JavaScript26.09 MB HTML
gpu-lexer 0.0.2533 msChrome/WebGPU5.56 MB raw JavaScript1,054,839 token ranges
Sugar High 2.3.11.25 sNode/V85.56 MB raw JavaScript147.50 MB HTML
Prism 1.30.01.83 sNode/V85.56 MB raw JavaScript59.11 MB HTML
Starry Night 3.11.013.19 sNode/V85.56 MB raw JavaScriptHAST with 1,485,453 nodes
Shiki 4.4.334.59 sNode/V85.56 MB raw JavaScript1,160,820 tokens

These libraries return different data structures. HTML generators allocate and copy markup. Starry Night builds a syntax tree. Shiki returns detailed tokens. gpu-lexer returns source ranges. The table measures the complete returned value, not token recognition alone.

Why the component returns less HTML

The component copies plain identifiers, whitespace, and punctuation without wrappers. It adds a flat span only to comments, strings, numbers, keywords, types, functions, constants, and operators. Each highlighted run adds 33 to 37 bytes of markup. The component adds no line wrappers, inline styles, or nested token elements.

Sugar High wraps every token and every line. Its default token markup contains both a class and an inline color style. Prism also wraps punctuation and can produce nested token spans. Those choices support their styling models, but they create more markup for punctuation-heavy minified code.

For this input, the component's 26.09 MB result was 56% smaller than Prism's HTML and 82% smaller than Sugar High's HTML. Its output is still 4.7 times larger than the source because three.min.js contains many short highlighted tokens.

Agreement with Shiki #

Shiki 4.4.3 is the reference when the component and another highlighter disagree. The score maps Shiki's TextMate scopes and the component's HTML classes to the nine token types used by gpu-lexer: plain, comment, string, number, keyword, type, function, constant, and operator.

FixtureBytesShiki agreement
React 19.2.8 development47,219100.00%
three.min.js555,65099.97%
Underscore 1.13.868,91699.95%
Lodash 4.18.1545,94599.99%

The score compares the class of each non-whitespace source character. The runner also decodes the component's HTML and requires it to reproduce the complete input. The component cannot gain points by dropping text.

These are focused JavaScript regression measurements. They are not comparable to gpu-lexer's Top-25 score. That score covers many languages and gives an unsupported language a score of zero.

The four files exercise different JavaScript patterns. React covers anonymous functions, function-valued properties, CommonJS names, uppercase parameters, and reserved words used as property names. Three.js covers constructors without parentheses and nested conditional expressions.

How the executable oracle works #

The project uses two oracle layers:

  1. compare-shiki.mjs downloads four pinned JavaScript files, verifies their byte lengths and SHA-256 digests, runs Shiki and the Wasm component, and reports each disagreement.
  2. A developer reduces a useful disagreement to a short input and exact HTML output in syntax-highlight-javascript-semantic.fixtures.txt.
  3. syntax-highlight-javascript-semantic.comply.zig embeds that fixture file and compiles it into a 5.7 kB Compliance module.
  4. qip comply runs those exact cases against the highlighter. A changed byte, missing span, extra span, or escaping error fails the check.

The large files find repeated errors. The small Compliance cases define the stable contract without storing React, Three.js, Underscore, Lodash, or Shiki inside the oracle.

Each fixture has three markers:

=== INPUT ===
const message = '<QIP & JavaScript>';
=== OUTPUT ===
<span class="syntax-keyword">const</span> message ...
=== END ===

The fixture parser runs at Zig compile time. It splits the checked-in text into five input/output pairs. An inline for then emits five unconditional calls to the imported qip.must_render_exactly function. The parser is not present in the compiled oracle.

For each call, the QIP host:

  1. reads the input and expected-output slices from the oracle's memory;
  2. copies the input into the highlighter's memory;
  3. calls the highlighter's render(i32) -> i64 export;
  4. reads the returned pointer and byte length; and
  5. compares the returned bytes with the expected HTML.

The ordinal identifies a failed case. The host reports its input, expected output, and actual output. The oracle returns 5, and the host checks that it observed exactly five calls.

Run the contract with:

qip comply \
  components/text/javascript/js-syntax-highlight-html.wasm \
  --with compliance/syntax-highlight-javascript-semantic.comply.wasm \
  --straight-line-oracles

--straight-line-oracles inspects the compiled oracle. It permits constants, direct oracle calls, dropped return values, and the final return. It rejects branches, loops, helper calls, indirect calls, and memory instructions. This check prevents a fixture oracle from conditionally skipping a case. It does not restrict the highlighter implementation.

Run the larger Shiki comparison and benchmark with:

make -C benchmarks/syntax-highlight-comparison install
make -C benchmarks/syntax-highlight-comparison compare
make -C benchmarks/syntax-highlight-comparison benchmark

Use JSON output to inspect confusion matrices and per-class precision and recall:

cd benchmarks/syntax-highlight-comparison
npm run compare -- --json react-development

Output classes #

The component uses the same nine-class vocabulary as gpu-lexer's comparison, but not its token decisions. Plain text has no element. The eight highlighted types use a syntax- prefix to avoid generic application class names.

TokenHTML class
Commentsyntax-comment
String or regular expressionsyntax-string
Numbersyntax-number
Keywordsyntax-keyword
Typesyntax-type
Functionsyntax-function
Constantsyntax-constant
Operatorsyntax-operator
Plain textNo element

These are not Shiki CSS classes. Shiki normally returns TextMate scopes, tokens, and theme colors. The component's smaller vocabulary keeps presentation separate from token meaning.

The comparison normalizes Shiki scopes in this order:

Shiki TextMate scopeCommon token
comment*Comment
string* or a scope that contains regexpString
constant.numeric*Number
keyword.operator*Operator
entity.name.function* or support.function*Function
Type, class, interface, enum, or supported class scopesType
Language constants and language variablesConstant
Other keyword* or storage* scopesKeyword
All other scopesPlain text

The remaining differences come from context that a small lexer does not fully parse and from formatting-sensitive TextMate scopes. The component does not add a special case only to imitate an unusual scope decision. A new rule must describe useful JavaScript behavior and have a reduced Compliance case.

When to use another highlighter #

Use this QIP component for fast JavaScript highlighting, compact WebAssembly, and stable semantic classes. Use Shiki when exact TextMate scope coverage and theme compatibility justify its cost. Use Starry Night when you need GitHub-compatible pl-* classes or a HAST tree. Use Prism or Sugar High when their CSS and browser integration fit your application.

The lexer uses deliberate heuristics. It is not a complete JavaScript parser, and it does not support the language range of Shiki, Starry Night, or gpu-lexer.

Did the Compliance oracle help? #

Yes. Shiki found the disagreements, but the Compliance oracle made each fix quick to verify. Once a disagreement became a small fixture, we could run its exact input and output check after every lexer change. This caught regressions without running Shiki or processing the four large library files again.

The five-case Compliance command took a mean of 15.82 ms across 20 local runs. The median was 15.51 ms. This time includes CLI startup, Wasm validation, --straight-line-oracles validation, both module instances, and all five exact output comparisons. It does not include rebuilding the Wasm files.

The two layers have different jobs. Use the slower Shiki comparison to discover new classes of disagreement. Reduce each useful example and add it to the Compliance fixture. The 16 ms Compliance check then becomes the normal inner loop.