Skip to content

Conversation

@markerikson
Copy link
Collaborator

@markerikson markerikson commented Oct 28, 2025

This PR:

  • Adds new options for the StrictMode shallow copying option:
    • "with_symbols" is an alias for the current strict: false behavior
    • "full" is an alias for the current strict: true behavior
    • "strings_only" is a new option that only copies string properties for objects
  • Updates shallowCopy to implement the "strings_only" option using Object.keys(base).forEach(key => copy[key] = base[key]).

Copying via Object.keys() appears to be similar enough in perf at small object sizes (<1000 keys), but drastically faster at large object sizes (>1020 keys), at least in v8. This is due to v8 implementing a "fast properties" mode that caps out at 1020 properties, at which point it changes into "dictionary mode".

Background

In looking at different immutable update libraries, I saw that Mutative implements shallow copying objects like this:

const propIsEnum = Object.prototype.propertyIsEnumerable;

    // For best performance with shallow copies,
    // don't use `Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj));` by default.
    const copy: Record<string | symbol, any> = {};
    Object.keys(original).forEach((key) => {
      copy[key] = original[key];
    });
    Object.getOwnPropertySymbols(original).forEach((key) => {
      if (propIsEnum.call(original, key)) {
        copy[key] = original[key];
      }
    });
    return copy;

I was curious, and tried this out in the existing Immer benchmarks suite. I didn't see much difference. However, I have an RTK Query stress test benchmark that loads 1000+ components at once, and in that shallowCopy is often a major bottleneck. In some of my experiments, switching to Object.keys() cut the shallow copy time significantly. However, the results across the main Immer benchmarks suite, my RTKQ app benchmark, low-level microbenchmarks for object copying techniques, and a standalone "RTKQ update scenario" script have had varying results. I'm still not entirely sure of what I'm seeing, but I've learned some related pieces of info.

Large Object / RTKQ Benchmarks

I'd added a "large objects" scenario to the benchmarks suite, which created an object with 1000 properties. Using keys().forEach() didn't seem to improve that. Same for the "RTKQ updates" benchmark scenario, which ran 100 pairs of updates. However, I later created a standalone rtkq-testing.mjs script (included in this PR) that went from 100 to 3000 updates, and there I saw major differences. Some examples:

Baseline Spread

Running benchmark with array size: 10
Done in 7.5 ms (items: 10, avg: 0.753 ms / item)
Running benchmark with array size: 100
Done in 13.1 ms (items: 100, avg: 0.131 ms / item)
Running benchmark with array size: 250
Done in 63.9 ms (items: 250, avg: 0.256 ms / item)
Running benchmark with array size: 500
Done in 325.3 ms (items: 500, avg: 0.651 ms / item)
Running benchmark with array size: 1000
Done in 1490.1 ms (items: 1000, avg: 1.490 ms / item)
Running benchmark with array size: 1021
Done in 1311.0 ms (items: 1021, avg: 1.284 ms / item)
Running benchmark with array size: 1500
Done in 2555.0 ms (items: 1500, avg: 1.703 ms / item)
Running benchmark with array size: 2000
Done in 4727.8 ms (items: 2000, avg: 2.364 ms / item)
Running benchmark with array size: 3000
Done in 12927.8 ms (items: 3000, avg: 4.309 ms / item)

Here, the "item" is really pairs of pending/resolved pseudo-actions, where pending adds a new key to the state.api.queries object, and resolved updates that value.

Note that the time per update keeps increasing. It's not linear - if it was, we'd see (13.1 * 10 = 131ms) for 1000 items, instead we see 1490. It's clear that the larger the objects get, the longer the updates take.

I tried a whole bunch of variations on object updates, but I'll jump ahead and show the one that looks the best:

Always Obj.keys().forEach(), No Symbols

Running benchmark with array size: 10
Done in 6.4 ms (items: 10, avg: 0.640 ms / item)
Running benchmark with array size: 100
Done in 14.1 ms (items: 100, avg: 0.141 ms / item)
Running benchmark with array size: 250
Done in 58.5 ms (items: 250, avg: 0.234 ms / item)
Running benchmark with array size: 500
Done in 146.3 ms (items: 500, avg: 0.293 ms / item)
Running benchmark with array size: 1000
Done in 590.0 ms (items: 1000, avg: 0.590 ms / item)
Running benchmark with array size: 1021
Done in 585.4 ms (items: 1021, avg: 0.573 ms / item)
Running benchmark with array size: 1500
Done in 1428.8 ms (items: 1500, avg: 0.953 ms / item)
Running benchmark with array size: 2000
Done in 2774.0 ms (items: 2000, avg: 1.387 ms / item)
Running benchmark with array size: 3000
Done in 6667.6 ms (items: 3000, avg: 2.223 ms / item)

