Skip to main content

Can you send a body in a GET request?

GET request body - you can attach one in code and it will travel over TCP, but RFC 7231 section 4.3.1 does not define what it means, so most servers discard it before your application ever sees it.

Theory

TL;DR

  • Technically, the body reaches the server at the TCP level. What happens next depends entirely on the server.
  • RFC 7231 says the payload in a GET request "has no defined semantics" - servers can ignore it, forward it, or reject it with 411.
  • Nginx drops GET bodies before they hit your app. Express body-parser skips them by default.
  • Production proxy layers like AWS ALB and Varnish strip the body without warning.
  • For filters, use query params. For large data, use POST.

Quick Example

javascript
// Client sends a body - server never sees it fetch('/api/users', { method: 'GET', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filter: 'active' }) }) .then(res => res.json()) .then(data => console.log(data)); // Server (Express): app.get('/api/users', (req, res) => { console.log(req.body); // {} - body-parser skipped GET res.json({ users: allUsers }); // No filtering applied });

The body traveled in the TCP packet but was dropped before the route handler ran. No error was thrown. That silence is what makes GET bodies so easy to ship without noticing the bug.

What RFC 7231 Actually Says

RFC 7231 section 4.3.1 states: "A payload within a GET request message has no defined semantics." That one sentence is the source of all the confusion. The spec does not say the body is forbidden - it says the server has no obligation to do anything with it. So different servers take different paths: some ignore it, some forward it, some return 411.

The spec also requires GET to be safe and idempotent. A safe method must not change server state. The moment you pass filter logic in a body and depend on the server parsing it, you are relying on application behavior the spec never guaranteed.

How Servers Handle It

Nginx (since v0.7) drops GET bodies in ngx_http_core_module before the request reaches your application. Apache mod_proxy may forward it but logs a warning. Express body-parser checks req.method and skips parsing for GET and HEAD entirely - so req.body is always {} on a GET route unless you explicitly configure { type: '*/*' }.

AWS ALB drops GET bodies before they reach Lambda. CDNs like Varnish and CloudFront build cache keys from the URL only, not the body. So if two clients send the same URL with different bodies, they get the same cached response. That is not just a performance issue - it is a data correctness bug.

Decision Rules

  • Pure data retrieval with simple filters: query params (/users?active=true&role=admin)
  • Complex filter objects or data over roughly 2KB: POST to a search endpoint (POST /users/search)
  • Caching needed: query params only - RFC 7234 does not include the request body in cache keys
  • GraphQL: POST always - GET with body variables is unreliable and was already deprecated in Apollo before v3.0

Elasticsearch is a deliberate exception. Their _search endpoint accepts a JSON body on GET requests to support the full Query DSL. The ES docs acknowledge this explicitly and recommend POST as the alternative. That is a conscious decision by one product team, not a general pattern to follow.

Common Mistakes

Mistake 1: Trusting req.body in an Express GET handler

javascript
// Wrong app.use(express.json()); app.get('/users', (req, res) => { const { filter } = req.body; // Always undefined res.json(db.query(filter)); }); // Fix: switch to POST, or configure middleware explicitly app.use(express.json({ type: '*/*' })); // But adding this just to support GET bodies is a bad trade

body-parser checks req.method and bails out on GET and HEAD. The body never gets parsed.

Mistake 2: Sending large filter data via GET body

javascript
// Wrong - Safari may 400, browsers cap body size on GET fetch('/graphql', { method: 'GET', body: JSON.stringify(largeVariables) // 413 Payload Too Large on some clients }); // Fix: POST per GraphQL spec fetch('/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables: largeVariables }) });

Safari has been known to reject GET requests with a body above 2KB. The GraphQL spec recommends POST for all non-trivial queries.

Mistake 3: Expecting CDN caching to work with GET bodies

javascript
// Wrong assumption - both requests share the same cache entry // Request A: GET /api/data, body: { "tenant": "acme" } // Request B: GET /api/data, body: { "tenant": "contoso" } // Contoso gets Acme data from cache // Fix: put the filter in the URL // GET /api/data?tenant=acme // GET /api/data?tenant=contoso

RFC 7234 builds cache keys from the URL and selected headers. Body content is not part of that key.

