Skip to main content

Performance & size

Measured runtime cost per call, cold and warm scan times, an honest breakdown of the 11.1 kB bundle-size badge, and how each number is verified.

Runtime cost

Every number here is measured on the built package under Node 22. The first three rows are per call, warm; the last two are the build plugin walking a whole project.

Measured runtime cost per call, and cold and warm project scan times.
OperationCost
cn("px-2 py-1", …, "px-4")~123 ns
ss() with 3 groups~385 ns
ss() with 8 groups~970 ns
Cold scan, 2,000-file project~98 ms
Warm rescan, same project~17 ms

A single-map ss call takes a dedicated path with no argument loop, so it costs what it did before variadic arguments existed. Each further argument is one more map walk, a nested group costs the same as a top-level one, and there is exactly one tailwind-merge pass per call whatever the shape.

Passing only class strings costs about what cn does — given only class values, ss is cn.

The two scan rows are build time, not browser time. The plugin caches extraction per file by mtime and size, coalesces concurrent scans, and only rewrites the sidecar when the class list actually changed, so an unrelated keystroke costs a stat rather than a re-parse.

Bundle size

The bundle-size badge reads 11.1 kB because it measures the whole dependency tree. That number is real, but almost none of it is tailess.

Minified and gzipped size of tailess and its single runtime dependency.
Packagemin+gzip
tailwind-merge~8.4 kB
tailess itself~2.7 kB
Total~11.1 kB

tailwind-merge is the one runtime dependency, and it is what a cn() helper is built on in essentially every Tailwind codebase — roughly two thirds of Tailwind installs already pull it in.

You may have paid for most of this already

If your project already depends on tailwind-merge, your bundler keeps the single shared copy and adding tailess costs the 2.7 kB, not the 11.1 kB.

About a fifth of tailess' own 2.7 kB is the text of its development-time warnings. That text cannot be dead-code-eliminated without either risking a crash when the package is loaded unbundled or putting a process.env read on the render path — both were measured and rejected.

Why clsx is not a dependency

clsx is the other half of the usual cn() pairing, and tailess does not depend on it. An internal join module does the same job in about forty lines, which keeps the dependency count at 1.

The swap was worth making for two reasons. That code compresses slightly better alongside the rest of the package than clsx does on its own — 35 gzipped bytes smaller in the end — and it removes a package from the tree.

clsx stays a devDependency purely as a test oracle, which is what makes the replacement checkable rather than merely plausible.

Verified on

The test suite runs the real Tailwind compiler over real fixture directories and asserts the generated rules exist — the only assertion that actually fails when the bridge breaks. It covers both plugins, the split-CSS-entry @import chain, the add-a-class and delete-a-class dev cycle, the inline fallback path, and every one of the 149 keys.

Tests
340, across 24 files
Coverage
96% statements, 91% branches
Tailwind
4.3.3
Manually verified
Vite 8 with @tailwindcss/vite 4, Next.js 16 (Turbopack and webpack)

Two suites check a claim against a second implementation rather than against a list someone typed. The parity suite hands the scanner a source string, evaluates that same string with the real helpers, and asserts every prefixed class the runtime produced is among the candidates the scanner found — so the two halves of the bridge cannot drift apart in the one direction that matters.

The join suite does the same for the clsx replacement, with clsx itself as the oracle: null-prototype objects, Proxies, boxed primitives, a getter that throws, symbol keys, 200-deep nesting, and the circular array that overflows the stack in both, plus 50,000 generated cases from a seeded PRNG so a failure replays exactly.

CI additionally runs lint, typecheck, publint and arethetypeswrong on every push, and the release workflow cannot publish unless all of them pass.