Skip to content
Technical Cases

34× faster in the worst case: optimizing match() without going native

Why the 'chain of ifs' in our Result/Option library's match() dispatch wasn't really a Big-O problem — and how a hidden Symbol-keyed tag halved the hot path and cut the worst case by ~34×.

Technical CasesJohnny Carreiro·May 29, 2026·4 min read

There was something about match() in @consolidados/results that bothered me. The dispatch was a sequential chain of ifs: primitive type check first, then iterate cases checking key in matcher, then discriminated union, then isOk(), isErr(), isSome(), isNone(). The reflex was obvious: that's O(n) linear, hash table fixes it.

The reflex was half right and half wrong. The wrong half is where the real win was.

The diagnosis

Before touching a line, I measured each dispatch path:

PathReal complexity
Primitives (string/number/symbol)O(1)cases[matcher]
Discriminated union (with 3rd arg)O(1)cases[matcher[discriminant]]
Mixed primitive + object unionO(C) over case count (for...in loop)
Result.Ok / Err · Option.Some / NoneO(1) in variants (4 branches), but falls into the mixed-union loop before reaching isOk()

The real O(n) only showed up on the mixed-union path — rare, and n is small. The hot path (Result/Option, n=2) was O(1) in Big-O, but paid a pile of constant cost per call: 3 typeof, a wasted for...in, a falsy discriminant check, then "isOk" in matcher (prototype walk) + matcher.isOk() (method call) + matcher.unwrap().

In other words: the cases object already was a hash table (V8/SpiderMonkey index plain objects by key). The problem wasn't missing a hashmap — the dispatch was iterating instead of looking up by the right key.

The hypothesis: a Symbol-keyed tag

If I put a hidden discriminant on each variant and read it in match, the dispatch becomes:

match.ts
1const tag = matcher[TAG];          // 1 property read
2if (tag) return cases[tag](...);   // 1 lookup

Implementation:

ok.ts
1const TAG = Symbol.for("@consolidados/results.tag");
2
3class Ok<T> {
4  readonly [TAG] = "Ok" as const;
5  // rest of the fluent API unchanged
6}

Symbol.for(...) guarantees the same global symbol even if the module gets bundled N times. The Symbol is invisible to for...in, Object.keys, JSON.stringify — zero collision risk with mixed-union variants like {Other: [...]}.

What about the mixed-union loop? I flipped it: instead of iterating cases checking key in matcher (O(C)), iterate the matcher's own keys (usually 1) and look them up in cases (O(1) per lookup).

Why NAPI/Rust/Zig wasn't the answer

The first temptation when you see slow dispatch is "let's go native". Doesn't work: the dispatch has to invoke handlers, which are JS closures. Closures don't cross the native boundary. Marshalling the matcher + picking a handler natively would cost more than the match itself.

Why DOD (data-oriented design) wasn't the answer

The other temptation: "ditch the classes, use plain data + free functions, dispatch becomes trivial". Doesn't work either: the fluent API (result.map(fn).flatMap(g).unwrap()) is a requirement. In JS, "fluent + plain-data" forces a prototype chain (via class or Object.create with a shared proto). A factory with per-instance closures — const Ok = v => ({_tag, map: fn => ..., unwrap: () => v}) — allocates a closure per method per instance and is worse than a class.

The win DOD would bring for dispatch was captured by the tag.

The measured result

Head-to-head bench with vitest bench, five strategies (H0 baseline → H4 tag) across ten scenarios:

ScenarioBefore (ops/s)After (ops/s)Speedup
Result.Ok hot path12.4 M25.4 M2.0×
Result.Err11.7 M25.5 M2.2×
Option.Some / None~11.7 M25.4 M2.2×
Mixed union n=105.0 M16.8 M3.35×
Mixed union n=50 (real worst case)0.5 M17.4 M🚀 34×
Primitive / discriminated≈ baseline≈ baseline1.05–1.10×

No regression in any scenario. 103/103 tests green. Public API unchanged (Symbol is invisible). Bundle didn't grow.

When it's not worth doing

  • A smaller library, or hot path with fixed n=2 (Result only): the gain fits inside noise for many uses. If you're not going to measure, you may not notice.
  • Primitive-only pattern matching: already O(1). The tag doesn't help.
  • You don't have a test suite that guarantees zero regression: the tag is invisible, but touching base classes of a public library without a safety net is a recipe for silent bugs.

The lesson

"Chain of ifs" looked like O(n) at a glance. It wasn't. The constant cost per call — repeated typeof, wasted for...in, in that prototype-walks, method call instead of property read — was what added up. Big-O is a useful model; when n is tiny, it hides more than it shows. Measuring each hot-path branch before "optimizing" by the name of the problem is what separates a real gain from a refactor that swaps six for half a dozen.

Full technical details in the BENCH.md in the repo. Install: bun add @consolidados/results.


Your library or service is showing a "chain of ifs" in the profile — or you're thinking of rewriting to fix performance? ConsoliDados is a senior engineering boutique — we diagnose the real cause before proposing a rewrite, and we ship code to production with real observability. Tell us the case: we respond with a viability assessment within 24 business hours. → Performance engineering