---
title: 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:

```ts
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:

```ts
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:

```ts
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.

**V8**

| 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 |

**JSC**

| 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](https://oxc.rs/) 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](https://github.com/gvergnaud/ts-pattern):

**Before**

```ts
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()
}
```

**After**

```ts
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)
}
```

**V8**

| ops/s · B/op | ts-pattern    | `match`     | `match`, compiled |
| ------------ | ------------- | ----------- | ----------------- |
| Per call     | 6.5M · 1.7 KB | 28M · 152 B | 425M · 0 B        |

**JSC**

| 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](https://github.com/aidenybai/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:

**Before**

```ts
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)
```

**After**

```ts
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 }
}
```

**V8**

| 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 |

**JSC**

| 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.

```ts
const copy = { ...target, [key]: value }

const copy = { ...target }
copy[key] = value
```

**V8**

| 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         |

**JSC**

| 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:

**V8**

| ops/s · B/op | `===`      | `Object.is` |
| ------------ | ---------- | ----------- |
| 1000 objects | 1.3M · 6 B | 5.3M · 1 B  |

**JSC**

| 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:

```ts
const copy = [...array]

const copy = array.slice()
```

**V8**

| 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 |

**JSC**

| 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](https://github.com/MichaelOstermann/remmi), which lets every function mutate inside a `withMutations` scope, copying each value only the first time it gets changed:

```ts
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:

```ts
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:

```ts
const next = tracks.slice()
for (const track of updates) next[index.get(track.id)] = track
```
