Files
BoosAPI/desktop/libs/markstream-vue/docs/katex-worker-performance-analysis.md
boos e0a5f57793 feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
2026-06-22 11:32:57 +08:00

7.6 KiB

KaTeX Worker Performance Playbook

Looking for the Chinese version? See KaTeX Worker Performance Guide (ZH).

Question: is a Worker actually faster than rendering on the main thread?

Use this guide to decide when the Worker + cache pipeline is worth enabling.

Short answer

Yes. A Worker backed by a cache easily wins in most real workloads.

Why:

  1. The cache eliminates ~99 % of the cost (cache hit rate commonly >70 %).
  2. The Worker keeps the main thread responsive, so scrolling/typing stays smooth even when formulas are heavy.
  3. Memory overhead is tiny -- roughly 10-50 KB for ~200 cached formulas.

Quick performance comparisons

Scenario 1 - single lightweight formula

Direct render:   ~2-5 ms
Worker:          ~3-7 ms (includes postMessage overhead)
Takeaway:        Worker is slightly slower but the difference is negligible.

Scenario 2 - single complex formula

Direct render:   ~20-50 ms (blocks the main thread)
Worker:          ~22-52 ms (main thread stays free)
Takeaway:        UX improves because the page never freezes.

Scenario 3 - repeated formula with cache

Direct render:   5 ms x 10 renders = 50 ms
Worker+cache:    5 ms + 0.01 ms x 9 hits = 5.09 ms
Takeaway:        ~10x faster once cached.

Scenario 4 - mixed real document

50 formulas with 35 duplicates:
- No cache:   250 ms (every formula rerenders)
- With cache: 75 ms  (only 15 "unique" renders)
- Cache hit rate: 70 %
- Speedup: ~3.3x

How to benchmark

1. Use the built-in Vitest benchmark

pnpm install
pnpm test test/benchmark/katex-worker-vs-direct.test.ts
pnpm test test/benchmark/katex-worker-vs-direct.test.ts -- --reporter=verbose

2. Estimate the "switch to Worker" threshold

Compute how many unique formulas (N) you can render on the main thread before risking a noticeable jank:

  • Formula: N ~ floor(B / (R x (1 - H)))
    • B: main-thread budget in ms (use 50 ms for "user sees a hitch" or 16.7 ms for 1 frame).
    • R: average time to render one unique formula.
    • H: cache hit rate (0-1). When first rendering a page, assume H = 0.

Fast helpers:

node scripts/measure-katex-threshold.mjs
import { recommendNForSamples, recommendWorkerThreshold } from 'markstream-vue/utils/katex-threshold'

const exactN = recommendWorkerThreshold({ R: 10, H: 0, B: 50 })
const sampleBased = recommendNForSamples(['x', '\\sum_{i=1}^{n}', '\\int f(x) dx'], { H: 0, B: 50 })

Practical tips:

  • Default to the "medium complexity" threshold.
  • First paint: assume B = 50 and H = 0. During scrolling or repeat renders, increase N because the cache hit rate climbs quickly.
  • If you detect lots of integrals/matrices, pick the conservative threshold (smaller N).

3. Monitor live traffic

import { enablePerfMonitoring, getPerfReport } from 'markstream-vue/utils/performance-monitor'

enablePerfMonitoring()

setTimeout(() => {
  getPerfReport()
}, 30_000)

Browser console helpers:

window.__katexPerfReport()
window.__katexPerfMonitor.exportMetrics()

4. Inspect Chrome DevTools

A. Performance panel

  1. Open DevTools -> Performance.
  2. Record while rendering formulas.
  3. Inspect:
    • Main lane -> watch katex.renderToString.
    • Worker lane -> ensure work moved off the main thread.
    • Long tasks (>50 ms) -> any red markers mean main thread was blocked.

B. Memory panel

  1. Take a Heap snapshot after rendering.
  2. Search for the cache Map.
  3. Check size:
    • <1 MB -> no worries.
    • 5 MB -> lower CACHE_MAX.

C. Performance Monitor

  1. Cmd/Ctrl + Shift + P -> "Show Performance Monitor".
  2. Watch CPU usage, JS heap, Frames while rendering.

Decision matrix

✅ When to prefer Worker + cache

Scenario Rationale
Complex math (>10 ms) Keeps UI responsive.
>5 formulas per page Cache savings stack up.
Lots of repetitions Cache hit rate skyrockets.
Smooth scrolling/typing required No main-thread stalls.
Mobile devices CPUs are weaker, so avoid blocking.

⚠️ When direct render is fine

Scenario Rationale
Only trivial formulas <5 ms each, Worker overhead similar.
SSR / Node.js Worker API unavailable.
Single formula Cache never pays off.
Extreme bundle constraints Worker adds a small chunk.
try Worker + cache
  -> on error / timeout
fallback to direct render
  -> on success
store result back in cache

Benefits:

  • ✅ Production-safe (there is always a fallback).
  • ✅ Fast path takes advantage of caching.
  • ✅ Progressive enhancement (Worker is optional).

Real-world measurements

Render time by formula type

Type Example Avg time Worker benefit
Simple x = y 2-3 ms Low (~1 ms overhead).
Medium \sum_{i=1}^{n} 5-10 ms Medium (prevents frame drops).
Complex \int_{-\infty}^{\infty} 15-30 ms High (avoids jank).
Matrix \begin{pmatrix}... 30-80 ms Huge (main thread unusable otherwise).

Cache effectiveness

Case First render Cache hit Speedup
Variable x 2 ms 0.005 ms 400x
Summation 10 ms 0.008 ms 1250x
Complex integral 30 ms 0.01 ms 3000x

Sample document (50 formulas, 15 unique)

Strategy Total time Main-thread block UX
No optimization 250 ms 250 ms ⚠️ Noticeable hitching.
Worker only 265 ms 0 ms ✅ Smooth but slower.
Worker + cache 78 ms 0 ms :white_check_mark:✅ Fast and smooth.

Memory footprint

Input formula:   ~30 bytes
HTML output:     ~150 bytes
Expansion ratio: ~5x
One cache entry: ~180 bytes (with key)
200 entries:     ~36 KB

Conclusion: memory cost is negligible.

Optimization recipes

1. Tune cache size

// inside katexWorkerClient.ts
const CACHE_MAX = 500 // e.g. bump from 200 to 500 for more unique formulas

2. Pre-render frequent formulas

import { setKaTeXCache } from 'markstream-vue/workers/katexWorkerClient'

const commonFormulas = ['x', 'y', 'E=mc^2', '\\sum_{i=1}^{n}']

for (const formula of commonFormulas) {
  requestIdleCallback(() => {
    renderAndCache(formula)
  })
}

3. Use requestIdleCallback

if ('requestIdleCallback' in window) {
  requestIdleCallback(() => {
    renderKaTeXInWorker(formula)
  })
}

Key takeaways

  1. Worker overhead is tiny (~1-2 ms).
  2. Cache hit rates >70 % are normal, so caching is the real win.
  3. Worker + cache + fallback is the optimal combo.
  4. Memory costs stay under ~100 KB even with aggressive caching.
  5. Users notice the smoother scrolling much more than the extra kilobytes.

Final recommendation

Keep the existing Worker + cache + fallback architecture.

  • ✅ Great performance (cache removes most work).
  • ✅ Smooth UX (Worker isolates blocking work).
  • ✅ Stable (fallback guarantees output).
  • ✅ Memory friendly.
  • ✅ Progressive enhancement friendly.

Nothing else needs changing -- the current design is already the sweet spot. 🎉

References