GraphQL · advanced

GraphQL field is intermittently null: separate resolver errors from missing data

This playbook distinguishes resolver errors from genuinely missing data when a GraphQL field returns null intermittently. It sequences decisions from null propagation rules, to error masking, to dependency-side failure surfaces, and ends with regression checks tied to the GraphQL October 2021 Errors specification.

The symptoms

  • A specific GraphQL field returns null on some requests but a populated value on others with the same query shape and arguments.
  • The HTTP response has HTTP 200 and a JSON body, so the failure is not surfaced as a transport error, yet the data shape is degraded.
  • The top-level `errors` array is sometimes present and sometimes absent for identical operations, making the null look "random" or "data-dependent."
  • Field-level error extensions (for example, `code`, `path`, or `extensions.exception`) appear only in certain argument combinations or downstream states.
  • A retry of the same query shortly afterward returns a non-null value, while a peer query against a sibling entity stays null persistently.
  • Nulls cluster around a boundary: a particular tenant, region, cache layer, downstream service version, or feature flag state.

Likely causes

  • A non-null field resolver threw or returned a rejected promise, and the GraphQL execution layer coerced it to null per the spec's null propagation rules, with the error recorded separately in the top-level `errors` array.
  • A nullable field legitimately has no record (for example, an empty relation or an unset optional attribute), and the intermittent shape reflects actual data variance rather than a failure.
  • A dependency or backing service returns an error only under specific input conditions (auth token, region, rate limit, schema mismatch, partial outage), causing the resolver to fall through to null on those requests.
  • A cache layer returns a stale or poisoned null entry, or a cache key collision causes one entity's null response to be served for another entity's query.
  • Authorization logic returns null instead of throwing an authorization error, masking permission failures as missing data and producing intermittent nulls tied to caller identity or scope.
  • Schema or type mismatch in generated/typed resolvers causes a field to resolve to `undefined` for a subset of parent shapes (for example, a union or interface member lacking the field implementation).

First ten minutes

  1. 01Capture the exact operation: query/mutation name, variables, and selection set. Note the operation id if the server emits one, since intermittent bugs are reproducible only with the precise input.
  2. 02Inspect the response body's `errors` array, not just the field value. Per the spec, errors at non-null positions cause null propagation; a null field with no matching `errors` entry is more likely missing data than a failure.
  3. 03Record request metadata that correlates with the null: timestamp, tenant or auth principal, region/zone, cache hit/miss, dependency call latency, and any feature flag values active for the request.
  4. 04Re-run the same operation twice within a short window and compare responses. If the second call returns a value while the first returned null, the issue is stateful (cache, dependency, auth) rather than purely data-shaped.
  5. 05Check the boundary where the resolver reads or composes data: the resolver log line, the outbound dependency call log, and any cache read/write line, looking for the field path that produced null.
  6. 06Triage the null type: is the field declared `null` or non-null in the schema? Non-null fields that arrive null at the parent are the strongest signal of a resolver error rather than missing data.

Evidence to collect

  • The complete GraphQL response for a failing and succeeding request, including data, the errors array, error paths, extensions, variables, and a correlation identifier.
  • Resolver and downstream dependency logs for the same field execution, aligned by correlation identifier and timestamp to distinguish missing data from an execution error.
  • The schema nullability and authorization rules for the field, plus the cache key or data-loader inputs used by the failing and succeeding requests.

Where to look

  • GraphQL execution boundary: field resolver, null propagation, error path, and extensions handling for the exact operation.
  • Dependency boundary: database, cache, or downstream service response used by the resolver, including whether a timeout is converted into null.
  • Authorization and caching boundary: principal-specific policy, cache key, and data-loader scope that could make equivalent queries observe different values.

Diagnostic steps

  1. 01Classify each observed null as "spec-mandated null" (the field is nullable and no value exists), "propagated null from a non-null child" (an inner non-null field errored), or "resolver-returned null" (the resolver chose to return null on success or on a caught error). This classification is the first decision point.
  2. 02For propagated nulls, walk the `path` array in the matching `errors` entry to the leaf that errored, then inspect that leaf resolver to see whether it threw or returned a rejected promise. The leaf, not the parent, is where the fix belongs.
  3. 03For resolver-returned nulls, branch on whether the resolver returned null on success (legitimately missing data) or after catching an exception (a swallowed error). Distinguish by checking for a log line, metric, or stack trace at the catch site.
  4. 04For dependency-driven nulls, compare the dependency call's outcome for null-returning requests versus value-returning requests with identical arguments. Differences in status, latency, or response body point to a dependency-side cause.
  5. 05For cache-driven nulls, force a cache miss for the failing key (by varying a cache-busting argument or invalidating the relevant tag) and re-run. If the field is now populated, the cache layer is implicated; if still null, the cache is innocent.
  6. 06For authorization-driven nulls, run the same operation as two distinct principals that should differ in permission. Identical nulls across both principals indicate data absence; differing nulls indicate authorization masking.
  7. 07For schema/typing-driven nulls, generate the operation against the published schema and against the implementation's view of the schema. Mismatches in the field's presence on union or interface members explain intermittent nulls across heterogeneous parents.

