Serving GraphQL over HTTP

This section documents the current GraphQL specification (October 2021), plus the working draft for @defer/@stream/@oneOf, and the GraphQL-over-HTTP specification, as published at graphql.org and spec.graphql.org — which are the references these pages are written and verified against — and, for the integration pages, against Spring for GraphQL (2.0.x), Strawberry, Ariadne, and the client docs for Apollo Client / urql / Relay. No specific patch version is pinned. Some surfaces (Apollo Router/managed federation, graphql-ws internals, the Relay compiler internals, GraalVM native) are linked rather than documented in depth.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as GraphQL tooling iterates quickly.

GraphQL defines a query language and a type system, but says nothing by itself about how a request reaches a server over the network — that’s the job of the separate GraphQL-over-HTTP specification. This page covers the single-endpoint convention, the two HTTP methods a server typically accepts, the request parameters and response media types the wire format uses, status-code rules, and the surrounding concerns — batching, caching, CORS, CSRF, and tooling — that come with running GraphQL on top of HTTP.

The single endpoint

Unlike REST’s many URL-addressed resources, a GraphQL server conventionally exposes exactly one endpoint — almost always /graphql — that accepts every operation, whatever the query, mutation, or subscription asks for:

POST /graphql HTTP/1.1
Host: api.example.org
Content-Type: application/json
Accept: application/json

{"query":"query { book(id: \"42\") { title } }"}

The operation itself, not the URL, is what determines what data comes back. There is no equivalent of a REST URL path per resource to version or document separately — schema evolution (see Schema Design) happens by adding types and fields to the one schema behind the one endpoint. See graphql.org/learn/serving-over-http for the canonical introduction to this convention.

GET vs. POST

A server built for GraphQL almost always accepts POST as its primary method, and may additionally accept GET for simple, side-effect-free reads:

Method Typical use

POST

The default for every operation type, including mutations and subscriptions' initial handshake. Parameters travel in a JSON request body, with no practical length limit and no risk of a query being cached or logged as part of a URL.

GET

Optional, and conventionally restricted to queries only — never mutations, since a GET is expected to be safe and idempotent and a mutation is neither. Parameters travel in the URL’s query string, which makes a GET request natively cacheable by intermediate HTTP caches and CDNs (see the caching section below), at the cost of a URL-length ceiling that can force a long query document into a persisted-query hash instead of its full text.

A GET request encodes the same parameters as query-string keys, URL-encoded, with variables and extensions serialized as JSON strings within the URL:

GET /graphql?query=query%20GetBook(%24id%3A%20ID!)%20%7B%20book(id%3A%20%24id)%20%7B%20title%20%7D%20%7D&variables=%7B%22id%22%3A%2242%22%7D HTTP/1.1
Host: api.example.org
Accept: application/json

Equivalently, with curl doing the URL-encoding:

curl -G https://api.example.org/graphql \
  --data-urlencode 'query=query GetBook($id: ID!) { book(id: $id) { title } }' \
  --data-urlencode 'variables={"id":"42"}'

The GraphQL-over-HTTP specification requires a compliant server to support POST; GET support, and which operation types it’s offered for, is a server’s own choice. See the GraphQL-over-HTTP specification for the normative method requirements.

Request parameters

Whichever method carries it, a GraphQL-over-HTTP request recognizes four parameters, escaped here as \{ query, variables, operationName, extensions }:

Parameter Meaning

query

Required. The GraphQL document’s source text — one or more operation and fragment definitions.

variables

Optional. A JSON object mapping variable names (without the leading $) to their values, as covered in Variables, Directives & Fragments.

operationName

Required only when query defines more than one named operation — tells the server which one to execute. Optional (and typically omitted) for a document with a single operation.

extensions

Optional. A JSON object for client-supplied, server-defined metadata outside the spec’s own fields — most commonly a persisted-query hash (see Automatic Persisted Queries below), never business data that belongs in variables.