Note that the time-per-item still increases, but more slowly, and the overall time gets cut dramatically.

All the other variations run through the same benchmark
## With Size Loop

Running benchmark with array size: 10
Done in 5.8 ms (items: 10, avg: 0.584 ms / item)
Running benchmark with array size: 100
Done in 11.5 ms (items: 100, avg: 0.115 ms / item)
Running benchmark with array size: 250
Done in 59.4 ms (items: 250, avg: 0.238 ms / item)
Running benchmark with array size: 500
Done in 357.4 ms (items: 500, avg: 0.715 ms / item)
Running benchmark with array size: 1000
Done in 1474.0 ms (items: 1000, avg: 1.474 ms / item)
Running benchmark with array size: 1021
Done in 1399.6 ms (items: 1021, avg: 1.371 ms / item)
Running benchmark with array size: 1500
Done in 2977.1 ms (items: 1500, avg: 1.985 ms / item)
Running benchmark with array size: 2000
Done in 5940.2 ms (items: 2000, avg: 2.970 ms / item)
Running benchmark with array size: 3000
Done in 13534.4 ms (items: 3000, avg: 4.511 ms / item)

---

## Obj.keys() and Spread

Running benchmark with array size: 10
Done in 5.4 ms (items: 10, avg: 0.540 ms / item)
Running benchmark with array size: 100
Done in 13.2 ms (items: 100, avg: 0.132 ms / item)
Running benchmark with array size: 250
Done in 67.9 ms (items: 250, avg: 0.272 ms / item)
Running benchmark with array size: 500
Done in 384.4 ms (items: 500, avg: 0.769 ms / item)
Running benchmark with array size: 1000
Done in 1343.3 ms (items: 1000, avg: 1.343 ms / item)
Running benchmark with array size: 1021
Done in 1396.1 ms (items: 1021, avg: 1.367 ms / item)
Running benchmark with array size: 1500
Done in 2898.0 ms (items: 1500, avg: 1.932 ms / item)
Running benchmark with array size: 2000
Done in 5425.7 ms (items: 2000, avg: 2.713 ms / item)
Running benchmark with array size: 3000
Done in 14085.2 ms (items: 3000, avg: 4.695 ms / item)

---

## Size Loop + Obj.keys() + Obj.ownSymbols()

Running benchmark with array size: 10
Done in 5.6 ms (items: 10, avg: 0.563 ms / item)
Running benchmark with array size: 100
Done in 14.0 ms (items: 100, avg: 0.140 ms / item)
Running benchmark with array size: 250
Done in 63.2 ms (items: 250, avg: 0.253 ms / item)
Running benchmark with array size: 500
Done in 345.0 ms (items: 500, avg: 0.690 ms / item)
Running benchmark with array size: 1000
Done in 1352.5 ms (items: 1000, avg: 1.352 ms / item)
Running benchmark with array size: 1021
Done in 1230.4 ms (items: 1021, avg: 1.205 ms / item)
Running benchmark with array size: 1500
Done in 2333.6 ms (items: 1500, avg: 1.556 ms / item)
Running benchmark with array size: 2000
Done in 4189.9 ms (items: 2000, avg: 2.095 ms / item)
Running benchmark with array size: 3000
Done in 9866.5 ms (items: 3000, avg: 3.289 ms / item)

---

## Size Loop + Obj.keys() (no symbols)

Running benchmark with array size: 10
Done in 5.7 ms (items: 10, avg: 0.567 ms / item)
Running benchmark with array size: 100
Done in 13.3 ms (items: 100, avg: 0.133 ms / item)
Running benchmark with array size: 250
Done in 56.9 ms (items: 250, avg: 0.228 ms / item)
Running benchmark with array size: 500
Done in 371.0 ms (items: 500, avg: 0.742 ms / item)
Running benchmark with array size: 1000
Done in 1484.0 ms (items: 1000, avg: 1.484 ms / item)
Running benchmark with array size: 1021
Done in 1233.6 ms (items: 1021, avg: 1.208 ms / item)
Running benchmark with array size: 1500
Done in 2357.0 ms (items: 1500, avg: 1.571 ms / item)
Running benchmark with array size: 2000
Done in 3984.9 ms (items: 2000, avg: 1.992 ms / item)
Running benchmark with array size: 3000
Done in 10407.5 ms (items: 3000, avg: 3.469 ms / item)

---

