Skip to main content

HTTP: The Protocol That Carries Your GraphQL (Whether It Likes It or Not)

· 16 min read
GraphQL Guy

HTTP Protocol

Every GraphQL query you send travels over HTTP. Every response comes back the same way. But here's the thing: HTTP was designed for fetching documents, not executing queries. GraphQL essentially hijacked a delivery truck and turned it into a taxi service. Let's explore the protocol that makes it all possible.

The Origin Story

HTTP - Hypertext Transfer Protocol - was invented by Tim Berners-Lee in 1989 at CERN. The original goal? Link scientific documents together so physicists could share research. Fast forward 35 years, and it's carrying cat videos, financial transactions, and yes, your carefully crafted GraphQL mutations.

YearVersionKey Features
1989-Tim Berners-Lee proposes the World Wide Web at CERN
1991HTTP/0.9One-line protocol, GET only, no headers
1996HTTP/1.0RFC 1945: added headers, POST, status codes
1997HTTP/1.1RFC 2068: persistent connections, chunked transfer
1999HTTP/1.1RFC 2616: the definitive version for 15 years
2014HTTP/1.1RFC 7230-7235: clarified and split into parts
2015HTTP/2RFC 7540: binary framing, multiplexing
2022HTTP (revised)RFC 9110-9114: HTTP Semantics, Caching, HTTP/1.1, HTTP/2 (RFC 9113 supersedes 7540), and HTTP/3 (RFC 9114, QUIC-based, UDP) all republished together

HTTP/0.9: The Innocent Beginning

The original HTTP was adorably simple. The entire protocol was one line:

GET /page.html

That's it. No headers. No status codes. No content types. The server would respond with raw HTML and close the connection. Done.

HTTP/0.9 Conversation
Client → GET /hello.html

Server → <html>
<body>Hello World</body>
</html>
[connection closed]

No status codes. No headers. Pure document transfer.

Could you run GraphQL over HTTP/0.9? Technically yes. Would it be a nightmare? Absolutely.

HTTP/1.0: Growing Up

HTTP/1.0 introduced the concepts we still use today:

GET /movie/123 HTTP/1.0
Host: api.example.com
Accept: application/json
User-Agent: GraphQLClient/1.0

HTTP/1.0 200 OK
Content-Type: application/json
Content-Length: 127

{"data":{"movie":{"id":"123","title":"The Matrix"}}}

The Request Anatomy

HTTP Request Structure
PartContent
Request LinePOST /graphql HTTP/1.1
HeadersHost: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGc...
Content-Length: 62
Blank Line(separates headers from body)
Body{"query":"query MovieTitle { movie(id: \"1\") { title } }"}

HTTP Methods: The Verbs

HTTP defines methods (verbs) for different operations:

MethodIdempotentSafeBodyPurpose
GETYesYesNo*Retrieve resource
HEADYesYesNoGet headers only
POSTNoNoYesCreate/submit data
PUTYesNoYesReplace resource
PATCHNoNoYesPartial update
DELETEYesNoNo*Remove resource
OPTIONSYesYesNoGet allowed methods
TRACEYesYesNoDebug/echo request
CONNECTNoNoYesEstablish tunnel

* Technically allowed but rarely used.

GraphQL uses: POST (always), GET (queries only, optional).

GraphQL's controversial choice: GraphQL uses POST for everything - queries, mutations, subscriptions. This violates REST conventions where GET should be used for reads. But GraphQL has good reasons:

  1. Query strings have length limits (~2000-8000 chars depending on browser/server)
  2. GraphQL queries can be massive (nested selections, fragments, variables)
  3. Caching is handled differently anyway (operation-level, not URL-level)

Status Codes: How HTTP Talks Back

HTTP status codes are three-digit numbers that tell you what happened:

HTTP Status Code Families
CodeNameMeaning
1xx - Informational
100Continue"Keep sending that body"
101Switching Protocols"Upgrading to WebSocket now"
2xx - Success
200OK"Here's your data"
201Created"Resource created successfully"
204No Content"Done, nothing to return"
3xx - Redirection
301Moved Permanently"Permanently moved, update your links"
302Found"Temporarily over there"
304Not Modified"Use your cached version"
4xx - Client Error
400Bad Request"I can't understand your request"
401Unauthorized"Who are you?"
403Forbidden"I know who you are, and no"
404Not Found"That doesn't exist"
429Too Many Requests"Slow down there, buddy"
5xx - Server Error
500Internal Error"Something broke"
502Bad Gateway"Upstream server failed"
503Unavailable"Server is overloaded or down"
504Gateway Timeout"Upstream took too long"

