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.
- cnfast is interesting because it changes class merging from a string-building task into an identity problem that can be cached across repeated renders.
- Its fastest path is not a bigger abstraction layer. It is a tighter one, built around stable template objects, V8-aware caching, and allocation avoidance.
- The library tries to keep output byte-identical to the standard Tailwind merge stack, so the performance claim depends on parity, not on changing behavior.
- It matters most where `cn` runs constantly: component-heavy UIs, dashboards, and Tailwind codebases that reuse the same class patterns over and over.
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.
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.
| Pattern | Caching | Hot-path allocations | Parity risk | Best fit |
|---|---|---|---|---|
| `clsx` + `tailwind-merge` | None by default | Repeated joins and merge work | Low | General-purpose correctness |
| Common `cn` wrapper | Usually none | Same work, plus wrapper overhead | Low | Most Tailwind apps |
| `cnfast` function form | Argument-aware caching | Reduced on repeated inputs | Medium | Frequent re-renders |
| `cnfast` tagged template form | Call-site identity caching | Very low on repeat renders | Medium | Stable 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.
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.
| Technique | Why it helps | What it avoids |
|---|---|---|
| Lazy trie construction | Pays setup cost once | Heavy startup work on every import |
| Factory-created objects | Keeps hidden classes stable | Shape churn and deoptimization |
| Two-generation caching | Bounds memory while preserving hits | Unbounded cache growth |
| Manual string handling | Reduces allocation pressure | Array 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.
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.
| Question | If yes, cnfast fits | If no, probably unnecessary |
|---|---|---|
| Do you use Tailwind everywhere? | The payoff compounds | The helper is less central |
| Do components re-render often? | Caching has a real target | The hot path is smaller |
| Do you care about byte-identical output? | Parity matters, so this is useful | A simpler helper may be enough |
| Do you already use `cn` pervasively? | Migration is easy | The 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.