Documentation › Research › Apollo performance guide · ← Part 1 · Part 3 →
This is research on Apollo Client's
InMemoryCache, the cache this project replaces. It does not describeInMemoryCacheRs; for that design, see RFC 0001: The architecture ofInMemoryCacheRs.
StoreWriter.writeToStore (architecture §4.1) is two phases. Phase 1 is pure computation over the
payload; phase 2 touches the store.
flowchart TB
START["writeToStore(store, { query, result, dataId, variables })"]:::api
subgraph phase1["Phase 1 — staging (no store mutation)"]
direction TB
P1A["canonicalStringify(variables) → varString<br/><i>once per write, O(V)</i>"]:::memo
P1B["processSelectionSet — recursive, once per object occurrence<br/><b>per object:</b> new Set, new Map, new Trie<br/><b>per field:</b> new path array (length D), getStoreFieldName,<br/>getChildMergeTree, getMergeFunction, DeepMerger.merge"]:::write
P1C["policies.identify(result) per object occurrence<br/><i>default id: a string concat;<br/>keyFields: K key paths + JSON.stringify</i>"]:::write
P1D["duplicate guard + isFresh, then<br/>context.incomingById: Map#lt;dataId, staged#gt;<br/><i>duplicates collapse here, after their<br/>fields were already processed</i>"]:::store
P1A --> P1B --> P1C --> P1D
end
subgraph phase2["Phase 2 — effectful merge"]
direction TB
P2A["for each staged entity (distinct dataId):<br/>applyMerges (user merge functions)"]:::write
P2B["__DEV__ only: warnAboutDataLoss<br/><i>per field with a selection set</i>"]:::dirty
P2C["store.merge(dataId, storeObject)<br/><b>new DeepMerger per entity</b><br/><b>equal() per object-valued field<br/>that is not === the stored value</b>"]:::dirty
P2D["group.dirty(dataId, storeFieldName)<br/>per field that actually changed"]:::memo
P2A --> P2B --> P2C --> P2D
end
START --> phase1 --> phase2 --> BC["broadcastWatches() unless txCount #gt; 0"]:::memo
classDef api fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#0f172a
classDef write fill:#fde68a,stroke:#d97706,stroke-width:2px,color:#0f172a
classDef store fill:#dcfce7,stroke:#16a34a,stroke-width:2px,color:#0f172a
classDef memo fill:#e9d5ff,stroke:#7c3aed,stroke-width:2px,color:#0f172a
classDef dirty fill:#fecaca,stroke:#dc2626,stroke-width:2px,color:#0f172a
Phase 1 is proportional to the payload, not to the number of distinct entities. The
duplicate guard (context.written, architecture §4.7)
and the isFresh check run at the end of processSelectionSet, after the object's fields
have been processed and every child object has been recursed into. An entity that appears
three times in a payload is traversed and identified three times and staged once; the
probe counts this in its section 1 (three occurrences of an entity with one child: six
identify calls). Phase 2 is proportional to the distinct entities staged.
This is the part most people underestimate. Reading processSelectionSet allocation by
allocation:
private processSelectionSet({ dataId, result, selectionSet, context, mergeTree, path }) {
let incoming: StoreObject = {}; // 1 object per object
// ...
const fieldNodeSet = new Set<FieldNode>(); // 1 Set per object
this.flattenFields(selectionSet, result, context, typename) // 1 Map + 1 Trie per object
.forEach((context, field) => {
const path = [...currentPath, field.name.value]; // 1 array of length D per FIELD
// ...
const storeFieldName = policies.getStoreFieldName({...}); // string build; canonicalStringify if args
const childTree = getChildMergeTree(mergeTree, storeFieldName);
let incomingValue = this.processFieldValue(value, field, context, childTree, path);
const merge = policies.getMergeFunction(typename, field.name.value, childTypename);
incoming = context.merge(incoming, { [storeFieldName]: incomingValue }); // 1 object per FIELD
});And inside processFieldValue, for a list:
if (isArray(value)) {
return value.map((item, i) => {
const value = this.processFieldValue(item, field, context, getChildMergeTree(mergeTree, i), [...path, i]);
maybeRecycleChildMergeTree(mergeTree, i);
return value;
});
}Per write of E objects with F fields each, at depth up to D:
| Allocation | Count | Note |
|---|---|---|
incoming store object |
2E |
the initial {}, plus the one copy the write's shared DeepMerger makes on the first field (see below) |
Set<FieldNode> |
E |
|
Map<FieldNode, TContext> (from flattenFields) |
E |
|
Trie (limitingTrie) |
E |
fragment-revisit guard, discarded immediately |
readField closure and { ...result, ...incoming } |
E each |
the spread, made by identify for the key function, copies O(F) properties |
single-key object { [storeFieldName]: value } |
E · F |
fed to DeepMerger |
path array of length ≤ D |
E · F, plus one per list element |
[...currentPath, name] per field, [...path, i] per list element |
MergeTree node |
up to E · F, plus one per list element |
recycled via maybeRecycleChildMergeTree |
DeepMerger for store.merge |
one per distinct staged entity | phase 2 |
The
patharray is the sneaky one.[...currentPath, field.name.value]copies the whole path at every field, so each field costsO(D)time and words, and a write costsO(E · F · D). For a flat list the items sit atD = 2and the copy is negligible. For a chain ofDnested entities, leveldcopies a path of lengthdfor each of itsFfields, so the chain costsO(F · D²)in total. The constant is tiny (copying pointers), so the quadratic term only becomes visible at depths of several hundred: see thewrite normalizedcolumn of the depth table in §3.3.
The one thing that is not re-allocated: context.merge is a single
makeProcessedFieldsMerger() for the whole write, and DeepMerger.shallowCopyForMerge
tracks pastCopies in a Set, so a given incoming object is copied once and then
mutated in place for the remaining F − 1 fields. Building an entity is O(F), not
O(F²).
public shallowCopyForMerge<T>(value: T): T {
if (isNonNullObject(value)) {
if (!this.pastCopies.has(value)) {
value = Array.isArray(value) ? value.slice(0) : { __proto__: Object.getPrototypeOf(value), ...value };
this.pastCopies.add(value);
}
}
return value;
}The trade-off is memory: pastCopies retains every intermediate object for the duration of
the write, so peak memory during a large write is proportional to the payload, not to the
delta.
The line with the largest potential cost on the write path is in entityStore.ts:
function storeObjectReconciler(existingObject, incomingObject, property) {
const existingValue = existingObject[property];
const incomingValue = incomingObject[property];
// Wherever there is a key collision, prefer the incoming value, unless
// it is deeply equal to the existing value. It's worth checking deep
// equality here (even though blindly returning incoming would be
// logically correct) because preserving the referential identity of
// existing data can prevent needless rereading and rerendering.
return equal(existingValue, incomingValue) ? existingValue : incomingValue;
}equal is @wry/equality's cycle-tolerant deep comparison. DeepMerger calls the
reconciler for every incoming field that the stored entity already has and whose value is
not === the stored one. For data arriving fresh off the network, unchanged primitives
(strings, numbers, booleans) are ===, so they are skipped before the reconciler is even
called. Every object-valued field is a fresh object, though: References (the writer makes
a new { __ref } object each time), lists, embedded objects, and JSON scalars all pay for
equal().
It does not run when the entity is new: EntityStore.merge calls
new DeepMerger(storeObjectReconciler).merge(existing, incoming), and with no existing
there is nothing to reconcile. That is why creating an entity and overwriting it with
identical data cost about the same (§2.7): a creation dirties
every field, an overwrite compares every field instead.
This is a deliberate trade: pay O(B) on the write to preserve referential identity, so
the read path's memo entries stay valid and React does not re-render. The comment says so
explicitly. equal() walks the whole value when the two values are equal and stops at the
first difference when they are not, so the costs below are for the unchanged case, the
one that polling produces:
| Field value shape | Equality cost when unchanged |
|---|---|
scalar (string, number, boolean) |
never reaches equal() — it is === |
Reference ({ __ref }) |
O(1) — one key |
array of Reference of length N |
O(N) (O(1) when the lengths differ) |
embedded object blob of size B |
O(B) — full recursive walk |
array of embedded blobs, total size B |
O(B) |
Consequence. A single large untyped JSON blob stored in one cache field is deep-compared in its entirety on every write that touches that field, even when the field is unchanged. This is the number-one cause of "why is writing my unchanged payload so slow" (see §7.4).
getStoreFieldName (architecture §3.3) builds the storage key. Without arguments it is the field name;
with arguments it goes through getStoreKeyName → canonicalStringify.
It is worth being precise about what canonicalStringify memoizes, because it is easy to
assume more than it does:
// utilities/internal/canonicalStringify.ts
const keys = Object.keys(value);
if (keys.every(everyKeyInOrder)) return value; // fast path: already sorted
const unsortedKey = JSON.stringify(keys);
let sortedKeys = sortingMap.get(unsortedKey); // LRU of 1 000 KEY-SET PERMUTATIONSThe LRU maps a key-set permutation ('["type","limit"]') to the sorted array of those
same keys. It does not memoize the serialized output. So:
- the full
JSON.stringifywalk runs on every call —O(A)for arguments of sizeA, always; - what is saved is
keys.sort(), and only for objects whose keys were not already in order. The per-objectObject.keys, the order check and, for an unsorted object, theJSON.stringify(keys)lookup key and a re-ordered copy of the object still happen on every call; - the LRU is bounded by the number of distinct object shapes in the app, not by the
number of distinct argument values, so it rarely fills up. Fresh variable objects on
every render cost nothing extra here. (
cache.gc()empties it.)
There is no memoization one level up either: Policies.getStoreFieldName is called
per field per object on both the read and the write path (on the read path, once per
field of every memo entry that recomputes) and rebuilds the key every time. Argument cost
is therefore O(A) per field occurrence: it scales with the size of the argument
structure, not with how many distinct values it takes. Separately, every write,
read and diff serializes the whole variables object once into varString, the
memo-key component: O(V) per call.
The probe writes one root field whose single argument is an object nested d levels
deep (each level has three keys, so A grows linearly with d), over a one-item result:
d |
write, same variables object |
scale | write, fresh variables object each call |
scale |
|---|---|---|---|---|
| 1 | 33.6 µs | — | 34.9 µs | — |
| 8 | 43.0 µs | 0.16 | 44.5 µs | 0.16 |
| 32 | 79.9 µs | 0.46 | 80.6 µs | 0.45 |
| 128 | 247.6 µs | 0.77 | 245.0 µs | 0.76 |
The two columns agree within the run-to-run noise, which is the direct confirmation that
nothing is memoized per value — reusing the same variables object buys nothing. Cost
tracks the size of the argument structure: the scale column rises towards 1.00 as the
argument grows and the fixed cost of the one-item write stops dominating.
Argument count looks free in the probe, but only because of where the arguments sit:
0, 2, 8 and 24 arguments on the one root field over a 50-item result write in 912.2 µs,
612.0 µs, 588.7 µs and 511.7 µs. The times do not grow with the count: the first row is
the section's first measurement and carries its warm-up, and the others fall as the
process warms. That is because the one key is built a constant number of times per
operation and the 50-item traversal dominates. Count is part of A: the same 24 arguments
on a field selected on every list item would be paid once per item.
The resulting key is the fully serialized, key-sorted form, which is also what makes
feed(type: "top", limit: 10) and feed(limit: 10, type: "top") collide correctly:
search({"filter":{"a":2,"nested":{"a":2,"nested":{"a":2,"m":3,"z":1},"z":1},"z":1}})
Every object with a selection set pays policies.identify on write. The probe writes
2 000 books, each with an id, an isbn, a title and two embedded objects (author,
published), under five configurations. Every normalizing row stores the same 2 001
entries, so the rows differ only in how the id is computed (K is the number of
key-field reads per book):
keyFields configuration |
K |
store entries | write | vs. default | rewrite, identical payload | vs. default |
|---|---|---|---|---|---|---|
default (__typename + id) |
— | 2 001 | 59.44 ms | 1.00× | 61.12 ms | 1.00× |
["isbn"] |
1 | 2 001 | 64.47 ms | 1.08× | 66.24 ms | 1.08× |
["isbn", "title"] |
2 | 2 001 | 68.47 ms | 1.15× | 69.66 ms | 1.14× |
["isbn", "author", ["name"]] (nested path) |
3 | 2 001 | 73.24 ms | 1.23× | 73.18 ms | 1.20× |
false (books stay embedded) |
— | 1 | 48.71 ms | 0.82× | 53.14 ms | 0.87× |
The ranking follows directly from key-extractor.ts, and each normalizing row costs
O(K) per object on top of the same traversal:
- default
__typename+id—defaultDataIdFromObjectreads two properties, concatenates a string, and records{ id }as the key object. Nothing else in the cache is this cheap, which is the practical argument for just having anid. keyFields: ["isbn"]— the specifier is compiled once (cached by its JSON), but per object the compiled function runscollectSpecifierPaths(a freshDeepMerger), extracts every key path throughcontext.readField(the fullPolicies.readFieldmachinery, once per step of each path),normalizes the value (a no-op for a scalar), andJSON.stringifys the key object. Each extra key-field read adds to the cost, as the["isbn", "title"]row shows.- nested path (
["isbn", "author", ["name"]]) —["author", ["name"]]is one path of two steps:readField("author")on the book, thenreadField("name")on the embedded author. Only the extracted scalar goes into the key; the author object is not serialized. keyFields: false—identifystill runs, but its key function returnsundefinedimmediately. The books then stay embedded inROOT_QUERY.books, so the write skips per-book staging,store.mergeand references, which is why it is the cheapest row. The cost moves into the parent field: every rewrite runs oneequal()over the whole list (§2.3). For this shape that is still cheaper than re-staging 2 000 entities, but it grows with the list and invalidates the whole field when anything in it changes (§7.3).
applyMerges (architecture §4.6) walks the MergeTree after normalization. A user merge function
turns a field into a black box for the writer:
| Consequence | Why |
|---|---|
| the field's existing value is read back from the store | existing must be materialized before the call |
| the writer cannot skip the field | merge may produce anything |
equal() still runs on the result |
store.merge reconciles the merge function's output with the stored value |
| pagination helpers copy the whole list | [...existing, ...incoming] is O(N) per page |
A merge on a list field turns each incremental page write from O(page) into
O(accumulated list). Loading P pages of M items each copies M · P(P + 1) / 2 items,
O(P² · M) in total. That is usually acceptable (P is small), but it is the reason
infinite scroll degrades. The equal() that store.merge runs on the merged list is cheap
while the list grows: two arrays of different lengths are unequal after one length check.
It walks the whole list only when a write leaves the length unchanged, such as a refetch of
a page that is already loaded. The read side grows the same way: every page dirties the
list field, so each watcher of it re-reads the accumulated list, O(i · M) for page i
(§3.3).
flowchart LR
A["page 1<br/>merge: [] + M = M"]:::write
B["page 2<br/>merge: M + M = 2M"]:::write
C["page 3<br/>merge: 2M + M = 3M"]:::write
D["page P<br/>merge: (P-1)M + M = PM"]:::dirty
A --> B --> C --> D
T["total copied: M·P(P+1)/2 = O(P²·M)<br/>and each page's re-read walks the accumulated list"]:::dirty
D --> T
classDef write fill:#fde68a,stroke:#d97706,stroke-width:2px,color:#0f172a
classDef dirty fill:#fecaca,stroke:#dc2626,stroke-width:2px,color:#0f172a
writeQuery into a list of N normalized entities (F = 8 fields each):
N |
cold | scale | identical payload | scale | one field changed | scale |
|---|---|---|---|---|---|---|
| 100 | 1.95 ms | — | 1.42 ms | — | 1.34 ms | — |
| 1 000 | 15.99 ms | 0.82 | 13.86 ms | 0.97 | 13.83 ms | 1.04 |
| 5 000 | 83.41 ms | 1.04 | 75.95 ms | 1.10 | 72.18 ms | 1.04 |
| 20 000 | 349.28 ms | 1.05 | 322.07 ms | 1.06 | 317.53 ms | 1.10 |
Writes are linear in N and insensitive to what actually changed. The three columns
stay close at every size, because the traversal, the identify calls, the per-field
allocations and the store merge all happen regardless — dirtying fields is the only part
a no-op write avoids.
For a fresh payload there is no "nothing changed" fast path: the writer only learns that the payload is unchanged by normalizing it and comparing field by field. (The
isFreshshortcut only applies to objects the reader itself handed out, and it skips the merge, not the traversal.) This is the single most useful fact for reasoning about polling and subscription workloads.
The N = 100 cold cell is the first measurement in its process, so the probe
re-measures the same kind of write later in the same process:
| write of 100 entities | time |
|---|---|
cold N = 100, as measured first in the table above |
1.95 ms |
| the same write, re-measured into a brand-new cache | 1.53 ms |
| into a primed but empty cache | 1.57 ms |
| overwriting 100 existing entities | 1.44 ms |
Two effects can inflate that cell, neither of them per-entity work. The first is JIT:
the table's cold N = 100 is the first measurement the process makes, and re-measuring
the same write later costs 22 % less. The second would be one-time per-cache setup —
transforming the document, materializing type policies, allocating a fresh
StoreReader/StoreWriter — which is what separates a brand-new cache from a primed one.
In this run it is not visible: the primed write (1.57 ms) is no faster than the brand-new
one (1.53 ms), so at this size the setup cost is within the run-to-run noise.
Creating entities and overwriting them cost about the same: 1.57 ms against 1.44 ms for
100 entities, overwriting being 8 % cheaper in this run (and 8 % cheaper at N = 20 000
in the scaling table above, where the cold column also pays the per-cache setup):
Creating
Nentities costs roughly what overwritingNidentical ones costs. A creation dirties every field; an overwrite compares every incoming field with the stored one instead (and runsequal()on the object-valued ones). Per-entity write cost depends little on whether the entity already existed.
| ← Previous | Up | Next → |
|---|---|---|
| Part 1 — The cost model in one page | Apollo performance guide | Part 3 — The read path |