Performance
This monorepo addresses performance almost fanatically, including spending hundreds of USD worth of tokens to:
- Find performance improvements ad-hoc (“Pump perf, make no mistakes” - you never know)
- Orchestrate sub-agents for experiments
- Learn techniques from 3rd-party libraries
If anything can be shaved off, and neither readability nor simplicity are sacrificed in doing so (which is often the case), it gets shipped. If performance can be increased by reducing the API surface area, it might get shipped.
Why
There are two extremes: Not thinking about performance at all, or thinking about it too much.
Ideally we would find a balance between the two on a case-by-case basis, which is hard, especially because it has to hold up over time: What was fast enough yesterday might suddenly become too slow tomorrow.
When we don’t think about performance at all, our products become slower and slower over time, and we get used to it, we might not even notice it.
When we think about it too much, sleepless nights thinking about how to pump performance, historically that would have taken a lot of time, while turning code into something that can only be described as an abomination (and earned us a “premature optimization” talk).
We also run into the danger of optimizing the wrong thing: Improving few Effects reading many Signals, which is unrealistic, while decreasing the performance of many Effects reading few Signals, which is much more realistic.
Today, coding agents make this much easier and faster to explore than ever before, even if they have to be steered carefully, away from the wrong case like above.
The worst performance problem is death by a thousand cuts: Not a single particular thing that sticks out in profiles, but dozens of slightly unoptimized things that compound, which nobody wants to touch.
The more code we ship that is performance considerate, the more we can afford to be sloppy elsewhere.
Memory allocations
In JavaScript, more often than not functions create a shallow copy even when nothing changed:
const a = [1, 2, 3]
const b = a.map((i) => i)
a === b // false
Sometimes this can be a deal breaker, for example in React and signal libraries, all of which depend on === checks to skip work.
So now you’ll come to find that libraries often ship something like this:
function map(target, fn) {
let didChange = false
const result = target.map((before) => {
const after = fn(before)
didChange ||= before !== after
return after
})
return didChange ? result : target
}
While this implementation does return the original reference when possible, it still creates a copy every time, even when nothing changed.
In JavaScript, these allocations don’t show up where they happen: They end up as GC time spread across the whole profile, which no single function owns.
We generally speaking try to avoid this by doing something similar to:
function map(target, fn) {
let copy
for (let i = 0; i < target.length; i++) {
const before = target[i]
const after = fn(before)
if (before === after) continue
// Copy once now that we know we are actually going to write:
copy ??= target.slice()
copy[i] = after
}
return copy ?? target
}
For the keen eye: There is a difference in behavior when using sparse arrays. If you have a serious use case for sparse arrays, you probably shouldn’t hand them to any 3rd-party library anyway.
| ops/s · B/op | Copy first | Copy on write |
|---|---|---|
| 10 items, unchanged | 33M · 232 B | 359M · 0 B |
| 10 items, all changed | 34M · 232 B | 37M · 128 B |
| 1000 items, unchanged | 498K · 8.3 KB | 5.4M · 15 B |
| 1000 items, one changed | 505K · 8.2 KB | 1.1M · 8.2 KB |
| 1000 items, all changed | 525K · 8.2 KB | 756K · 8.2 KB |
| ops/s | Copy first | Copy on write |
|---|---|---|
| 10 items, unchanged | 22M | 351M |
| 10 items, all changed | 22M | 26M |
| 1000 items, unchanged | 277K | 4.7M |
| 1000 items, one changed | 268K | 557K |
| 1000 items, all changed | 274K | 457K |
Build-time optimizations
Our stdlib-transform uses the fantastic oxc toolchain to provide several build-time optimizations, in some cases offering 0-cost abstractions. oxc is fast enough to blast through 1000 files in less than a second, so there is no particular reason to not do that.
The main challenge here is that code needs to be designed around this: As of today, a transformer can’t easily use type information, so it has to understand what’s happening purely by looking at the syntax.
A few examples:
match
Our pattern matching tool featuring type-level exhaustiveness checks, greatly inspired by ts-pattern:
type Shape =
| { kind: "circle", radius: number }
| { kind: "square", size: number }
function label(value: Shape | null) {
return match(value)
.case(null, "Nothing")
.shape({ kind: "circle" }, "A circle")
.shape({ kind: "square" }, "A square")
.unwrap()
}import { throwUnmatched } from "@monstermann/stdlib/internals/match/throwUnmatched.mjs"
function label(value: Shape | null) {
return value === null ? "Nothing"
: typeof value === "object" && value !== null && value.kind === "circle" ? "A circle"
: typeof value === "object" && value !== null && value.kind === "square" ? "A square"
: throwUnmatched(value)
}| ops/s · B/op | ts-pattern | match |
match, compiled |
|---|---|---|---|
| Per call | 6.5M · 1.7 KB | 28M · 152 B | 425M · 0 B |
| ops/s | match |
match, compiled |
ts-pattern |
|---|---|---|---|
| Per call | 141M | 785M | 13M |
This only works because match has .case(), .shape() and .cond() instead of a single .with(): Whether .with(x) compares a value, a shape or runs a predicate depends on the type of x, so it can’t be compiled, while each of ours can only mean one thing. pattycake, a compiler for ts-pattern, has been stuck as “highly experimental” since early 2024, falling back to ts-pattern for whatever it can’t compile.
Other examples
Object.setPath, Object.merge and pipe are compiled into handwritten code when possible:
const a = Object.setPath(state, "user.settings.theme", "dark")
const b = Object.merge(task, { done, title: "New" })
const c = pipe(value, (v) => v + 1, (v) => v * 2)const a = setPath0(state, "dark")
const b = merge0(task, done, "New")
const c = ((value + 1) * 2)
function setPath0(t, v) {
const t0 = t.user, t1 = t0.settings, t2 = t1.theme
if (Object.is(t2, v)) return t
return { ...t, user: { ...t0, settings: { ...t1, theme: v } } }
}
function merge0(t, v0, v1) {
if (Object.is(t.done, v0) && Object.is(t.title, v1)) return t
return { ...t, done: v0, title: v1 }
}| ops/s · B/op | Runtime | Compiled |
|---|---|---|
Object.setPath, unchanged |
28M · 32 B | 434M · 0 B |
Object.setPath, changed |
11M · 232 B | 33M · 200 B |
Object.merge, unchanged |
26M · 72 B | 328M · 0 B |
Object.merge, changed |
18M · 144 B | 65M · 72 B |
pipe, three steps |
16M · 264 B | 17M · 208 B |
| ops/s | Runtime | Compiled |
|---|---|---|
Object.setPath, unchanged |
96M | 691M |
Object.setPath, changed |
21M | 30M |
Object.merge, unchanged |
108M | 929M |
Object.merge, changed |
53M | 80M |
pipe, three steps |
40M | 51M |
Smaller findings
Smaller things, which only show once you measure them the way an app uses them.
Computed properties
In V8, the following snippet performs differently depending on how many shapes of target the function sees: one (monomorphic), a few (polymorphic) or many (megamorphic). JavaScriptCore runs both about the same.
A naive benchmark would test Object.set(foo, "bar", true) only, where the engine sees only one shape, however that’s not what is going to happen.
const copy = { ...target, [key]: value }
const copy = { ...target }
copy[key] = value
| ops/s · B/op | Computed property | Spread, then assign |
|---|---|---|
| One shape | 102M · 40 B | 102M · 40 B |
| Four shapes | 4.7M · 240 B | 26M · 240 B |
| ops/s | Computed property | Spread, then assign |
|---|---|---|
| One shape | 89M | 91M |
| Four shapes | 22M | 23M |
Object.is
V8 slows down when comparing two objects with === instead of Object.is, whereas other types stay consistently fast. JavaScriptCore doesn’t. Mapping over 1000 objects where nothing changes:
| ops/s · B/op | === |
Object.is |
|---|---|---|
| 1000 objects | 1.3M · 6 B | 5.3M · 1 B |
| ops/s | === |
Object.is |
|---|---|---|
| 1000 objects | 2.9M | 2.9M |
Copying arrays
slice() copies an array at least as fast as a spread, and a large one faster:
const copy = [...array]
const copy = array.slice()
| ops/s · B/op | Spread | slice() |
|---|---|---|
| 10 items | 75M · 128 B | 94M · 128 B |
| 1000 items | 4.3M · 8.2 KB | 5.1M · 8.2 KB |
| ops/s | Spread | slice() |
|---|---|---|
| 10 items | 93M | 96M |
| 1000 items | 1.6M | 2.0M |
What didn’t work
Linked lists
We shipped doubly linked lists several times for different things. Our gut feeling would say that removing things from either end or in between should be faster this way, but that turned out to not be the case.
Removing things from the middle of a Set is better, as long as every item is unique.
Removing things from either end of an Array via pop and shift is better.
If you need both, indexOf followed by a splice isn’t slow enough to justify a doubly linked list.
Mutation fast paths
We tried shipping opt-in mutation as a fast path, twice. First with remmi, which lets every function mutate inside a withMutations scope, copying each value only the first time it gets changed:
const before = []
const after = withMutations(() => {
const a = push(before, 0) // Copies
const b = push(a, 1) // Mutates
const c = push(b, 2) // Mutates
return c
})
before // []
after // [0, 1, 2]
Then with a { mut: true } option on individual functions, for arrays we already own:
const tasks = Array.map(all, toView)
Array.filter(tasks, isOpen, { mut: true })
Array.filter(tasks, isVisible, { mut: true })
Neither beat plain mutation. From remmi’s own benchmarks, pushing values into an empty array (Apple M1 Max, Node 24):
| Ops/s | |
|---|---|
| Mutation | 29M |
remmi (withMutations) |
6.8M |
| Copy + mutation | 4.0M |
| mutative | 143K |
| immer | 83K |
The big win remmi gave us came from a single loop, which applied hundreds of updates one at a time and copied the entire array for each one. Copying once by hand did the same.
So there is no mutation mode to opt into. Instead look at hot paths, look at anything that loops, and optimize those by hand. A copy nobody else holds a reference to can just be changed with plain JavaScript:
const next = tracks.slice()
for (const track of updates) next[index.get(track.id)] = track