## Obj.keys(), Keys Reuse, Symbols

Running benchmark with array size: 10
Done in 6.1 ms (items: 10, avg: 0.611 ms / item)
Running benchmark with array size: 100
Done in 15.0 ms (items: 100, avg: 0.150 ms / item)
Running benchmark with array size: 250
Done in 72.3 ms (items: 250, avg: 0.289 ms / item)
Running benchmark with array size: 500
Done in 386.8 ms (items: 500, avg: 0.774 ms / item)
Running benchmark with array size: 1000
Done in 1543.9 ms (items: 1000, avg: 1.544 ms / item)
Running benchmark with array size: 1021
Done in 1135.5 ms (items: 1021, avg: 1.112 ms / item)
Running benchmark with array size: 1500
Done in 2135.8 ms (items: 1500, avg: 1.424 ms / item)
Running benchmark with array size: 2000
Done in 3539.8 ms (items: 2000, avg: 1.770 ms / item)
Running benchmark with array size: 3000
Done in 8105.9 ms (items: 3000, avg: 2.702 ms / item)

---

## Obj.keys(), Keys Reuse, No Symbols

Running benchmark with array size: 10
Done in 6.2 ms (items: 10, avg: 0.623 ms / item)
Running benchmark with array size: 100
Done in 13.2 ms (items: 100, avg: 0.132 ms / item)
Running benchmark with array size: 250
Done in 70.4 ms (items: 250, avg: 0.282 ms / item)
Running benchmark with array size: 500
Done in 435.3 ms (items: 500, avg: 0.871 ms / item)
Running benchmark with array size: 1000
Done in 1533.9 ms (items: 1000, avg: 1.534 ms / item)
Running benchmark with array size: 1021
Done in 1287.0 ms (items: 1021, avg: 1.261 ms / item)
Running benchmark with array size: 1500
Done in 2031.2 ms (items: 1500, avg: 1.354 ms / item)
Running benchmark with array size: 2000
Done in 3464.3 ms (items: 2000, avg: 1.732 ms / item)
Running benchmark with array size: 3000
Done in 8041.9 ms (items: 3000, avg: 2.681 ms / item)

---

## Always Obj.keys().forEach() and Symbols

Running benchmark with array size: 10
Done in 6.0 ms (items: 10, avg: 0.605 ms / item)
Running benchmark with array size: 100
Done in 11.3 ms (items: 100, avg: 0.113 ms / item)
Running benchmark with array size: 250
Done in 44.0 ms (items: 250, avg: 0.176 ms / item)
Running benchmark with array size: 500
Done in 136.3 ms (items: 500, avg: 0.273 ms / item)
Running benchmark with array size: 1000
Done in 563.7 ms (items: 1000, avg: 0.564 ms / item)
Running benchmark with array size: 1021
Done in 555.1 ms (items: 1021, avg: 0.544 ms / item)
Running benchmark with array size: 1500
Done in 1577.9 ms (items: 1500, avg: 1.052 ms / item)
Running benchmark with array size: 2000
Done in 2745.9 ms (items: 2000, avg: 1.373 ms / item)
Running benchmark with array size: 3000
Done in 7055.6 ms (items: 3000, avg: 2.352 ms / item)

---
## Reflect.ownKeys() (strings and symbols)

Running benchmark with array size: 10
Done in 5.7 ms (items: 10, avg: 0.574 ms / item)
Running benchmark with array size: 100
Done in 11.3 ms (items: 100, avg: 0.113 ms / item)
Running benchmark with array size: 250
Done in 48.6 ms (items: 250, avg: 0.195 ms / item)
Running benchmark with array size: 500
Done in 155.1 ms (items: 500, avg: 0.310 ms / item)
Running benchmark with array size: 1000
Done in 644.1 ms (items: 1000, avg: 0.644 ms / item)
Running benchmark with array size: 1021
Done in 658.8 ms (items: 1021, avg: 0.645 ms / item)
Running benchmark with array size: 1500
Done in 1716.6 ms (items: 1500, avg: 1.144 ms / item)
Running benchmark with array size: 2000
Done in 3067.3 ms (items: 2000, avg: 1.534 ms / item)
Running benchmark with array size: 3000
Done in 8190.5 ms (items: 3000, avg: 2.730 ms / item)

Larger Object Benchmarks

Based on that, I tried modifying the "largeObjects" benchmark, playing around with various object sizes. In the process, I specifically started to see varying behavior for that one benchmark scenario as I bumped the size past ~1000 properties. At 1000, {...base} was faster and Object.keys() was slower. At 1500, they flipped. I experimented and narrowed it down to something like 1012 or 1020 as the breakpoint.