Common mistakes

  • Treating any null response as "missing data" without checking the `errors` array, which causes teams to add data backfill work for problems that are actually resolver failures per the GraphQL Errors specification.
  • Catching all exceptions inside a resolver and returning null "to keep the response shape stable," which silently converts errors into data and makes intermittent nulls undebuggable from the client side.
  • Reading a single failing request in isolation and inferring a data problem, when the same operation against a sibling entity returns a value and indicates a stateful or input-dependent cause instead.
  • Assuming a cache layer is innocent because it returned "no error," when negative caching or a poisoned key can produce a perfectly well-formed null response that the cache treats as healthy.
  • Conflating a nullable field type with "this field can fail silently," and so failing to add observability or to align the field's nullability with its actual reliability contract.
  • Fixing only the symptom at the parent by coercing a non-null field to nullable, which papers over the leaf error and lets the underlying failure persist off the user's screen.

Safe fixes

  • Remove silent catch-and-null patterns from resolvers for non-null fields; let the exception propagate so the spec's null propagation makes the failure visible in `data` and the cause visible in `errors`. Apply only after confirming the field's type is non-null in the schema.
  • For nullable fields where absence is meaningful, return an explicit sentinel (such as an empty list or a typed "absent" value) instead of conflating "missing" with "error," and document the contract in the schema description.
  • Add a resolver-level metric or log line keyed by `path` and `errorType` so intermittent nulls become countable rather than anecdotal, without changing the public response shape.
  • Scope cache keys by tenant, principal scope, and dependency version, and avoid storing negative entries for non-deterministic or auth-sensitive fields, only after verifying that caching is the implicated layer via a cache-bust comparison.
  • For authorization masking, return a typed authorization error with a stable `extensions.code` rather than null, so callers can distinguish "not allowed" from "not present" without inspecting nulls.
  • When dependency calls fail, surface the dependency's error class in `errors[].extensions` while keeping the field nullable, so the field's null has a paired machine-readable cause for downstream consumers.

Prove the fix

  1. 01Run the originally failing operation with the original variables and confirm the field returns a value (or a documented null) on N consecutive requests across the window where intermittent nulls were previously observed, with the `errors` array either empty or containing only expected entries.
  2. 02Re-run the operation against a sibling entity that previously also returned null intermittently, and confirm both produce stable, contractually correct responses under identical inputs.
  3. 03Inspect the `errors` array for the path that previously errored; the count of errors on that path during a fixed observation window must drop to the expected baseline (zero, or aligned with the field's documented failure rate).
  4. 04Force a cache miss for the previously failing key and re-run with cache re-enabled; the response shape and `errors` array must match the cache-miss case within the contract, proving no stale null is being served.
  5. 05Run the operation as two distinct principals with differing permissions and confirm the response distinguishes "not present" from "not authorized" via either the field's value or a stable `extensions.code`, never via ambiguous nulls.
  6. 06Capture the schema diff for the field in question: if the fix changed nullability, the diff must match the documented contract, and any client query that assumed the previous nullability must be re-validated against the new schema.

Prevention and next steps

  • Define and document each field's nullability, error policy, and observability contract in the schema description, so resolvers, clients, and on-call engineers share one source of truth for what null means.
  • Adopt a resolver convention that distinguishes "data absent" returns from "error caught" returns, with separate log levels and metric labels, so intermittent nulls remain diagnosable from logs alone.
  • Treat the GraphQL `errors` array as a first-class product surface: every error must carry a stable `extensions.code`, and clients must be contractually allowed to rely on it under the spec's Errors section.
  • Add continuous checks that compare schema-declared nullability against resolver behavior in staging, alerting when a resolver returns null on a path whose type is non-null, before the issue reaches production traffic.

Safe commands and checks

grep -RIn "ResolverError\|GraphQLError\|extensions.code" <path-to-server-logs> | head -n 50
grep -RIn "return null\|resolve:.*null" <path-to-resolver-source> | head -n 50
grep -RIn "type:.*\\[.*\\]\\|@nullable\\|NonNull" <path-to-schema-sdl> | head -n 50
grep -RIn "catch.*null\\|catch (.*) {.*null" <path-to-resolver-source> | head -n 50