A request naming more than one operation without operationName — or naming one that doesn’t exist in the document — is a request error the server rejects before execution begins:

query GetBook($id: ID!) {
  book(id: $id) { title }
}

query GetAuthor($id: ID!) {
  author(id: $id) { name }
}
{
  "query": "query GetBook($id: ID!) { book(id: $id) { title } } query GetAuthor($id: ID!) { author(id: $id) { name } }",
  "operationName": "GetAuthor",
  "variables": { "id": "7" }
}

See graphql.org/learn/serving-over-http for the parameter list, and the GraphQL-over-HTTP specification for the exact encoding rules for each transport (JSON body vs. URL query string).

Response media types

A server’s Content-Type response header, together with what the client sent as Accept, determines which of two conforming media types the body is serialized as:

Media type Behavior

application/json

The long-standing, widely deployed default. A server serving this type conventionally answers every well-formed GraphQL request with HTTP 200, whatever \{ data, errors } it produced —  distinguishing "the request executed, with errors" from "the request never reached execution" is left entirely to inspecting the body.

application/graphql-response+json

Defined by the GraphQL-over-HTTP specification specifically so that transport-level failures can be told apart from execution-level ones using ordinary HTTP status codes, without a client needing to parse the body first. A client opts in by sending Accept: application/graphql-response+json; a server that supports the newer type should honor it when offered.

POST /graphql HTTP/1.1
Content-Type: application/json
Accept: application/graphql-response+json

{"query":"{ book(id: \"42\") { title } }"}
HTTP/1.1 200 OK
Content-Type: application/graphql-response+json

{"data":{"book":{"title":"The Left Hand of Darkness"}}}

Response and Error Handling owns the JSON body’s own \{ data, errors, extensions } shape once it’s on the wire; this page’s scope stops at how that body is framed and status-coded at the HTTP layer. See the GraphQL-over-HTTP specification’s media type section for the full negotiation rules.

Status-code rules

The two media types carry different status-code expectations:

  • Under application/json, the prevailing convention is HTTP 200 for every request that reached the GraphQL layer at all, including one whose data is null and whose errors array is full — a 4xx/5xx is reserved for failures below GraphQL entirely (malformed JSON, an unroutable path, an unsupported method).

  • Under application/graphql-response+json, the specification defines a stricter mapping: a request that is well-formed JSON but fails GraphQL-level request validation (unparsable query, a missing required parameter) can legitimately return 400 Bad Request; one that violates the server’s own operational limits can return other 4xx codes; and a request that executes, however many errors it produces, still returns 200, matching the application/json behavior for that one case.

curl -s -o /dev/null -w '%{http_code}\n' https://api.example.org/graphql \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/graphql-response+json' \
  -d '{"query": "{ not a valid query"}'
# 400

See the GraphQL-over-HTTP specification’s status code section for the complete, normative table of when each code applies.

Request batching

Some servers (and most client libraries, transparently) support sending several operations in one HTTP round trip as a JSON array of request objects instead of a single object — reducing per-request overhead when a client needs to fire off multiple independent queries at once:

[
  { "query": "query { book(id: \"1\") { title } }" },
  { "query": "query { book(id: \"2\") { title } }" }
]

A batched response mirrors the request shape — a JSON array of individual \{ data, errors } results in the same order. Batching is not part of the core GraphQL-over-HTTP specification; it’s a widely adopted, de facto convention that specific server and client implementations opt into, so whether — and how — a given server supports it is implementation-specific rather than guaranteed. Performance and N+1 covers batching data-loading inside a single execution (via a request-scoped DataLoader), which is a different mechanism from batching multiple top-level HTTP requests together.

GET cacheability

Because HTTP caches key on method and URL, a GET request with its parameters in the query string is transparently cacheable by a browser, CDN, or reverse proxy the same way a REST GET is — something a POST request, whose body a cache does not inspect, cannot get for free:

