skip to content

Where in a GraphQL response do per-field timings go, and is their format specified?

level: juniorimportance: nice to knowfreq 19%

answer

  1. Not data, and not errors
  2. The response map is closed
  3. One entry reserved for the server
  4. Named by the spec, shaped by convention

basics

~20 s

Under the response's extensions entry — besides data and errors, the only top-level entry a GraphQL response may carry. The specification requires a map and defines nothing about its contents, so every timing format there is a convention.

solid answer

~50 s

The GraphQL specification defines a response as a map that may hold `data`, `errors` and `extensions`, and **must not** hold any other top-level entry. So a server cannot add a sibling `tracing` key; anything it wants to say about execution goes under `extensions`. The specification requires `extensions` to be a map and then deliberately stops — the contents are reserved for the implementation, with no required keys and no registry. The per-field payloads you have seen there (a request start time plus one entry per resolved field with its response path and a duration) are a format one server published and others copied. Two consequences follow. The payload ships in the response body to every caller who can run the operation, so a ticketing API returning resolver durations is publishing how slow its seat-availability lookup is. And a client that parses it is coupled to one server, not to GraphQL.

code

json · 17 lines
json
{
  "data": {
    "performance": {
      "id": "perf-88213",
      "seatMap": { "sectionCount": 14 }
    }
  },
  "extensions": {
    "fieldTimings": {
      "durationNs": 41870000,
      "fields": [
        { "path": "performance", "startNs": 120000, "durationNs": 41310000 },
        { "path": "performance.seatMap", "startNs": 41500000, "durationNs": 310000 }
      ]
    }
  }
}

go deeper

for a junior

Be ready to name the response entry that carries anything a server wants to add, and to say that the specification leaves its contents open. Knowing the response map holds only data, errors and extensions is the recall being tested.

for a middle

Explain why an open slot with an undefined shape means there is no portable tracing format, and what that costs a consumer: any parser you write is coupled to one server's structure and has to be adapted, not reused.

for a senior

Show the operational judgement. Timings in the response body are disclosed to every caller and add bytes to every response, so treat enabling them as an exposure decision gated by header or environment, and keep the real trace server-side.

for a principal

Own the policy across services. Decide whether timings ever leave the process, what an internal-only debug channel looks like, and how you keep client tooling reading your own translated model rather than a vendor payload you may replace.

## The response map is closed, with one open entry The GraphQL specification defines the response to a request as a map. It may contain `data`, it may contain `errors`, it may contain `extensions`, and it must not contain any other top-level entry. That closing clause is what makes this question have a single answer: however tidy it would look, a server may not return a `tracing` key sitting beside `data`. Everything a server wants to report about an execution that is neither the result nor an error has exactly one place to go. `extensions` is that place. The specification says it must be a map, and then stops on purpose. Its contents are reserved for the implementation to use as it sees fit, with no required keys, no naming scheme and no registry of well-known values. It is the one sanctioned extension point in the response map, and it exists precisely so that servers can add things like timings without anybody having to amend the specification. ## Which means there is no standard tracing format Follow that through and a widespread misconception falls apart. Engineers talk about "GraphQL tracing" as though it were a defined artefact with a schema. It is not. What exists is a payload shape that one server implementation published years ago and many others copied: a format version, a request start and end timestamp, and a flat list of resolved fields, each carrying its response path, its parent type and field name, and a start offset and duration measured in nanoseconds from the start of the request. That shape is widespread and well understood, and it is still a convention. No part of GraphQL validation touches it; the schema does not describe it; two servers may disagree about it and both be conformant. The same is true of everything else that turns up in that slot: a computed cost score, a cache-policy summary, a document hash, a list of deprecation warnings. All conventions living in a spec-reserved box. ## What it looks like on the wire A read against a ticketing graph might come back with the result and a timing block beside it. The nesting under `extensions` — the key name, the units, the field list — is the server's choice, not the specification's: ```json { "data": { "performance": { "id": "perf-88213", "seatMap": { "sectionCount": 14 } } }, "extensions": { "fieldTimings": { "durationNs": 41870000, "fields": [ { "path": "performance", "startNs": 120000, "durationNs": 41310000 }, { "path": "performance.seatMap", "startNs": 41500000, "durationNs": 310000 } ] } } } ``` A different server, tracing the same operation, may return an entirely different structure under an entirely different key, and a tool that reads one will not read the other. ## Why the placement matters operationally Three practical consequences follow from timings living in the response body rather than in a server-side pipeline. **It is disclosed to the caller.** Whoever can run the operation can read the timings. A public ticketing endpoint that returns per-field durations is telling anyone who asks which of its backends is slow, roughly how expensive a seat-availability check is, and — through which paths appear at all — the internal shape of resolution. That is useful reconnaissance for someone probing for an expensive query to hammer, and it is a performance leak on top. The usual posture is off by default, switched on by a request header that only internal callers can set, or enabled only outside production. **It costs bytes on every response it is enabled for.** One entry per resolved field means the timing block can dwarf `data`. A seat-map read that resolves 1,847 seats produces a timing entry per seat field; the block can be several times the size of the result. Compression hides some of that and none of the parse cost on the client. **A failed request can still carry it.** `extensions` is independent of the other two entries. A request that failed validation before execution began — so `data` is absent and `errors` is present — can still carry `extensions`, and a partially successful response carries all three. That is genuinely useful, because the timing of a request that ended badly is often the timing you most want, and it is the case people forget when they write a client that only reads `extensions` on success. ## The request has an extensions map too Worth knowing that `extensions` is not only a response entry: a GraphQL request may carry its own `extensions` map alongside `query`, `operationName` and `variables`, which is how the Automatic Persisted Queries hash handshake travels. Same principle in the other direction — a reserved, contents-undefined slot that conventions have colonised. ## The short answer to give Timings go under `extensions`, because the response map is otherwise closed to `data` and `errors`; the specification defines the slot and refuses to define its contents; every per-field timing payload you have seen there is a vendor convention rather than a specified format; and because it travels in the response body, switching it on in production is a disclosure decision as much as a performance one.

  • If the format is unspecified, how would you stop a tool from breaking when you change servers?
    Do not let the timing payload be the interface. Treat whatever the server emits under `extensions` as an internal detail, translate it once at the edge into your own span or metric model, and have every dashboard read that model. Then a server swap changes one adapter rather than every consumer. It also lets you drop the payload from client-facing responses entirely and keep the timings server-side.
  • Can a server return extensions on a response where data is absent?
    Yes. The three entries are independent. A request error — a parse or validation failure — produces a response with `errors` and no `data`, and it may still carry `extensions`. That is the case a client must handle, because the timings of a request that failed early are often the ones worth reading, and code that only inspects `extensions` on a successful response will silently miss them.

saying these in an interview costs you the question

  • Says the spec defines a standard tracing payload format
  • Puts timings in a new top-level key beside data
  • Thinks extensions may only appear alongside data
  • Assumes two servers emit the same timing structure
  • Leaves timings enabled on public responses by default

context