GraphQL's Status Code Philosophy

Here's where GraphQL gets weird. Traditionally, a GraphQL response almost always returns 200 OK, even when there are errors:

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": null,
"errors": [
{
"message": "User not found",
"path": ["user"]
}
]
}

Wait, what? The user wasn't found but we got 200 OK?

GraphQL's reasoning:

  • The HTTP request succeeded (it reached the server, was parsed, was executed)
  • The GraphQL execution had errors, but that's GraphQL-level, not HTTP-level
  • Partial success is possible (some fields resolve, others fail)
GraphQL HTTP Status Codes (per the GraphQL-over-HTTP draft)
HTTP CodeWhenGraphQL Response
200 OKExecuted cleanly, no errors{ data }
294 Partial SuccessExecuted, errors alongside data{ data, errors }
400Malformed JSON, or the document could not be parsed{ errors }
422Parsed but invalid: failed validation, bad variables{ errors }
401 / 403Authentication / authorization required(varies)
405Wrong method (e.g. GET for mutation)(varies)
5xxServer failed before producing a GraphQL response(varies)

Legacy servers still return 200 for everything that executes, reserving 4xx/5xx for transport-level failures. The accurate codes above apply when the client accepts the newer application/graphql-response+json media type.

That said, the always-200 era is ending. The GraphQL-over-HTTP specification defines a dedicated response media type, application/graphql-response+json, and a client that accepts it promises to read the body no matter what the status code says. That frees servers to send honest codes for the benefit of everything that can't parse GraphQL: your CDN, your gateway, and your dashboards can tell a clean 200 from a 294 Partial Success from a 422 without opening a single response body.

HTTP Headers: The Metadata Layer

Headers are key-value pairs that provide metadata about the request or response.

Essential Request Headers

POST /graphql HTTP/1.1
Host: api.movies.com # Required in HTTP/1.1
Content-Type: application/json # What format is the body?
Content-Length: 156 # How big is the body?
Accept: application/json # What format do you want back?
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
User-Agent: MyApp/1.0 # Who's calling?
Accept-Encoding: gzip, deflate # Compression we understand
Connection: keep-alive # Don't close after response

Essential Response Headers

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 1234
Content-Encoding: gzip # Response is compressed
Cache-Control: no-store # Don't cache this
Date: Mon, 24 Jun 2024 10:30:00 GMT
X-Request-Id: abc123 # For debugging/tracing

Headers That Matter for GraphQL

GraphQL-Relevant Headers

Request Headers:

HeaderPurpose
Content-Type: application/jsonStandard for GraphQL
Content-Type: application/graphqlAlternative (query in body)
Authorization: Bearer <token>Auth token
X-Request-ID: <uuid>Request tracing
Apollo-Require-Preflight: trueApollo Client specific

Response Headers:

HeaderPurpose
Content-Type: application/graphql-response+jsonThe spec's response media type (application/json for legacy clients)
Cache-Control: no-storeUsually no caching
X-Cache: HIT/MISSCDN cache status
Retry-After: 60Rate limiting hint

HTTP/1.1: The Workhorse

HTTP/1.1 (1997-2015) was the dominant protocol version for nearly two decades. Key features:

Persistent Connections

HTTP/1.0 closed the connection after each request. HTTP/1.1 keeps it open:

Chunked Transfer Encoding

Don't know the size upfront? Stream it:

HTTP/1.1 200 OK
Transfer-Encoding: chunked

7\r\n
{"data"\r\n
5\r\n
:{"m\r\n
8\r\n
ovie":{}\r\n
2\r\n
}}\r\n
0\r\n
\r\n

This is crucial for GraphQL subscriptions and streaming responses (like @defer and @stream).

The Head-of-Line Blocking Problem

HTTP/1.1's fatal flaw: requests must be processed in order:

Head-of-Line Blocking in HTTP/1.1

Requests must be processed in order:

  1. Client sends: Request A, Request B, Request C
  2. Server processes: A first (slow!), then B, then C
  3. Client receives: Response A, Response B, Response C - in order

If Request A is slow (complex query), B and C wait!

Workaround: Open multiple TCP connections (typically 6 per host) - but more connections means more overhead and more latency.

For GraphQL, this means a slow query blocks subsequent queries on the same connection.

HTTP/2: The Multiplexing Revolution

HTTP/2 (2015) fixed head-of-line blocking with multiplexing:

HTTP/2 Multiplexing - Single TCP Connection, Multiple Streams