Based on this I concluded that I had to be hitting some kind of built-in limit in v8.

v8 Fast Properties

I had run across mentions of v8's "fast properties" implementation before, where it optimizes property access for certain kinds of objects:

However, those gave no details on what the real triggers were for fast properties, or any specific size limits.

This evening I tried asking around online, and I quickly got some key pointers to the relevant v8 behavior and sources:

That snippet is:

static const int kDescriptorIndexBitCount = 10;
static const int kFirstInobjectPropertyOffsetBitCount = 7;
// The maximum number of descriptors we want in a descriptor array.  It should
// fit in a page and also the following should hold:
// kMaxNumberOfDescriptors + kFieldsAdded <= PropertyArray::kMaxLength.
static const int kMaxNumberOfDescriptors = (1 << kDescriptorIndexBitCount) - 4;

and if you do the math, (1 << 10) - 4 is 1020.

This was confirmed by one of the tests, which also specifically hardcodes 1020 as the expected max properties before an object converts from "fast property" mode into "dictionary" mode:

That matches the "around 1000" breakpoint I was seeing.

I also was pointed to these SO entries, which have some fantastic writeups from v8 devs explaining the nuances of the different modes:

Variations I've Tried

Without copy-pasting all of them:

  • Obj spread
  • Adding a for (var _key in obj) size++ counter loop
  • Adding Object.keys() just by itself
  • Using the size loop or the keys array to conditionally do either a spread or keys.forEach()
    • with and without an accompanying Object.getOwnPropertySymbols(). Note that we have a couple tests that assert we do copy symbols by default. Leaving out Object.getOwnPropertySymbols() is faster, but fails those tests.
    • with Object.keys() for the initial size check, and then reusing it for the copy
  • Reflect.ownKeys()

Is This An Improvement?

I'm actually pretty confused at this point :)

  • The micro benchmarks don't change much, but only one scenario (update-largeObject2) has over 1020 properties in an object. That example does seem to get faster with this change
  • Earlier, I'd specifically seen my RTKQ example app get drastically faster in terms of total scripting time, and time spent in reducer() and shallowCopy() specifically... but I'm re-running tests with that app tonight with this change on top of both 10.2 and Rewrite finalization system to use a callback approach instead of tree traversal #1183 , and I'm not seeing the big obvious changes I was before. Some, possibly, but not as much.
  • But on the other hand the local rtkq-testing.mjs that tries to replicate the same update sequence always shows a drastic improvement with Object.keys().forEach()
  • Also also I have no idea how this performs relatively in Firefox (SpiderMonkey) or Safari (JSC). I don't know if they have similar optimizations or property count behavior breakpoints. I don't like leaning on optimizations for one engine, but given the popularity of Chrome + Edge and Node, there's some justification for "do the thing that works faster in v8"

This PR

For this PR, I updated the StrictMode type to be more of an extended enum. Ultimately it's really adding a "strings_only" option, which triggers the Object.keys().forEach() path. Otherwise, it defaults to {...base}, which includes symbols. The other enum options are essentially aliases for existing behavior.

My thought is that we could ship this in a 10.3 minor, as it should leave the existing behavior unchanged, and users can opt in to the keys/strings-only approach if desired. Then, if this works well, we could flip the defaults in v11. That does also bring into question whether Immer should default to copying symbols for correctness, or just strings for performance. We could also remove the boolean options in v11 and just have the enum options.

We're already looking at switching to "just strings" for loose iteration in v11, so there could be an argument that shipping faster loose behavior as a default while retaining the ability to opt into correctness could be a theme for v11.

Obviously very happy to discuss details on if we want to ship this, and if so, how :)

@markerikson markerikson force-pushed the feature/shallow-copy-perf branch from e2a87db to df897c3 Compare October 28, 2025 03:31
@markerikson markerikson force-pushed the feature/shallow-copy-perf branch from df897c3 to b961670 Compare October 28, 2025 03:39
@coveralls
Copy link

Pull Request Test Coverage Report for Build 18863334140

Details

  • 7 of 140 (5.0%) changed or added relevant lines in 3 files are covered.
  • No unchanged relevant lines lost coverage.
  • Overall coverage decreased (-1.2%) to 41.192%

Changes Missing Coverage Covered Lines Changed/Added Lines %
src/utils/common.ts 6 12 50.0%
perf-testing/rtkq-testing.mjs 0 127 0.0%
Totals Coverage Status
Change from base Build 18857121209: -1.2%
Covered Lines: 1296
Relevant Lines: 3920

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants