Skip to content

Incomplete cache diffs eagerly build missing-field diagnostics (JSON.stringify of store objects + MissingFieldError construction) in production #13305

Description

@wolfie

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:

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions