Skip to content
stdlib
Esc
↑↓navigate↵open⌘Jpreview
On this page

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