All streams run simultaneously on ONE connection:

  • Stream 1: [Frame][Frame][Frame] → Response A
  • Stream 3: [Frame][Frame] → Response B
  • Stream 5: [Frame][Frame][Frame][Frame] → Response C
  • Stream 7: [Frame] → Response D

Key Features:

  • Binary framing (more efficient than text)
  • Header compression (HPACK)
  • Server push (preemptively send resources) - note: effectively dead in browsers (Chrome disabled it by default in version 106, September 2022; Firefox removed it in version 132). Apollo and other tools don't rely on it. Largely a footnote feature now.
  • Stream prioritization

HTTP/2 Frame Types

HTTP/2 is binary, not text. Messages are split into frames:

HTTP/2 Frame Types
TypeCodePurpose
DATA0x0Request/response body
HEADERS0x1HTTP headers
PRIORITY0x2Stream priority
RST_STREAM0x3Cancel a stream
SETTINGS0x4Connection settings
PUSH_PROMISE0x5Server push
PING0x6Keep-alive/latency measurement
GOAWAY0x7Graceful shutdown
WINDOW_UPDATE0x8Flow control
CONTINUATION0x9Header continuation

Each frame contains a 9-byte header: Length (24 bits), Type (8 bits), Flags (8 bits), Stream Identifier (31 bits).

GraphQL Benefits from HTTP/2

GraphQL + HTTP/2 Benefits

Scenario: Dashboard loading 5 GraphQL queries simultaneously.

AspectHTTP/1.1HTTP/2
Connections5 connections (or queued)1 connection, 5 streams
TCP Handshakes5 (one per connection)1 total
Query isolationSlow query blocks othersQueries complete independently
HeadersFull headers each timeCompressed (HPACK)

Result: Faster perceived performance, less server load.

HTTP/3: The QUIC Revolution

HTTP/3 (2022) replaces TCP with QUIC (UDP-based):

FeatureHTTP/1.1HTTP/2HTTP/3
TransportTCPTCPQUIC (UDP)
MultiplexingNoYesYes
Header CompressionNoHPACKQPACK
EncryptionOptionalOptional*Mandatory
HOL BlockingYesTCP-levelNo
Connection Setup1-3 RTT1-3 RTT0-1 RTT
Connection MigrationNoNoYes

* Browsers require HTTPS for HTTP/2.

Why QUIC Matters

HTTP/2 solved application-level head-of-line blocking but TCP still has it:

TCP Head-of-Line Blocking (HTTP/2 over TCP)

With packet loss:

  • Stream 1: Packet 1, Packet 2, Packet 3
  • Stream 2: Packet 4 (lost), Packet 5
  • Stream 3: Packet 6, Packet 7, Packet 8

TCP blocks ALL streams until Packet 4 is retransmitted - Streams 1 and 3 wait for Stream 2's lost packet.

HTTP/3 over QUIC: Each stream is independent at the transport level. Packet loss on Stream 2 only affects Stream 2.

Zero Round Trip Connection (0-RTT)

Content Negotiation

HTTP lets clients and servers negotiate format, language, and encoding:

# Request
GET /graphql HTTP/1.1
Accept: application/json, application/xml;q=0.9, */*;q=0.1
Accept-Language: en-US, en;q=0.9, de;q=0.8
Accept-Encoding: gzip, deflate, br

# Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: en-US
Content-Encoding: gzip
Vary: Accept, Accept-Encoding

The q parameter indicates preference (0.0-1.0):

  • application/json - implied q=1.0 (most preferred)
  • application/xml;q=0.9 - second choice
  • */*;q=0.1 - anything else as last resort

Caching: The HTTP Superpower

HTTP has sophisticated caching built in:

HTTP Caching Headers

Response Headers (server → cache):

HeaderPurpose
Cache-Control: max-age=3600Cache for 1 hour
Cache-Control: no-cacheValidate before using
Cache-Control: no-storeNever cache
Cache-Control: privateOnly browser cache
Cache-Control: publicCDN can cache too
ETag: "abc123"Content fingerprint
Last-Modified: ...When content changed
Vary: Accept-EncodingCache varies by this header

Request Headers (client → server):

HeaderPurpose
If-None-Match: "abc123"Return 304 if ETag matches
If-Modified-Since: ...Return 304 if unchanged
Cache-Control: no-cacheForce fresh response

The GraphQL Caching Problem

Here's why GraphQL and HTTP caching don't play nice:

# REST (cacheable by URL)
GET /movies/123 HTTP/1.1
# Cache key: /movies/123
# Easy to cache!

