cnfast: The Tiny Tailwind Helper That Turns String Merging Into Identity Caching

A drop-in `cn` replacement that keeps Tailwind output byte-identical while leaning on V8 behavior, stable template objects, and zero-cost hot paths to make repeated renders cheaper.

8 min read View on GitHub More from aidenybai

A developer repeatedly sends the same class string through a mechanical cache gate, while a second path routes a stable template card into a vault keyed by identity. The image explains that cnfast treats repeated class merging as a reusable object problem, not a fresh string problem.
cnfast's core idea is simple and odd at the same time: the same class merge can be recognized, cached, and skipped.
Key Takeaways

The memorable trick in cnfast is not that it merges classes faster. It is that it asks a different question: is this really a new problem, or is it the same call site wearing new props?

That matters because the standard `cn` pattern is usually treated like disposable glue. In a React app, though, disposable glue can sit on a hot render path and get called thousands of times without anyone noticing until profiling starts making rude noises.

The weird trick: class merging can be cached by identity

The tagged template form is the sharpest version of the idea. A template literal carries a stable `TemplateStringsArray`, which means repeated renders can share identity even when values change around it.

That lets cnfast use the call site itself as part of the cache story. Same template object, same merge structure, same work avoided.

The tagged template form is special because the cache key can be the object identity of the template itself, not just the text it produces.

cnfast runs 3.8x faster on average than tailwind-merge, up to 7x on component-heavy code, with byte-identical output. Same API, no code changes.

That claim only matters if the surrounding machinery is disciplined. cnfast is not just a cached string helper. It is a bundle of small choices that all aim at the same thing: keep the hot path cold.

Why `cn` became a bottleneck in Tailwind-heavy apps

The baseline stack is familiar: `clsx` builds the string, `tailwind-merge` resolves conflicts, and a small `cn` wrapper glues the two together. It is convenient, readable, and easy to sprinkle everywhere.

PatternCachingHot-path allocationsParity riskBest fit
`clsx` + `tailwind-merge`None by defaultRepeated joins and merge workLowGeneral-purpose correctness
Common `cn` wrapperUsually noneSame work, plus wrapper overheadLowMost Tailwind apps
`cnfast` function formArgument-aware cachingReduced on repeated inputsMediumFrequent re-renders
`cnfast` tagged template formCall-site identity cachingVery low on repeat rendersMediumStable template-heavy UI code

That distinction is why the benchmark story is plausible. The utility is tiny, but tiny utilities become expensive when they live inside lists, tables, virtualized views, and UI libraries that render constantly.

A close-up engine room shows Tailwind tokens moving through numbered character tiles, bracket depth gauges, and a compact trie lattice. A side pile of regex scrolls and string slices sits rejected at the door. The image explains how cnfast parses and resolves class names while avoiding wasted work.
cnfast's parser and resolver are built to recognize Tailwind structure without paying for heavyweight string handling on every pass.

How cnfast keeps the hot path cold

The implementation follows a clear performance discipline. It detects the runtime engine, takes a V8-aware path when it can, and avoids making the allocator do extra work just to say the same thing again.

// Simplified idea, not the full implementation
const isV8 = !('line' in new Error()) && !('lineNumber' in new Error())

export function cn(...args: ClassValue[]) {
  return isV8 ? mergeVariadicCached(args) : mergeVariadic(args)
}

export function cnTag(strings: TemplateStringsArray, ...values: unknown[]) {
  return templateCached(strings, values)
}

The parser is just as deliberate. Instead of leaning on regex and slices, it walks the string with character codes, tracks bracket and parenthesis depth, and handles arbitrary Tailwind values without spraying allocations across the heap.

The parser is all about avoiding wasted work

That design choice sounds boring until you remember how much of JavaScript performance is really about not asking the engine to guess. Integer comparisons, manual scans, and stable object shapes are not aesthetic preferences. They are ways to keep the engine in the fast lane.

The resolver follows the same rule. It builds class-group lookup structures lazily, then keeps their shapes stable so V8 can optimize them predictably.

The resolver is a trie, but the real story is shape stability

This is where cnfast stops being just a micro-optimized helper and becomes an engineering opinion. It prefers factory-created objects, monomorphic shapes, and lazy initialization because those choices make the fast path more predictable for the engine.

TechniqueWhy it helpsWhat it avoids
Lazy trie constructionPays setup cost onceHeavy startup work on every import
Factory-created objectsKeeps hidden classes stableShape churn and deoptimization
Two-generation cachingBounds memory while preserving hitsUnbounded cache growth
Manual string handlingReduces allocation pressureArray joins and extra temporary strings

The benchmark suite is there to make that philosophy credible. If the output stays byte-identical across a large parity corpus, then the speedup is about execution strategy, not a changed result.

Why the benchmark numbers are believable

That is the part worth paying attention to. cnfast is not trying to win by relaxing correctness. It is trying to win by recognizing repetition early and exploiting it aggressively.

Tailwind users, maybe this hasn't crossed your mind, but using tailwind-merge means you've added a quite slow runtime CSS-in-JS layer to your React app. This new project is a 4x faster alternative, although it remains a bottleneck.

This Week In React, Developer Newsletter · This Week In React #287

Where cnfast fits, and where it does not

If your app rarely re-renders and class merging is not on a critical path, the win will be modest. If your UI is class-heavy and render-heavy, the savings become easier to justify.

That makes cnfast less of a universal replacement than a targeted one. It is most compelling when Tailwind class composition shows up everywhere and the same patterns repeat across the app.

QuestionIf yes, cnfast fitsIf no, probably unnecessary
Do you use Tailwind everywhere?The payoff compoundsThe helper is less central
Do components re-render often?Caching has a real targetThe hot path is smaller
Do you care about byte-identical output?Parity matters, so this is usefulA simpler helper may be enough
Do you already use `cn` pervasively?Migration is easyThe change may not justify itself

The best summary is not that cnfast is faster. It is that it treats repeated UI work as a problem of identity, not just string assembly. That is a small change in syntax and a bigger change in mental model.