GET /graphql?query=%7B%20book(id%3A%20%2242%22)%20%7B%20title%20%7D%20%7D HTTP/1.1
Accept: application/json
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
Content-Type: application/json

{"data":{"book":{"title":"The Left Hand of Darkness"}}}

This is one of the main reasons a server offers GET at all despite POST being the default: pairing a GET query with an ordinary Cache-Control header lets a CDN serve repeat requests for the same query and variables without reaching the origin server. Caching covers HTTP-level caching (and its alternatives — normalized client-side caches, persisted-query-keyed caching) in depth.

CORS

A GraphQL endpoint called from a browser-based client on a different origin than the server needs the same Cross-Origin Resource Sharing configuration any other cross-origin HTTP API needs — GraphQL introduces nothing special here beyond what its own request shape implies for preflight:

OPTIONS /graphql HTTP/1.1
Origin: https://app.example.org
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.org
Access-Control-Allow-Methods: POST, GET
Access-Control-Allow-Headers: content-type

Because a JSON POST with Content-Type: application/json is not a CORS "simple request," a browser sends the preflight OPTIONS shown above before the actual request — the same rule that applies to any JSON REST API, not something GraphQL-specific.

CSRF prevention

A GET-based GraphQL query, and a POST sent with a form-encoded (rather than JSON) content type, can both be triggered cross-site by a browser without a preflight check, the same way a classic HTML form submission can — which makes CSRF a live concern for any mutation-capable GraphQL endpoint that accepts either shape. The standard mitigations from ordinary web security apply directly: requiring a custom header (most simply, requiring Content-Type: application/json, which forces the preflight above and blocks a plain form submission), checking Origin/Referer on state-changing requests, or requiring a CSRF token for any POST that isn’t application/json. Several server frameworks — see Spring Boot: Transports, Security & Testing for one concrete example — reject non-JSON POST bodies to a GraphQL endpoint by default specifically to close off this surface, and disabling that protection to accept form-encoded requests should be a deliberate, narrow decision rather than a default.

GraphiQL and endpoint tooling

Getting Started introduces GraphiQL, Apollo Sandbox, and GraphQL Playground as schema-exploration tools; at the HTTP layer, what makes any of them work against a given server is simply that server answering GET requests with Accept: text/html (or a dedicated path) by serving the IDE’s own HTML/JS bundle instead of a GraphQL JSON response, alongside the ordinary POST handler the IDE calls underneath. A production deployment commonly disables this HTML surface (or gates it behind authentication) while leaving the JSON endpoint itself open, since serving an interactive query editor to the public internet widens the attack surface described in Security and Demand Control.

Automatic Persisted Queries and trusted documents

A full query document can be large, and sending its complete text on every request wastes bandwidth for a client that issues the same operation repeatedly. Automatic Persisted Queries (APQ) and the related trusted documents pattern both address this by having the client send a short hash instead of the query text on repeat calls:

{
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  }
}

The first time the server sees a given hash it doesn’t recognize, it replies with a PersistedQueryNotFound error; the client then retries once, this time sending both query and the same extensions.persistedQuery hash so the server can register the mapping for every subsequent call:

{
  "query": "query GetBook($id: ID!) { book(id: $id) { title } }",
  "variables": { "id": "42" },
  "extensions": {
    "persistedQuery": {
      "version": 1,
      "sha256Hash": "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38"
    }
  }
}

Trusted documents takes this further as a security posture rather than just a bandwidth optimization: the server pre-registers an allowlist of hashes at deploy time (generated from the client’s own build) and refuses any query whose hash isn’t already known, rather than accepting arbitrary query text from any caller at all — closing off the query-shape-based attacks covered in Security and Demand Control. Both patterns also shrink a GET request’s URL enough to make caching (above) practical for queries whose full text would otherwise exceed a comfortable URL length. See graphql.org/learn/serving-over-http for the APQ/trusted-documents overview.