Skip to content

Encode and decode plain objects in linear time - #735

Open
emileber wants to merge 1 commit into
Shopify:remote-uifrom
emileber:emileber/linear-object-encoding
Open

emileber wants to merge 1 commit into
Shopify:remote-uifrom
emileber:emileber/linear-object-encoding

Conversation

@emileber

@emileber emileber commented Oct 23, 2026 •

Copy link
Copy Markdown

Summary

createBasicEncoder encodes and decodes plain objects in time quadratic in their number of keys. This change builds objects with Object.fromEntries instead, making both linear without changing the encoded format. It's also faster than the current spread for ordinary payloads.

Context

In both encode and decode, the plain-object branch rebuilds the accumulator on every key:

Object.keys(value).reduce((object, key) => ({...object, [key]: ...}), {})

Arrays and primitives are linear, so payload size in bytes isn't the problem; key count per object is. An object with a few thousand keys (for example, a snapshot of a key–value store) takes seconds to cross an endpoint.

One object with n keys and ~60-byte string values (Node 22, laptop):

Keys Before (encode / decode) After (encode / decode)
1,000 39 / 28 ms 4.2 / 0.7 ms
5,000 2,086 / 2,071 ms 3.5 / 1.1 ms
10,000 8,943 / 8,876 ms 5.4 / 1.9 ms

Ordinary payloads get faster too. A full call from a page to a Web Worker endpoint, carrying a nested payload of about 150 keys, 4 levels deep (Playwright, native spread for the "before" column):

µs per call Before After
Chromium 134 92
Chromium, 4× CPU throttling 469 333
WebKit 141 140
Firefox 206 160

Approach

  • Object.fromEntries(Object.keys(value).map(…)) in both encode and decode. Like the object spread it replaces, it defines own data properties, so:
    • keys such as __proto__ and constructor stay ordinary keys instead of changing the result's prototype;
    • an environment where Object.prototype.get or .set is defined, or where Object.prototype has a setter for a key, doesn't change the result. Plain assignment would let an inherited setter intercept the key.
  • The encoded format, cycle handling (seen), function IDs and retainer propagation are unchanged, so endpoints on 1.4.7 and on this version can talk to each other.
  • Object.fromEntries is ES2019 (Chrome 73, Firefox 63, Safari 12.1). That is within @shopify/browserslist-config's targets. Consumers supporting older browsers need the usual polyfill.
  • I also tried an earlier version of this PR that used Object.defineProperty with a fresh null-prototype descriptor per key. It was linear but slower than the spread for ordinary payloads (187 µs per call in Chromium), because of the per-key descriptor allocation.

One behavior difference worth noting: when the spread is compiled to Object.assign (older Babel targets), a __proto__ key was previously dropped or changed the prototype of the result. It is now an own property, matching native spread.

@emileber
emileber marked this pull request as ready for review October 23, 2026 22:46
The plain-object branches of createBasicEncoder's encode and decode
spread the accumulator on every key, which is quadratic in the number of
keys: an object with a few thousand keys takes seconds to cross an
endpoint.

Build the result with Object.fromEntries instead. Like the spread, it
defines own data properties, so keys such as __proto__ and constructor
stay ordinary keys, and neither a defined Object.prototype.get or .set
nor an inherited setter can change the result. It's also faster than
the spread for ordinary payloads. The encoded format is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@emileber
emileber force-pushed the emileber/linear-object-encoding branch from f656ae1 to 50229dc Compare October 27, 2026 05:52

This branch has not been deployed

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

1 participant