What Actually Goes on the Wire
What Actually Goes on the Wire
The GraphQL specification deliberately says nothing about transport. It defines documents, types, and execution, then stops. How those bytes travel is a separate specification, GraphQL over HTTP, and the shape it settled on is small enough to memorize.
curl https://api.example.com/graphql \
-H 'Content-Type: application/json' \
-H 'Accept: application/graphql-response+json' \
-d '{
"operationName": "ProductPage",
"query": "query ProductPage($id: ID!) { product(id: $id) { id title } }",
"variables": { "id": "UHJvZHVjdDox" }
}'
Three properties of the request matter:
- One URL. Every operation goes to the same endpoint, so the path never identifies the operation. The body does.
- Three fields.
queryis the document text,operationNamepicks which operation in it,variablescarries the values. The server executes nothing until all three agree. - Content types. Requests are JSON, so
Content-Type: application/jsonworks with every server. The GraphQL-specific media type family isapplication/graphql+json, and its response-facing memberapplication/graphql-response+jsonis how a client signals that it wants specification-correct status codes instead of a legacy 200-for-everything server. A server that only speaksapplication/jsonis called a legacy server by that specification.
GET is allowed, for queries
A read-only operation can travel as a URL:
GET /graphql?query=query%7Bme%7Bname%7D%7D HTTP/1.1
That makes the operation cacheable by any intermediary, shareable as a link, and cheap to prefetch. Two hard rules follow. The query string must be URL-encoded and bounded, so long documents do not fit. And a mutation sent as GET must be rejected with 405 Method Not Allowed, because GET requests are triggered by crawlers, browser prefetch, and link previews; a side effect reachable that way fires when nobody asked for it.
Status Codes Track the Phase That Failed
Status Codes Track the Phase That Failed
The most common misconception about GraphQL over HTTP is "it always returns 200." That describes a legacy server, not the specification. The rule that makes the table below sensible: the status code reflects which phase of the request pipeline failed, while the body carries field-level detail.
| Failure | Example | Status | Legacy server |
|---|---|---|---|
| Body is not valid JSON | truncated JSON | 400 | 400 |
query missing or not a string |
{} |
400 | 400 |
| Document has a syntax error | query { user { |
400 | 400 |
| Document fails validation | user { nmae } |
422 | 200 |
| Variable coercion fails | $id: ID! with no value |
422 | 200 |
Unsupported Content-Type |
text/plain |
415 | 415 |
Unsupported Accept type |
no GraphQL media type | 406 | varies |
| Mutation sent as GET | side effect over GET | 405 | 405 |
| Resolver throws during execution | database timeout | 200 + errors |
200 + errors |
| Server fails before producing a result | unhandled crash | 5xx | 5xx |
One invariant holds across all of them: if the result contains data and data is not null, the status is 2xx. Partial success is still success at the HTTP layer, with the failure recorded per field in errors.
That makes client code a two-step decision:
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
if (res.status >= 500) {
throw new Error('server unavailable'); // transport level: retry later
}
const body = await res.json();
if (body.errors && !body.data) {
throw body.errors[0]; // request error: fix the document
}
if (body.errors) {
report(body.errors); // field errors: render partial data
}
return body.data;
Branching on status alone breaks on legacy servers that answer 200 with a validation error inside the body; branching on the body alone hides a real 500. Check both. The exact payload shape, including the path and extensions entries, is covered in standard error shape.
Cutting Bytes: GET, Persisted Queries, Compression
Cutting Bytes: GET, Persisted Queries, Compression
Shipping the full document on every request is pure overhead once a client has sent the same operation hundreds of times in one session. Three techniques shrink it, in increasing order of commitment.
1. GET with a query string. Works for small read-only operations and turns each one into a cacheable URL. The limit is real: reverse proxies and CDNs commonly cap request lines around 2 KB, and anything longer is rejected before your server sees it. Reserve it for a handful of tiny, hot queries.
2. Automatic persisted queries (APQ). The client sends a hash and lets the server ask for the document when it is unknown:
{
"extensions": {
"persistedQuery": {
"sha256Hash": "ecf4edb46db40b513051591e944758ea5c9d42f8829688fcbf4a6808464476ea"
}
},
"variables": { "id": "UHJvZHVjdDox" }
}
The first request for an unknown hash gets a PersistedQueryNotFound error and the client retries with the full document; after that, the hash alone suffices and bodies drop to a few dozen bytes. Details in persisted queries.
3. Registered operation manifests. The build step publishes every operation and its hash to the CDN ahead of time. The client sends only the hash, the edge rejects unknown hashes, and the CDN caches by identity. This is where GraphQL finally matches REST's cache-by-URL behavior, at the price of a deployment pipeline for operations.
Two supporting points that come up in design reviews:
- Compression still applies. One JSON document per response compresses well with gzip or Brotli, but some CDN configurations skip compression for
application/graphql-response+jsonbecause the media type is unfamiliar. Check that yours does not. - Non-JSON flows use other transports. File uploads go through multipart form extensions rather than a plain JSON body, and subscriptions run over WebSocket or server-sent events; see WebSocket protocol and SSE transport.
Interview angle: "the client gets 200 and still shows nothing useful - walk me through the debug." Read the errors array, identify the failing phase from the message, then decide whether it is a document bug (validation), a data bug (field error), or a deployment bug (status handling that the client misreads).
Practice Problems
Support forwards five responses from a production GraphQL endpoint and the dashboard shows only status codes. Classify each: (1) the body is not valid JSON, (2) the document text "query { user {" is unclosed, (3) the document selects user { nmae } on a schema with no nmae, (4) variables are {} for query Get($id: ID!), (5) the database throws halfway through execution while two sibling fields resolve. Give the status a specification-following server returns and what the client should do.
Example:
Input: Case 3: document selects user { nmae }, schema has no such field, server speaks application/graphql-response+json
Output: HTTP 422, no data entry, errors array naming the invalid field; client fixes the document because no resolver ran
Validation runs after parse and before execution, so the failure is request-level: the server reports the request as unprocessable instead of returning a partial result.
Optimal Solution — O(1) per response time, O(1) space
Classify by phase rather than by message text: body parse, document parse, validation, variable coercion, execution, or server failure. Status follows the phase, and the client branches on 5xx first, then on whether data is present.
function interpret(status, body) {
if (status >= 500) return { action: 'retry', kind: 'server-failure' };
if ([400, 405, 406, 415, 422].includes(status)) {
return { action: 'fix-request', kind: 'request-error', detail: body && body.errors };
}
if (body && body.errors && !body.data) {
// legacy server put a request error into a 200 body
return { action: 'fix-request', kind: 'request-error', detail: body.errors };
}
if (body && body.errors) {
return { action: 'render-partial', kind: 'field-errors', detail: body.errors };
}
return { action: 'render', kind: 'success' };
} Edge Cases:
- Legacy servers return 200 for cases 3 and 4, so the body must be inspected even when the status looks healthy
- data: null with errors means nothing rendered; data plus errors means partial rendering
Quiz
1. A resolver throws while three sibling fields resolve fine. What does a specification-following server return?
2. The request sends operationName: "GetCart" but the document contains only GetProfile. What happens?
3. Why must a mutation never be accepted over GET?
4. Which header pair sends a JSON operation and asks for specification-correct status codes?
Flashcards
Question
POST /graphql body fields
Click to reveal answer
Answer
query (document text), operationName (which operation runs), variables (values). extensions carries optional features such as persisted query hashes.
Question
Status code rule of thumb
Click to reveal answer
Answer
400 for bad JSON or document syntax, 422 for validation or variable coercion, 405 mutation over GET, 415 bad Content-Type, 406 bad Accept, 200 for execution results, 5xx when no result was produced.
Question
Why 200 can still mean failure
Click to reveal answer
Answer
Field errors occur during execution, so the response is a valid result: partial data plus an errors array. Read the body, not just the status.
Question
Legacy server behavior
Click to reveal answer
Answer
A server speaking only application/json returns 200 for validation and coercion failures too, putting the failure in the body. Clients must branch on status and body.
Question
Persisted query handshake
Click to reveal answer
Answer
Client sends a sha256 hash in extensions.persistedQuery; server answers PersistedQueryNotFound on a miss; client retries with the full document.
Revision Notes
Key Takeaways
- 1. GraphQL defines no transport; GraphQL over HTTP defines the JSON request, the media types, and the status codes
- 2. Status codes track the failing phase: parse 400, validation or coercion 422, execution 200 with errors, misuse 405/415/406
- 3. Any result with non-null data is a 2xx, so partial success is ordinary success at the HTTP layer
- 4. GET, persisted queries, and registered operation manifests exist to make operations addressable by identity again
Interview Tips
- • Answer "GraphQL always returns 200" with nuance: that is legacy application/json behavior, while the specification ties the status to the failing phase
- • Draw the client decision tree: 5xx retry, errors without data means fix the document, errors with data means render partial, otherwise render data
Cheat Sheet
GraphQL over HTTP cheat sheet
Request
POST /graphql
Content-Type: application/json
Accept: application/graphql-response+json
{ "query": "...", "operationName": "X", "variables": {} }
Status codes
| Stage | Status |
|---|---|
| bad JSON / missing query / syntax | 400 |
| validation or variable coercion | 422 (legacy servers: 200) |
| mutation over GET | 405 |
| wrong Content-Type / Accept | 415 / 406 |
| execution, field errors allowed | 200 |
| failure before any result | 5xx |
Rules: data non-null implies 2xx; read the errors array even on 200; GET only for queries; shrink bodies with APQ hashes or registered manifests.