Issue Description
Whenever diffQueryAgainstStore executes over incomplete data, it eagerly builds full diagnostics — even in production builds, and even when the caller only ever reads diff.complete:
- Per missing field,
execSelectionSetImpl builds a message string that embeds a pretty-printed dump of the parent store object (readFromStore.ts, compiled readFromStore.js:160 in 4.2.3):
[resultName]: `Can't find field '${selection.name.value}' on ${isReference(objectOrReference) ?
objectOrReference.__ref + " object"
: "object " + JSON.stringify(objectOrReference, null, 2)}`,
For non-normalized (embedded) parents this stringifies the whole object per missing field per diff; the cost scales with object size (measured: ~3µs for a 2KB parent, ~12µs for a 32KB parent, per call).
- Per incomplete diff, a
MissingFieldError is constructed (readFromStore.js:89). It extends Error, so every construction pays V8 stack capture plus the nested missing tree building — again regardless of whether anyone reads it.
This hits hardest on pages with many watched queries loading concurrently. Each arriving response dirties sibling watches (they share entity fields like id), each dirtied watch re-diffs, and every one of those re-diffs while data is still incomplete rebuilds the full diagnostic set — only for the notification pipeline to check diff.complete and discard the rest.
Real-world numbers: profiling a page with ~50 concurrently-loading watched queries and ~60 responses (Chrome DevTools, production build, 4× CPU throttle), ~95–140ms of main-thread time during a single page load sat in MissingFieldError construction plus missing-message building — with the MissingFieldError constructor alone showing 91ms of self time. None of these errors were ever surfaced; the queries simply hadn't finished loading yet.
Suggested fix
- Build the message lazily (or drop the store dump): the pretty-printed
JSON.stringify(objectOrReference, null, 2) could become a cheap description (e.g. __typename), or the whole string could be built only when the message is actually read.
- Construct
MissingFieldError lazily — only when diff.missing is consumed by public readers (readQuery / read throwing, dev warnings), not for internal completeness checks. Note this likely can't be a naive getter, since diff objects flow through equal() comparisons that enumerate properties — it probably needs internal consumers to use a lighter completeness signal instead.
Notably, after locally patching the message construction to skip the JSON.stringify (keeping everything else identical), the MissingFieldError constructor still shows ~80ms of self time in the same profile — the Error construction itself, not the message string, is the dominant cost.
This is a sibling of #13304 — same theme: diagnostic/AST work on the hot cache path that production builds pay for but never use.
Link to Reproduction
https://codesandbox.io/p/devbox/flamboyant-kare-lyph3l?workspaceId=ws_3ZUcivLQQWJ6J7QPEy3ipB
Reproduction Steps
Runnable benchmark (same shape as a loading list page — data written by a narrower query than the one being watched):
const { InMemoryCache, gql } = require("@apollo/client");
const watched = gql`
query C($id: ID!) {
customer(id: $id) {
id rev
profile { name bio address { street city zip } fax vatId }
orders { id total lines { sku qty price } }
}
}
`;
// what was actually written: no fax/vatId (missing on an embedded parent)
const written = gql`
query CW($id: ID!) {
customer(id: $id) {
id rev
profile { name bio address { street city zip } }
orders { id total lines { sku qty price } }
}
}
`;
const revQuery = gql`query R($id: ID!) { customer(id: $id) { id rev } }`;
const data = (c, rev) => ({
customer: {
__typename: "Customer", id: `c-${c}`, rev,
profile: { __typename: "Profile", name: "n", bio: "x".repeat(8192),
address: { __typename: "Address", street: "s", city: "c", zip: "z" } },
orders: Array.from({ length: 30 }, (_, i) => ({
__typename: "Order", id: `o-${c}-${i}`, total: i,
lines: [{ __typename: "Line", sku: "s", qty: 1, price: 1 }] })),
},
});
const cache = new InMemoryCache();
for (let c = 0; c < 50; c++)
cache.writeQuery({ query: written, variables: { id: `c-${c}` }, data: data(c, 0) });
console.time("300 incomplete diffs");
for (let r = 1; r <= 6; r++) {
for (let c = 0; c < 50; c++) {
// model an arriving response dirtying the entity
cache.writeQuery({ query: revQuery, variables: { id: `c-${c}` },
data: { customer: { __typename: "Customer", id: `c-${c}`, rev: r } } });
cache.diff({ query: watched, variables: { id: `c-${c}` },
returnPartialData: true, optimistic: false });
}
}
console.timeEnd("300 incomplete diffs");
Profiling this shows the time concentrated under MissingFieldError and the missing-message construction (JSON.stringify frames). Replacing the message with a constant string and skipping the MissingFieldError construction (keeping only the completeness flag) removes that entire block.
@apollo/client version
4.2.3
Issue Description
Whenever
diffQueryAgainstStoreexecutes over incomplete data, it eagerly builds full diagnostics — even in production builds, and even when the caller only ever readsdiff.complete:execSelectionSetImplbuilds a message string that embeds a pretty-printed dump of the parent store object (readFromStore.ts, compiledreadFromStore.js:160in 4.2.3):For non-normalized (embedded) parents this stringifies the whole object per missing field per diff; the cost scales with object size (measured: ~3µs for a 2KB parent, ~12µs for a 32KB parent, per call).
MissingFieldErroris constructed (readFromStore.js:89). It extendsError, so every construction pays V8 stack capture plus the nestedmissingtree building — again regardless of whether anyone reads it.This hits hardest on pages with many watched queries loading concurrently. Each arriving response dirties sibling watches (they share entity fields like
id), each dirtied watch re-diffs, and every one of those re-diffs while data is still incomplete rebuilds the full diagnostic set — only for the notification pipeline to checkdiff.completeand discard the rest.Real-world numbers: profiling a page with ~50 concurrently-loading watched queries and ~60 responses (Chrome DevTools, production build, 4× CPU throttle), ~95–140ms of main-thread time during a single page load sat in
MissingFieldErrorconstruction plus missing-message building — with theMissingFieldErrorconstructor alone showing 91ms of self time. None of these errors were ever surfaced; the queries simply hadn't finished loading yet.Suggested fix
JSON.stringify(objectOrReference, null, 2)could become a cheap description (e.g.__typename), or the whole string could be built only when the message is actually read.MissingFieldErrorlazily — only whendiff.missingis consumed by public readers (readQuery/readthrowing, dev warnings), not for internal completeness checks. Note this likely can't be a naive getter, since diff objects flow throughequal()comparisons that enumerate properties — it probably needs internal consumers to use a lighter completeness signal instead.Notably, after locally patching the message construction to skip the JSON.stringify (keeping everything else identical), the MissingFieldError constructor still shows ~80ms of self time in the same profile — the Error construction itself, not the message string, is the dominant cost.
This is a sibling of #13304 — same theme: diagnostic/AST work on the hot cache path that production builds pay for but never use.
Link to Reproduction
https://codesandbox.io/p/devbox/flamboyant-kare-lyph3l?workspaceId=ws_3ZUcivLQQWJ6J7QPEy3ipB
Reproduction Steps
Runnable benchmark (same shape as a loading list page — data written by a narrower query than the one being watched):
Profiling this shows the time concentrated under
MissingFieldErrorand the missing-message construction (JSON.stringifyframes). Replacing the message with a constant string and skipping theMissingFieldErrorconstruction (keeping only the completeness flag) removes that entire block.@apollo/clientversion4.2.3