A new HTTP verb for safe, idempotent, cacheable queries with a request body. Closes the gap between GET and POST for complex search APIs.
The problem: GET is safe and cacheable but can't carry a body. POST can carry a body but is not safe (caches treat it as state-mutating). For complex search APIs, this forces developers to either stuff queries into URIs (brittle, length-limited, logged in cleartext) or use POST with search semantics (un-cacheable, semantically wrong).
application/json, application/sql, application/graphql, application/x-www-form-urlencoded, custom DSLs.Content-Location URI so clients can later GET the same results without resending the body.pkg/query package implements RFC 10008 QUERY handling including Content-Type validation, 415 + Accept-Query negotiation, and 422 for malformed query bodies. The cache demo uses pkg/cache with a SHA-256 body-keyed store.
| Property | GET | QUERY | POST (search) |
|---|---|---|---|
| Safe (no state change) | Yes | Yes | No |
| Idempotent | Yes | Yes | No |
| Cacheable by default | Yes | Yes | No |
| Carries a request body | Undefined | Yes | Yes |
| Query in URI | Required | Optional | No |
| Accept-Query negotiation | No | Yes | No |
A QUERY request flows through client → (optional proxy cache) → origin server. The animation shows all three paths: cache miss, cache hit, and the Accept-Query negotiation on unsupported formats.
QUERY /users HTTP/1.1 with Content-Type: application/json and a query body.Content-Type is present and supported. Runs the query. Returns 200 OK with Content-Location URI for the result set.X-Cache: MISS. Next identical QUERY → X-Cache: HIT, no backend call.See exactly how the same complex query looks in each HTTP method — and why QUERY is the right choice for search APIs.
# ─── GET (query in URI — breaks at ~2000 chars) ─────────────────── curl -G "https://api.example.com/users" \ --data-urlencode 'filter={"status":"active","dept":["Engineering"]}' \ --data-urlencode 'sort=name:asc' \ --data-urlencode 'limit=50' # ─── POST (not safe, not cacheable) ─────────────────────────────── curl -X POST "https://api.example.com/users/search" \ -H "Content-Type: application/json" \ -d '{"filter":{"status":"active","dept":["Engineering"]},"sort":[{"field":"name","dir":"asc"}],"limit":50}' # ─── QUERY (safe + cacheable + body) ────────────────────────────── curl -X QUERY "https://api.example.com/users" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"filter":{"status":"active","dept":["Engineering"]},"sort":[{"field":"name","dir":"asc"}],"limit":50}'
package main import ( "bytes" "encoding/json" "net/http" ) func queryUsers(client *http.Client, baseURL string) (*http.Response, error) { body, _ := json.Marshal(map[string]interface{}{ "filter": map[string]interface{}{ "status": "active", "dept": []string{"Engineering"}, }, "sort": []map[string]string{{"field": "name", "dir": "asc"}}, "limit": 50, }) req, _ := http.NewRequest("QUERY", baseURL+"/users", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") // QUERY is safe + idempotent — the client can retry on timeout return client.Do(req) } // Server handler (uses pkg/query from this project) import "github.com/jralmaraz/http-query-method/pkg/query" handler := query.JSONQueryHandler(func(q map[string]interface{}) (interface{}, error) { results := db.Query(q["filter"], q["sort"], q["limit"]) return map[string]interface{}{"results": results}, nil })
// ─── Browser / Node.js (fetch API) ─────────────────────────────── const response = await fetch('https://api.example.com/users', { method: 'QUERY', // RFC 10008 method headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ filter: { status: 'active', dept: ['Engineering'] }, sort: [{ field: 'name', dir: 'asc' }], limit: 50, }), // Note: QUERY is not a CORS-safelisted method — preflight will fire }); const data = await response.json(); console.log(`Cache: ${response.headers.get('X-Cache')}`); // MISS or HIT console.log(`Results URI: ${response.headers.get('Content-Location')}`); // ─── Node.js with undici (explicit QUERY support) ───────────────── import { fetch } from 'undici'; // undici allows custom methods const res = await fetch(url, { method: 'QUERY', body, headers });
import httpx # httpx supports custom methods natively query_body = { "filter": {"status": "active", "dept": ["Engineering"]}, "sort": [{"field": "name", "dir": "asc"}], "limit": 50, } # httpx allows any HTTP method string response = httpx.request( method="QUERY", url="https://api.example.com/users", json=query_body, headers={"Accept": "application/json"}, ) data = response.json() print(f"Cache: {response.headers.get('X-Cache')}") # MISS or HIT print(f"Results: {data['results']}") # Flask server handler from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/users', methods=['QUERY']) def query_users(): query = request.get_json() if not query: return jsonify(error="Content-Type required"), 400 results = db.search(query["filter"], query.get("limit", 20)) return jsonify(results=results)
import java.net.URI; import java.net.http.*; import java.net.URLEncoder; // ─── GET (query in URI — breaks at ~2000 chars) ─────────────────── var client = HttpClient.newHttpClient(); var params = URLEncoder.encode(""" {"status":"active","dept":["Engineering"]}""", StandardCharsets.UTF_8); var get = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/users?filter=" + params + "&limit=50")) .GET() .header("Accept", "application/json") .build(); // ─── POST (not safe, not cacheable) ─────────────────────────────── var body = """ {"filter":{"status":"active","dept":["Engineering"]}, "sort":[{"field":"name","dir":"asc"}],"limit":50}"""; var post = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/users/search")) .POST(HttpRequest.BodyPublishers.ofString(body)) .header("Content-Type", "application/json") .build(); // ─── QUERY (safe + cacheable + body) — Java 11+ HttpClient ──────── var query = HttpRequest.newBuilder() .uri(URI.create("https://api.example.com/users")) .method("QUERY", HttpRequest.BodyPublishers.ofString(body)) .headers("Content-Type", "application/json", "Accept", "application/json") .build(); var response = client.send(query, HttpResponse.BodyHandlers.ofString()); // QUERY is safe + idempotent → safe to retry on timeout or 5xx // ─── Spring Boot 6 client (RestClient with custom method) ───────── @Service public class UserQueryService { private final RestClient rest = RestClient.create(); public List<User> findUsers(UserFilter filter) { return rest .method(HttpMethod.valueOf("QUERY")) .uri("https://api.example.com/users") .contentType(MediaType.APPLICATION_JSON) .body(filter) .retrieve() .body(new ParameterizedTypeReference<List<User>>() {}); } } // ─── Spring Boot 6 handler (server side) ────────────────────────── @RestController @RequestMapping("/users") public class UserController { @RequestMapping(method = RequestMethod.valueOf("QUERY"), consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<List<User>> queryUsers(@RequestBody UserFilter filter) { var results = userService.search(filter); return ResponseEntity.ok() .header("Content-Location", "/users/results/" + filter.cacheKey()) .body(results); } }
use reqwest::Method; // reqwest supports custom methods via Method::from_bytes let query_method = Method::from_bytes(b"QUERY").unwrap(); let body = serde_json::json!({ "filter": {"status": "active", "dept": ["Engineering"]}, "sort": [{"field": "name", "dir": "asc"}], "limit": 50, }); let response = client .request(query_method, "https://api.example.com/users") .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&body) .send() .await?; // Axum server handler use axum::{routing::method_routing, Router, Json, http::StatusCode}; async fn query_users(Json(q): Json<serde_json::Value>) -> Json<serde_json::Value> { let results = db.search(&q).await; Json(serde_json::json!({"results": results})) } let app = Router::new() .route("/users", method_routing::on(MethodFilter::try_from("QUERY").unwrap(), query_users));
RFC 10008 §2.2 defines how QUERY responses are cached. The cache key incorporates the full request body, making QUERY unique among cacheable methods.
SHA-256(method + URI + Content-Type + body). A proxy must buffer and hash the request body before looking up the cache — this is inherently more expensive than GET cache lookup.
QUERY /users with body. Proxy reads the full body to compute the cache key: SHA-256(URI + Content-Type + body).200 OK with Cache-Control: max-age=300 and optionally a Content-Location URI for the result set.X-Cache: HIT, 1ms latency, no origin call. Different body → new MISS.Enter a query body. Send it twice to see MISS → HIT behaviour.
// pkg/cache — in-memory QUERY response cache (from this project) store := cache.NewStore(5 * time.Minute) handler := cache.NewCachingHandler(queryHandler, store) // X-Cache: MISS on first request, X-Cache: HIT on repeat // Cache key derivation (RFC 10008 §2.2) func Key(uri, contentType string, body []byte) string { h := sha256.New() h.Write([]byte(uri)) h.Write([]byte("\x00")) h.Write([]byte(contentType)) h.Write([]byte("\x00")) h.Write(body) return hex.EncodeToString(h.Sum(nil)) } // RFC 10008 §2.2: "no-transform" prevents cache normalisation // Cache-Control: no-transform → use raw body as key, skip whitespace normalisation
# nginx — cache QUERY responses using body hash as key http { proxy_cache_path /var/cache/nginx/query levels=1:2 keys_zone=query_cache:10m max_size=1g inactive=5m use_temp_path=off; server { location /api/ { # Allow QUERY to be cached (nginx treats unknown methods as uncacheable by default) proxy_cache_methods GET HEAD QUERY; proxy_cache query_cache; # Cache key: URI + body digest (requires request body digest module) proxy_cache_key "$scheme$request_method$host$request_uri$http_content_type$request_body_hash"; proxy_cache_valid 200 5m; add_header X-Cache $upstream_cache_status; proxy_pass http://backend; } } }
// Apache Tomcat 12 + Servlet 6.1 — native QUERY method support // apache/tomcat#1026 merged July 1 2026; requires Tomcat 12.0.x + Servlet 6.1 API // Tomcat 12 is the FIRST Java servlet container to ship native doQuery() dispatch. import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.*; import java.io.IOException; import java.security.MessageDigest; import java.util.HexFormat; import java.util.concurrent.ConcurrentHashMap; @WebServlet("/api/users") public class UserQueryServlet extends HttpServlet { // Simple in-process cache — replace with Ehcache / Caffeine in production private final ConcurrentHashMap<String, byte[]> cache = new ConcurrentHashMap<>(); // Tomcat 12 dispatches QUERY to doQuery() — override like doGet() / doPost() @Override protected void doQuery(HttpServletRequest req, HttpServletResponse resp) throws IOException { String ct = req.getContentType(); if (ct == null || !ct.contains("application/json")) { resp.setStatus(415); resp.setHeader("Accept-Query", "application/json"); // RFC 10008 §3 return; } byte[] body = req.getInputStream().readAllBytes(); // RFC 10008 §2.2 — body-keyed cache key (SHA-256 of URI + CT + body) String cacheKey = sha256Hex( (req.getRequestURI() + "\0" + ct + "\0").getBytes() ,body); byte[] cached = cache.get(cacheKey); if (cached != null) { resp.setContentType("application/json"); resp.setHeader("X-Cache", "HIT"); resp.getOutputStream().write(cached); return; } // QUERY is safe + idempotent → no side effects; safe to retry byte[] result = userRepository.search(body); cache.put(cacheKey, result); resp.setStatus(200); resp.setContentType("application/json"); resp.setHeader("Cache-Control", "max-age=300"); resp.setHeader("Content-Location", "/api/users/results/" + cacheKey); // RFC 10008 §4 resp.setHeader("X-Cache", "MISS"); resp.getOutputStream().write(result); } private String sha256Hex(byte[]... parts) throws IOException { try { var md = MessageDigest.getInstance("SHA-256"); for (var p : parts) md.update(p); return HexFormat.of().formatHex(md.digest()); } catch (Exception e) { throw new IOException(e); } } }
// Spring Boot 6 — QUERY caching handler // Spring Framework issue #36988 closed as "not planned" — @QueryMapping annotation // cannot be added because @QueryMapping is already claimed by Spring GraphQL. // Community-endorsed workaround (confirmed by Spring team): RequestMethod.valueOf("QUERY") // This matches how @RequestMapping resolves any custom HTTP method name. @RestController @RequestMapping("/api/users") public class UserQueryController { private final UserService userService; private final Cache<String, List<User>> queryCache; // Caffeine / Ehcache // No @QueryMapping — use RequestMethod.valueOf("QUERY") workaround @RequestMapping(method = RequestMethod.valueOf("QUERY"), consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<List<User>> queryUsers(@RequestBody UserFilter filter) { // RFC 10008 §2.2 — body-keyed cache: use deterministic filter key String cacheKey = filter.cacheKey(); // SHA-256(canonical JSON) of filter List<User> cached = queryCache.getIfPresent(cacheKey); if (cached != null) { return ResponseEntity.ok() .header("X-Cache", "HIT") .header("Content-Location", "/api/users/results/" + cacheKey) .body(cached); } // QUERY is idempotent — safe to run under Spring's @Cacheable as well List<User> results = userService.search(filter); queryCache.put(cacheKey, results); return ResponseEntity.ok() .header("Cache-Control", "max-age=300") .header("Content-Location", "/api/users/results/" + cacheKey) // RFC 10008 §4 .header("X-Cache", "MISS") .body(results); } } // RestClient (Spring 6.1+) on the calling side — no special setup needed var results = RestClient.create() .method(HttpMethod.valueOf("QUERY")) .uri("https://api.example.com/api/users") .contentType(MediaType.APPLICATION_JSON) .body(filter) .retrieve() .body(new ParameterizedTypeReference<List<User>>() {});
// Varnish VCL — full QUERY caching with body-keyed lookup import digest; sub vcl_recv { // Allow QUERY to pass through cache lookup (not just GET/HEAD) if (req.method == "QUERY") { // Buffer the body (requires bereq.body access in VCL 7+) set req.http.X-Query-Body-Hash = digest.hash_sha256(req.body); // Synthesise a cache-lookup key combining URI + body hash set req.http.X-Cache-Key = req.url + "|" + req.http.Content-Type + "|" + req.http.X-Query-Body-Hash; return(hash); // proceed to cache lookup } } sub vcl_hash { if (req.method == "QUERY") { hash_data(req.http.X-Cache-Key); return(lookup); } } sub vcl_deliver { set resp.http.X-Cache = if(obj.hits > 0, "HIT", "MISS"); }
Cache-Control: no-transform to prevent the cache from normalising the body before computing the key. The raw body is used as-is.Content-Location: /users/queries/q-abc123. Client can GET that URI later without resending the body — no cache-key computation needed.RFC 10008 §2 defines specific HTTP status codes for each class of QUERY failure. Correct error responses are essential for client adaptation and debuggability.
| Status | Trigger | RFC ref | Special header |
|---|---|---|---|
400 Bad Request | Missing Content-Type header on QUERY request | §2 | — |
400 Bad Request | Content-Type claims JSON but body is XML (inconsistency) | §2 | — |
405 Method Not Allowed | QUERY sent to a resource that does not support it | RFC 9110 | Allow: GET, HEAD |
406 Not Acceptable | Accept header lists format server can't produce | §2 | — |
415 Unsupported Media Type | Query body format not understood by server | §2 + §3 | Accept-Query: application/json |
422 Unprocessable Content | Syntactically valid query but semantically invalid | §2 | — |
200 OK | Query processed successfully | §2 | Content-Location (optional) |
Accept-Query header listing the media types it does accept. This allows clients to adapt automatically. Example: Accept-Query: application/json, application/x-www-form-urlencoded
// pkg/query.Handler enforces RFC 10008 error semantics automatically import "github.com/jralmaraz/http-query-method/pkg/query" handler := query.NewHandler( func(req *query.Request) ([]byte, string, error) { // Validate query semantics var q SearchQuery if err := json.Unmarshal(req.Body, &q); err != nil { return nil, "", &query.QueryError{ Status: http.StatusUnprocessableEntity, // 422 Message: "invalid query: " + err.Error(), } } results := db.Search(q) out, _ := json.Marshal(results) return out, "application/json", nil }, "application/json", // supported types → Accept-Query on 415 ) // Missing Content-Type → 400 (automatic) // Wrong Content-Type → 415 + Accept-Query header (automatic)
const express = require('express'); const app = express(); // Express doesn't know QUERY natively — register it explicitly app.all('/users', (req, res) => { if (req.method !== 'QUERY') { return res.set('Allow', 'QUERY').status(405).json({ error: 'Method Not Allowed' }); } // RFC 10008 §2: validate Content-Type const ct = req.headers['content-type'] || ''; if (!ct) { return res.status(400).json({ error: 'Content-Type is required for QUERY' }); } if (!ct.includes('application/json')) { return res .set('Accept-Query', 'application/json') // RFC 10008 §3 .status(415).json({ error: 'Unsupported format' }); } const results = db.search(req.body); res.json({ results }); });
from fastapi import FastAPI, Request, Response from fastapi.routing import APIRoute app = FastAPI() # FastAPI allows custom HTTP methods via include_router @app.api_route("/users", methods=["QUERY"]) async def query_users(request: Request): ct = request.headers.get("content-type", "") if not ct: return Response( content='{"error":"Content-Type required"}', status_code=400, media_type="application/json" ) if "application/json" not in ct: return Response( content='{"error":"Unsupported format"}', status_code=415, media_type="application/json", headers={"Accept-Query": "application/json"} # RFC 10008 §3 ) body = await request.json() results = db.search(body) return {"results": results}
import org.springframework.web.bind.annotation.*; import org.springframework.http.*; @RestController @RequestMapping("/users") public class UserController { // Register QUERY method — Spring MVC accepts arbitrary method names @RequestMapping(method = RequestMethod.valueOf("QUERY"), consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> queryUsers( HttpServletRequest req, @RequestBody(required = false) UserFilter filter) { // 400 — missing Content-Type or body (Spring throws HttpMediaTypeNotSupportedException) if (filter == null) { return ResponseEntity.badRequest() .body(Map.of("error", "Content-Type and body required")); } // Business logic — safe to retry (QUERY is idempotent) var results = userService.search(filter); // 200 — include Content-Location for the result URI (RFC 10008 §4) return ResponseEntity.ok() .header("Content-Location", "/users/results/" + filter.cacheKey()) .body(results); } // 415 — wrong Content-Type; Spring auto-sends this but you can customise: @ExceptionHandler(HttpMediaTypeNotSupportedException.class) public ResponseEntity<?> handleUnsupportedMedia() { return ResponseEntity.status(415) .header("Accept-Query", "application/json") // RFC 10008 §3 .body(Map.of("error", "Unsupported format; use application/json")); } // 405 — if someone sends GET or POST to this endpoint @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity<?> handleWrongMethod() { return ResponseEntity.status(405) .header("Allow", "QUERY, OPTIONS") .body(Map.of("error", "Use QUERY method for search operations")); } }
The QUERY method is particularly powerful for database-backed APIs — any query language can be the body: SQL, GraphQL, JSONPath, or a custom DSL. See how different backends expose the QUERY method.
// PostgreSQL: accept application/sql body, execute as read-only query handler := query.NewHandler(func(req *query.Request) ([]byte, string, error) { sql := string(req.Body) // Safety: only SELECT statements allowed (parse + verify before exec) if !isSafeSelect(sql) { return nil, "", &query.QueryError{Status: 422, Message: "only SELECT queries allowed"} } // Execute in a read-only transaction to enforce safety semantics rows, err := db.QueryContext(ctx, "BEGIN READ ONLY; "+sql+"; COMMIT") if err != nil { return nil, "", &query.QueryError{Status: 422, Message: err.Error()} } defer rows.Close() result := scanRows(rows) // → []map[string]interface{} out, _ := json.Marshal(result) return out, "application/json", nil }, "application/sql") // Content-Location lets client bookmark results: // Content-Location: /queries/results/sha256-abcdef
// GraphQL: accept application/graphql body import "github.com/graphql-go/graphql" handler := query.NewHandler(func(req *query.Request) ([]byte, string, error) { // Accept both pure GraphQL body and JSON-wrapped {query, variables} var gqlQuery string var variables map[string]interface{} if strings.HasPrefix(req.ContentType, "application/graphql") { gqlQuery = string(req.Body) } else { var envelope struct { Query string; Variables map[string]interface{} } json.Unmarshal(req.Body, &envelope) gqlQuery = envelope.Query variables = envelope.Variables } result := graphql.Do(graphql.Params{Schema: schema, RequestString: gqlQuery, VariableValues: variables}) out, _ := json.Marshal(result) return out, "application/json", nil }, "application/graphql", "application/json")
from fastapi import FastAPI, Request, Response from sqlalchemy import text import json app = FastAPI() @app.api_route("/data", methods=["QUERY"]) async def sql_query(request: Request): ct = request.headers.get("content-type", "") if "application/sql" not in ct: return Response(status_code=415, headers={"Accept-Query": "application/sql, application/json"}) sql = (await request.body()).decode() # Enforce read-only: parse AST, reject non-SELECT if not sql.strip().upper().startswith("SELECT"): return Response(status_code=422, content='{"error":"Only SELECT allowed"}') with db.connect() as conn: result = conn.execute(text(sql)) rows = [dict(r) for r in result] return {"results": rows, "total": len(rows)}
const express = require('express'); const { MongoClient } = require('mongodb'); app.all('/documents', async (req, res) => { if (req.method !== 'QUERY') return res.status(405).send(); const filter = req.body?.filter || {}; const project = req.body?.project || {}; const sort = req.body?.sort || {}; const limit = Math.min(req.body?.limit || 100, 1000); // MongoDB filter expression is the QUERY body — no separate query param const results = await collection .find(filter, { projection: project }) .sort(sort) .limit(limit) .toArray(); // Return Content-Location for client-side bookmarking const queryHash = sha256(JSON.stringify(req.body)); res .set('Content-Location', `/queries/${queryHash}`) .set('Cache-Control', 'max-age=300') .json({ results, total: results.length }); });
Send QUERY requests to the in-browser Go server (compiled to WASM). All processing happens locally — no network requests.
This project tracks RFC 10008 and related HTTP standards. A GitHub Actions workflow checks the IETF Datatracker daily and opens a GitHub issue on any update.
implemented_rev in the baseline locks the specific revision implemented.Last checked: 2026-08-02 · standards-baseline.json · standards-tracker.yml
| Standard | WG / Publisher | Lifecycle status | PoC status | Rev | Affected files | Impl. commit |
|---|---|---|---|---|---|---|
| RFC 10008 — HTTP QUERY Method | IETF HTTP WG | ● Published RFC | Implemented | RFC |
pkg/query, cmd/demo-wasm, demo/index.html |
initial |
| RFC 9110 — HTTP Semantics | IETF HTTP WG | ● Published RFC | Implemented | RFC |
pkg/query (method semantics, status codes, Allow header) |
initial |
| RFC 7234 — HTTP/1.1 Caching | IETF HTTP WG | ● Published RFC | Implemented | RFC |
pkg/cache (TTL, X-Cache, no-transform, body-keyed key) |
initial |
| RFC 8288 — Web Linking | IETF HTTP WG | ● Published RFC | Monitoring | RFC |
Content-Location / Location result URI semantics |
monitoring |
| RFC 7807 — Problem Details | IETF HTTP WG | ● Published RFC | Monitoring | RFC |
pkg/query error responses (could adopt application/problem+json) |
monitoring |
Researched 2026-08-29 via jeswr/http-query-adoption, desiderantes' tracker, and primary issue trackers. Full data in standards-baseline.json → ecosystem_adoption.
Servers & Runtimes
| Implementation | Status | Details |
|---|---|---|
| Apache Tomcat 12 | ✅ Shipped | apache/tomcat#1026 merged July 1, 2026. Native doQuery() in HttpServlet (Servlet 6.1). First Java container with native support. Requires Tomcat 12.0.x minimum. |
| Node.js (http) | ✅ Works | Node.js http module has parsed QUERY natively since early 2024 — the http parser imposes no method allowlist. No special config. undici explicit support: nodejs/undici#5454 (open, body-aware cache key). |
| Eclipse Jetty 13 | 🔄 PR open | jetty.project#15316 — implements safe+idempotent registration, Accept-Query header, redirect semantics, compression handler integration. Not yet merged. |
| nginx | 🔄 PR open | nginx/nginx#1488 — basic QUERY method recognition. Note: proxy_cache_methods GET HEAD QUERY already works as a Varnish/cache config workaround today. |
Frameworks
| Framework | Status | Details |
|---|---|---|
| ASP.NET Core / .NET 10 | ✅ Shipped | Ships HttpMethods.Query constant, MapMethods(), and [AcceptVerbs] support. First-class — no workarounds. |
| Quarkus / Vert.x | ✅ Works | Netty-based routing imposes no method restriction. QUERY reaches route handlers without special handling. Route via .method(HttpMethod.valueOf("QUERY")). |
| Express.js | ✅ Works | app.all('/path', handler) + if (req.method !== 'QUERY') return res.status(405). No dedicated app.query() — works today without changes. |
| Spring Framework MVC | ❌ Not planned | spring-framework#36988 closed not-planned. @QueryMapping conflicts with Spring GraphQL. Workaround (Spring-confirmed): @RequestMapping(method = RequestMethod.valueOf("QUERY")) — what this demo uses. Stable, correct. |
| Fastify | 🔄 Issue open | fastify#6807 — proposes fastify.query() shorthand, schema validation, lifecycle hooks. Works today via fastify.route({method:'QUERY', ...}). |
Proxies, CDNs & API Tooling
| Implementation | Status | Details |
|---|---|---|
| Varnish / HAProxy | ✅ Works | Both forward arbitrary methods. Varnish VCL body-keyed caching works today (see Caching tab). HAProxy passes QUERY transparently with no config changes. |
| Envoy Proxy | 🔄 PRs open | PRs open for HTTP/1, HTTP/2, and HTTP/3 QUERY support. Envoy's codec currently normalises unknown methods in ways that may drop the body or misclassify safety. |
| Cloudflare Workers | 🔄 Issue open | Issue open for body-keyed QUERY caching in workerd. RFC 10008 was co-authored by Cloudflare engineers — production edge support expected but no ship date. |
| OpenAPI 3.2 | ✅ Shipped | OpenAPI 3.2 path items formally support QUERY as a method. API specs can now describe QUERY endpoints with full schema, request body, and response definitions. |
| OpenSearch / Elasticsearch | 🔄 RFC open | OpenSearch#22409 — RFC to replace POST /_search with QUERY /_search, making all search safe+cacheable. Synchronized rollout with Elasticsearch under discussion. |
Browser & Standards Body Signals
| Body / Vendor | Status | Details |
|---|---|---|
| WHATWG Fetch | 🔄 Issue open | whatwg/fetch#1938 (June 30, 2026) — three open integration questions: (1) method normalization, (2) CORS preflight by design, (3) body-keyed cache model. See "Path to Browser Support" below. |
| Mozilla Firefox | 💬 Position pending | mozilla/standards-positions#1430 — position requested July 2026. No position published. Not requesting immediate ship — guiding WHATWG Fetch/HTML work. |
| WebKit / Safari | 💬 Position pending | WebKit/standards-positions#692 — companion to Mozilla #1430 and Fetch #1938. No position published. |
| Chrome / Blink | — No signal | No Chrome Platform Status entry or Intent to Prototype as of 2026-08-29. Chrome does not cache repeated identical QUERY requests — body-keyed caching not implemented. Waiting on WHATWG Fetch #1938. |
RequestMethod.valueOf("QUERY") pattern is the correct, stable, Spring-team-endorsed approach. No better option is coming.
QUERY is safe, idempotent, and cacheable — the perfect semantics for a browser. Yet browser support involves several independent problems that must each be solved in the right order. Here is what needs to happen, and why it is non-trivial despite QUERY being, on the surface, a simple new HTTP method.
Before any browser can implement QUERY, the WHATWG Fetch Standard must be updated. Three questions must be resolved:
fetch(url, {method: 'query'}) sends lowercase query — not QUERY. Servers that do case-sensitive method matching will reject it silently. QUERY must be added to the "normalize a method" algorithm to prevent this footgun.Access-Control-Allow-Methods: QUERY in their preflight response. This is already the correct behaviour — no Fetch spec change required for CORS.Mozilla, WebKit, and Chrome must each publish a formal position before their engineers begin implementation. These positions signal: "we think this is a good idea and we intend to implement it." Without all three, the feature stalls because the browser interop requirement (identical behaviour across engines) cannot be guaranteed. The positions were requested in late June/July 2026 — expect 2–6 months for each engine's review process.
Four distinct browser subsystems each require changes:
sha256(URI + Content-Type + body). No existing method does this. Requires memory and disk cache changes in each engine.cache.put(queryRequest, response) must store and retrieve by body. Current Cache API is URL-keyed per the Fetch spec. A body-keyed variant is a new API surface — needs specification and implementation.<form method="QUERY"> does not work — WHATWG HTML lists only GET and POST as valid form methods. Adding QUERY requires both the HTML spec and browser HTML parser to be updated.Cross-browser interoperability is enforced through the WPT (Web Platform Tests) suite. QUERY tests must be written and pass in all three engines before the feature can be considered "interoperable." DevTools updates are also required: the Network panel currently treats body-carrying requests as POST-like (non-cacheable). QUERY needs its own badge showing safe+idempotent+cacheable with the body-keyed cache key visible in the headers inspector.
Browser adoption will instantly expose gaps in the surrounding infrastructure: WAFs that block unknown methods (many enterprise WAFs currently return 405 for anything not in GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS); CSP doesn't restrict methods but tooling may flag QUERY in security scans; API gateways like Kong and AWS API Gateway need QUERY added to their allowed-methods lists; CDN edge caches (Cloudflare, Fastly, Akamai) need body-keyed cache entries. RFC 10008 was co-authored by Cloudflare and Akamai engineers precisely to drive this alignment from the top, but enterprise WAF rules take years to propagate.
Estimates based on comparable method/cache changes (e.g. PATCH — RFC 5789, 2010 → broadly supported by 2012; HTTP/2 push → deprecated 2022 despite complexity). Body-keyed cache is the wildcard — it has no precedent in browser engines.
scripts/check_standards.py at 09:00 UTC. Queries the IETF Datatracker API for each tracked standard and compares to standards-baseline.json.standards-update labelled issue lists the change, the PoC impact, affected files, and the due-diligence checklist from docs/standards-tracking.md.