Mistake 4: Works locally, breaks in production

Local Flask or plain Node HTTP servers often parse whatever they receive. The issue appears after deploying behind Nginx or a cloud load balancer that strips the body. The dev/prod gap here is painful to debug - the code looks correct locally, the network request completes with 200, and the filtering just silently stops working.

Real-World Usage

  • Elasticsearch _search: GET with JSON body for Query DSL. ES docs explicitly note POST is preferred.
  • Apollo GraphQL (pre-3.0): allowed GET body for query variables. Removed in favor of POST.
  • GitHub Enterprise: GET /search/code accepts body in some versions (undocumented).
  • Splunk 9.x: GET /search/jobs/export uses body for SPL queries.

All of these are legacy decisions or one-off choices by specific teams. None are patterns to copy in new code.

Follow-Up Questions

Q: Why does my GET with curl -d work locally but fail in production?
A: Local dev servers (Flask, plain Node HTTP) parse whatever arrives. Production Nginx or CloudFront strips GET bodies before they reach the app. The TCP packet arrives, but the server discards the body.

Q: Is a GET body ever cached by CDNs?
A: No. RFC 7234 defines cache keys as URL plus selected headers. The body is not part of the key, so a CDN returns the same cached response regardless of body content.

Q: How do Axios and fetch differ when sending a GET body?
A: Both send the body at the TCP level. Axios prints a console warning in some versions. Fetch is silent. Either way, what matters is server-side behavior - and most servers discard it.

Q: I need to send 10KB of filter data with a GET-like operation. What do I use?
A: POST to a search endpoint. Name it /search or /query and document it as a read operation. Add ETag and Cache-Control headers if the back end needs to support caching on POST - some reverse proxies allow that with configuration.

Q: Does HTTP/2 change anything here?
A: No. HTTP/2 changes framing and multiplexing, not method semantics. GET body behavior is still undefined at the application layer.

Examples

Basic: Body Sent, Body Ignored

javascript
// client.js const response = await fetch('/api/products', { method: 'GET', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ category: 'electronics' }) }); const data = await response.json(); // data contains ALL products, not just electronics // The body was transmitted but never read // server.js (Express) app.use(express.json()); app.get('/api/products', (req, res) => { console.log(req.body); // {} res.json(db.getAll()); // Returns everything });

The body left the client, traveled over the network, and was dropped by Express body-parser. No filtering happened. No error was thrown.

Intermediate: Query Params vs POST for Complex Filters

javascript
// Wrong: filter buried in GET body // GET /api/orders + body: { "status": "pending", "userId": 42 } // req.body = {} on the server. Order status: chaos. // Right: simple filters go in query params // GET /api/orders?status=pending&userId=42 app.get('/api/orders', (req, res) => { const { status, userId } = req.query; // Works reliably everywhere const orders = db.orders.filter( o => o.status === status && o.userId === Number(userId) ); res.json(orders); }); // Right: complex filters go to a POST search endpoint // POST /api/orders/search app.post('/api/orders/search', express.json(), (req, res) => { const { filters, sort, pagination } = req.body; // Parsed correctly res.json(db.orders.search(filters, sort, pagination)); });

Query params for simple cases. A dedicated POST endpoint for everything else. Both patterns are predictable across every proxy, CDN, and framework stack.

Advanced: Elasticsearch GET Body (the Legitimate Exception)

bash
# Elasticsearch accepts GET body for its Query DSL curl -X GET "localhost:9200/products/_search" \ -H 'Content-Type: application/json' \ -d '{ "query": { "bool": { "must": [{ "match": { "category": "electronics" } }], "filter": [{ "range": { "price": { "lte": 500 } } }] } }, "sort": [{ "price": { "order": "asc" } }] }'
javascript
// The official ES JS client sends POST internally const result = await client.search({ index: 'products', body: { query: { bool: { must: [{ match: { category: 'electronics' } }], filter: [{ range: { price: { lte: 500 } } }] } } } });

ES added GET body support because the Query DSL is too complex for URL encoding. The official JavaScript client sends the actual HTTP request as POST under the hood anyway. If you hit the REST API directly with GET and a body, it works because the ES team specifically built that support. Most servers did not.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?