# GraphQL (same URL, different data)
POST /graphql HTTP/1.1

{"query": "query MovieTitle { movie(id: \"123\") { title } }"}

# vs

{"query": "query MovieDetails { movie(id: \"123\") { title director { name } reviews { rating } } }"}

# Same URL, same method, totally different responses!

Solutions:

GraphQL Caching Strategies
  1. Persisted Queries (Apollo, Relay) - GET /graphql?id=abc123&variables={"id":"1"} - now cacheable by URL
  2. CDN Response Caching - response includes { "extensions": { "cacheControl": {...} } }, CDN parses and caches accordingly
  3. Application-Level Caching - cache at resolver level; use DataLoader for request-scoped caching, Redis/Memcached for cross-request
  4. Normalized Client Cache (Apollo Client, Relay) - client caches entities by ID; different queries share cached entities

CORS: The Browser's Security Guard

Cross-Origin Resource Sharing controls which websites can call your API:

GraphQL CORS gotcha: GraphQL always sends Content-Type: application/json, which triggers preflight. Every first request to your API has this overhead.

CORS Configuration Example (Spring)

@Configuration
public class CorsConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/graphql")
.allowedOrigins("https://myapp.com", "https://admin.myapp.com")
.allowedMethods("POST", "GET", "OPTIONS")
.allowedHeaders("Content-Type", "Authorization")
.allowCredentials(true)
.maxAge(86400); // Cache preflight for 24 hours
}
}

WebSocket Upgrade: Real-Time GraphQL

GraphQL subscriptions often use WebSocket, which starts as HTTP:

# Upgrade Request
GET /graphql HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: graphql-transport-ws
Sec-WebSocket-Version: 13

# Upgrade Response
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: graphql-transport-ws

After this handshake, the connection switches from HTTP to WebSocket, and GraphQL subscription messages flow freely.

Security Headers

Beyond authentication, these headers protect your API:

Security Headers
HeaderPurpose
Strict-Transport-SecurityForce HTTPS
X-Content-Type-OptionsPrevent MIME sniffing
X-Frame-OptionsPrevent clickjacking
Content-Security-PolicyControl resource loading
X-XSS-ProtectionEnable XSS filter (legacy)
Referrer-PolicyControl referer header

Example for a GraphQL API:

Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'

Debugging HTTP

Browser DevTools

Network Tab Timing Breakdown
PhaseDescription
QueueingWait for connection
StalledBlocked by browser limits
DNS LookupResolve domain name
Initial ConnectionTCP handshake
SSLTLS handshake
Request SentUpload request
Waiting (TTFB)Time to first byte - server processing
Content DownloadReceive response

For GraphQL, "Waiting (TTFB)" is usually the biggest chunk - that's your query execution time.

cURL for Testing

# Basic GraphQL query
curl -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-H "Authorization: Bearer token123" \
-d '{"query": "query MovieTitles { movies { title } }"}'

# With verbose output (see all headers)
curl -v -X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "query MovieTitles { movies { title } }"}'

# Time the request
curl -w "@curl-format.txt" -o /dev/null -s \
-X POST https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "query MovieTitles { movies { title } }"}'

Summary

HTTP Cheat Sheet for GraphQL

Typical GraphQL Request:

POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token>

{"query": "...", "variables": {...}, "operationName": "..."}

Typical GraphQL Response:

HTTP/1.1 200 OK
Content-Type: application/json

{"data": {...}, "errors": [...], "extensions": {...}}

Key Points:

  • GraphQL uses POST for everything (GET optional for queries)
  • Legacy behavior is 200 for anything that executes; the GraphQL-over-HTTP spec now defines honest codes (400/422/294) with application/graphql-response+json
  • Errors live in the response body; status codes are signal for the infrastructure in between
  • HTTP caching is hard; use persisted queries or app-level cache
  • Subscriptions upgrade to WebSocket
  • HTTP/2+ recommended for multiplexed queries

Versions:

  • HTTP/1.1: Works fine, but head-of-line blocking
  • HTTP/2: Better, multiplexing over single connection
  • HTTP/3: Best, QUIC eliminates TCP-level blocking

HTTP wasn't designed for GraphQL. It was designed for fetching hypertext documents in the early 90s. But here we are, using it to execute complex query languages, stream real-time updates, and build the modern web. That's the beauty of good protocol design - it bends without breaking.


Tim Berners-Lee invented HTTP to share physics papers. Now it carries an absurd amount of GraphQL traffic. I'm pretty sure that counts as scope creep.

Sources