Tech15 min read

HTTP QUERY method (RFC 10008) vs POST /search: cache keys and CORS preflight

IkesanContents

In June 2026, HTTP gained a new method called QUERY.
It became a Proposed Standard as RFC 10008: The HTTP QUERY Method, and IANA’s HTTP Method Registry now lists QUERY with Safe: yes and Idempotent: yes.

QUERY is a method for read-only queries that carry a request body.
It is safe and idempotent like GET (idempotent meaning the result doesn’t change no matter how many times you send the same request), and it can carry a body like POST.
The “read-only query with a body” that many of us have been writing as POST /search or POST /graphql now has an official name in HTTP.

Where GET stops being enough

For small search conditions, plain GET is fine.

GET /feed?q=foo&limit=10&sort=-published HTTP/1.1
Host: example.org

Safe, idempotent, easy to cache, and the URL carries the whole condition so you can share it as-is.

The trouble starts when the query conditions grow.
Once you stack search-UI filters, nested AND/OR, full-text search, sorting, pagination, and aggregation conditions, the URL gets long fast.
RFC 10008 itself lists the problems with oversized data in a URI: size limits along the path are hard to know in advance, URI encoding adds overhead, and URIs tend to stick around in logs and bookmarks.

You could also attach a body to GET, but RFC 9110 says a body on a GET request has no generally defined semantics.
It also says a client should not generate a GET body unless the origin server has explicitly indicated support.
With proxies, CDNs, load balancers, and frameworks in the path, you get implementation differences: some drop the body, some reject it, some handle it in unexpected ways.

POST hides the meaning from intermediaries

Real-world APIs often use this shape.

POST /feed HTTP/1.1
Host: example.org
Content-Type: application/json

{
  "q": "foo",
  "limit": 10,
  "sort": "-published"
}

You get JSON in the body and no URL length worries.
But in HTTP terms, POST is the method that asks the target resource to process the request body according to the resource’s own semantics, and it is neither safe nor idempotent.

The application can decide “this POST /search is read-only”.
That, however, is a private agreement between the server and the humans who know the API.
Caches, proxies, retry logic, CORS, API gateways, and WAFs — the HTTP components in the middle — decide how to treat a request before the response comes back.
Even when POST /search returns 200 OK, the HTTP layer cannot tell whether that was a read-only search or something that changed server state.

This is where QUERY comes in.

QUERY /feed HTTP/1.1
Host: example.org
Content-Type: application/json

{
  "q": "foo",
  "limit": 10,
  "sort": "-published"
}

RFC 10008 defines QUERY as a safe and idempotent method.
The request body and metadata such as Content-Type define the query, and the target URI defines the scope of the query.
It carries a body like POST does, but the method itself tells intermediaries “this is a read-only, retryable query”.

What QUERY actually defines

QUERY only defines HTTP-level semantics; it does not step into query languages.
It does not standardize SQL, replace GraphQL, or define a schema for search APIs.

MethodBodySafeIdempotentTypical use
GETNo general semanticsyesyesFetching resources expressible as a URI
POSTYesnonoCreate, submit, execute, resource-specific processing
PUTYesnoyesReplace the target resource
DELETEGenerally unnecessarynoyesDelete the target resource
OPTIONSGenerally unnecessaryyesyesDiscover communication options and supported methods
QUERYYesyesyesRead-only queries with conditions in the body

RFC 10008 requires a Content-Type on QUERY requests.
If the media type is missing or inconsistent with the body, the request fails with a 4xx.
Candidates: 415 Unsupported Media Type for an unsupported media type, 422 Unprocessable Content when the query is syntactically valid but cannot be processed, and 406 Not Acceptable when the response cannot be produced in a format the client accepts.

The server can advertise which query formats it accepts with the Accept-Query response header.
The RFC’s examples include application/jsonpath and application/sql.

Accept-Query: "application/jsonpath", application/sql;charset="UTF-8"

The target URI sets the scope of the query.
QUERY /orders is a query against the collection of orders, QUERY /graphql is a query against a GraphQL endpoint.
The meaning of a query comes from the combination of URI and body.

Cache keys can no longer be just the URL

What QUERY changes is less the look of your client code and more the behavior of intermediaries.

Because QUERY is idempotent, retrying the same request after a connection failure is easier to justify.
POST can be retried too if the application implements idempotency keys, but the HTTP method alone gives no such signal.
With QUERY, at least the semantics of the request say a retry is allowed.

Caching gets more involved.
RFC 10008 makes QUERY responses cacheable, but requires the cache key to include not just the target URI but also the request body and related metadata.
With GET /feed?q=foo, the URL is more or less the cache key; with QUERY /feed, the cache has to read the whole body and the Content-Type to build the key.

QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{ "status": "paid", "limit": 50 }
QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{ "status": "cancelled", "limit": 50 }

These two look like the same URL, but they must be different cache entries.
The simplest approach is to key the cache internally on “URI + hash of the normalized body”.

method: QUERY
target: https://api.example.com/orders
content-type: application/json
accept: application/json
body-digest: sha256(canonical-json(body))
auth-scope: tenant:123:user:456

This key is for internal use by CDNs, reverse proxies, API gateways, and in-app caches — it is not a URL a human ever sees.
For JSON you would want to normalize whitespace and key order before hashing, but if that normalization diverges from how the server interprets the body, the cache starts serving one query’s response to a different query.
RFC 10008 itself is explicit that caching QUERY is more complex than caching GET.

QUERY conditions can’t be shared by copying a URL

With GET /orders?status=paid&limit=50, the URL contains the query condition, so you can paste it straight into a browser, Slack, an issue, or a bookmark.
With QUERY /orders, the condition lives in the body; copy the URL and the condition is gone.

RFC 10008 offers a way to fill this gap with Location and Content-Location.
Send the complex condition once via QUERY, let the server assign URIs to the query and its result, and use GET on those URIs afterwards.

QUERY /reports HTTP/1.1
Host: api.example.com
Content-Type: application/json

{
  "from": "2026-01-01",
  "to": "2026-06-30",
  "groupBy": ["region", "plan"],
  "metrics": ["revenue", "churn"]
}
HTTP/1.1 200 OK
Content-Type: application/json
Location: /reports/queries/8f3a...
Content-Location: /reports/results/7b91...
Cache-Control: max-age=300

Location is a URI that re-executes this exact query, and Content-Location is a URI that fetches this particular result.

URI typeExampleMeaning
Query URI/reports/queries/8f3a...Re-run the same conditions
Result URI/reports/results/7b91...Fetch the result as of that run, or a stored snapshot

Behind a query URI sits a saved search (“this ID means these conditions”), so the result changes as the data changes.
A result URI points at a snapshot, so the result is fixed — at the cost of extra design work for retention periods and access control.
A practical implementation is to normalize and hash the query body and return a URI like /reports/queries/{hash}.

Where it sits next to REST and GraphQL

In a REST API, QUERY reads as an addition to REST’s uniform interface: one more read-only method.
Small fetches and simple searches stay on GET.

GET /orders?status=paid&limit=50 HTTP/1.1
Host: api.example.com

QUERY earns its place when the conditions are too big to push into a URL but the operation is still read-only.

QUERY /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{
  "filter": {
    "and": [
      { "status": ["paid", "refunded"] },
      { "createdAt": { "gte": "2026-01-01", "lt": "2026-07-01" } },
      { "customer": { "segment": ["enterprise", "education"] } }
    ]
  },
  "sort": [{ "field": "createdAt", "direction": "desc" }],
  "limit": 100
}

Point the target URI at the collection you are querying.
QUERY /orders keeps the meaning on the HTTP method, instead of inventing a verb-ish subresource like /orders/query.
This is not about converting an existing REST API wholesale: fetches that fit in a URL stay on GET, and creates, updates, deletes, and job launches stay on POST, PUT, PATCH, and DELETE.

GraphQL is a slightly different story.
The current GraphQL over HTTP draft requires servers to accept POST and allows them to accept GET.
It also forbids executing mutations over GET.
In practice, even a read-only query goes out as a POST like this.

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

{
  "query": "query ($id: ID!) { user(id: $id) { name } }",
  "variables": { "id": "123" }
}

This POST may be a read-only query in GraphQL terms.
But at the HTTP layer it is a POST, and intermediaries cannot distinguish queries from mutations without understanding the GraphQL body.

Using QUERY for GraphQL could look like this.

QUERY /graphql HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/graphql-response+json

{
  "query": "query ($id: ID!) { user(id: $id) { name } }",
  "variables": { "id": "123" }
}

Now the GraphQL-level query and the HTTP-level QUERY line up, and the read-only, retryable, cacheable nature is visible from the HTTP method.
Mutations have side effects, so they stay on POST.
That said, the current GraphQL over HTTP draft has not adopted QUERY; using it in practice means waiting for GraphQL servers, clients, CDNs, and frameworks to support it.

AspectREST APIGraphQL-style APIHTTP QUERY
Centered onResource URIs and HTTP methodsSchema and query languageHTTP method semantics
Typical URL/orders, /orders/123/graphqlAny target URI
ReadsMostly GETPOST or GETQUERY
Complex conditionsURL query or ad-hoc POST /searchGraphQL document in the bodyQuery content in the body
Side effectsPOST, PUT, PATCH, DELETEmutationOut of scope for QUERY

Typical use cases

The most obvious one is search and filtering.
Product search on an EC site, order search in an admin panel, log search, audit event search, filtered user lists.
A handful of conditions fit in GET; once you have a condition builder with nested AND/OR and array conditions, QUERY becomes a candidate.

Next, reporting and analytics APIs.
You send periods, dimensions, metrics, and filters as JSON, but the processing is read-only.
Today this tends to be written as POST /reports/run; if it really only fetches results, QUERY /reports matches the semantics better.

Query languages like GraphQL and JSONPath are also a natural fit.
The Accept-Query examples in RFC 10008 include application/jsonpath.
SQL-like read queries, document-DB search DSLs, and OData-style complex filters use it the same way: a query language in the body.

From fetch or Axios it’s just a method string

On the client, you set method to "QUERY" in fetch and attach a body.

const response = await fetch("https://api.example.com/orders", {
  method: "QUERY",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json",
  },
  body: JSON.stringify({
    filter: {
      status: ["paid", "refunded"],
      createdAt: {
        gte: "2026-01-01",
        lt: "2026-07-01",
      },
    },
    sort: [{ field: "createdAt", direction: "desc" }],
    limit: 100,
  }),
});

if (!response.ok) {
  throw new Error(`QUERY failed: ${response.status}`);
}

const orders = await response.json();

It looks almost identical to a POST; the difference is that the HTTP method now declares “this is a read-only query”.
If you send this cross-origin from a browser, QUERY triggers a CORS preflight (the OPTIONS request the browser sends before the real one).
Unless the server includes QUERY in Access-Control-Allow-Methods, the browser blocks the request before your application code ever sees a response.

Axios works the same way: pass "QUERY" as method.

import axios from "axios";

const { data } = await axios({
  url: "https://api.example.com/orders",
  method: "QUERY",
  headers: {
    "Content-Type": "application/json",
    "Accept": "application/json",
  },
  data: {
    filter: {
      status: ["paid", "refunded"],
      createdAt: {
        gte: "2026-01-01",
        lt: "2026-07-01",
      },
    },
    sort: [{ field: "createdAt", direction: "desc" }],
    limit: 100,
  },
});

Writing the code is one step; getting the request through the path is another.
The request fails if any of the frontend code, browser, proxy, CDN, WAF, API gateway, or backend framework rejects an unknown method.
For experiments, first confirm curl -X QUERY reaches the origin, then check the browser preflight.

curl -i -X QUERY "https://api.example.com/orders" \
  -H "Content-Type: application/json" \
  -d '{"filter":{"status":["paid"]},"limit":10}'

The server side reads REQUEST_METHOD and the body

A bare-bones PHP handler checks $_SERVER['REQUEST_METHOD'] for QUERY and reads the body from php://input.
$_POST is not “an array that fills up on any POST” — it is PHP’s parse result of a form-encoded body.
For an application/json body, whether the method is POST or QUERY, you read the raw body from php://input and json_decode() it.

<?php
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: https://app.example.com');
header('Access-Control-Allow-Methods: GET, POST, QUERY, OPTIONS');
header('Access-Control-Allow-Headers: content-type');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'QUERY') {
    header('Allow: GET, POST, QUERY, OPTIONS');
    http_response_code(405);
    echo json_encode(['error' => 'Method Not Allowed']);
    exit;
}

$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (stripos($contentType, 'application/json') !== 0) {
    http_response_code(415);
    echo json_encode(['error' => 'Unsupported Media Type']);
    exit;
}

$rawBody = file_get_contents('php://input');
$query = json_decode($rawBody, true);

if (!is_array($query)) {
    http_response_code(400);
    echo json_encode(['error' => 'Invalid JSON']);
    exit;
}

// Run the DB search here. QUERY, so no state changes.
$result = [
    'method' => 'QUERY',
    'filter' => $query['filter'] ?? null,
    'items' => [],
];

echo json_encode($result, JSON_UNESCAPED_UNICODE);

In Node.js, plain http without a framework handles it the same way.
The steps: check the method, answer the CORS preflight, check Content-Type, parse the JSON body, run the read-only processing, respond.

import http from "node:http";

const server = http.createServer(async (req, res) => {
  res.setHeader("Access-Control-Allow-Origin", "https://app.example.com");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, QUERY, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "content-type");

  if (req.method === "OPTIONS") {
    res.writeHead(204);
    res.end();
    return;
  }

  if (req.method !== "QUERY") {
    res.writeHead(405, {
      "Content-Type": "application/json; charset=utf-8",
      "Allow": "GET, POST, QUERY, OPTIONS",
    });
    res.end(JSON.stringify({ error: "Method Not Allowed" }));
    return;
  }

  const contentType = req.headers["content-type"] ?? "";
  if (!contentType.startsWith("application/json")) {
    res.writeHead(415, { "Content-Type": "application/json; charset=utf-8" });
    res.end(JSON.stringify({ error: "Unsupported Media Type" }));
    return;
  }

  let rawBody = "";
  for await (const chunk of req) {
    rawBody += chunk;
  }

  let query;
  try {
    query = JSON.parse(rawBody);
  } catch {
    res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
    res.end(JSON.stringify({ error: "Invalid JSON" }));
    return;
  }

  // Run the DB search here. QUERY, so no state changes.
  const result = {
    method: "QUERY",
    filter: query.filter ?? null,
    items: [],
  };

  res.writeHead(200, {
    "Content-Type": "application/json; charset=utf-8",
    "Cache-Control": "max-age=60",
  });
  res.end(JSON.stringify(result));
});

server.listen(3000);

Frameworks like Express follow the same idea as long as the router can pick up arbitrary methods.
Some middleware and routers drop unknown HTTP methods by default, so check first whether app.all() catches it or whether custom methods can be registered explicitly.

Caveats before you use it

QUERY is standardized, but that does not mean it passes cleanly through every environment yet.
A new HTTP method can get dropped by an allow-list somewhere among load balancers, CDNs, WAFs, API gateways, frameworks, routers, CORS configs, and monitoring tools.

Cross-origin browser use puts QUERY under CORS preflight.
The OPTIONS response has to include Access-Control-Allow-Methods: QUERY or the request never leaves the browser.

OPTIONS /feed HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: QUERY
Access-Control-Request-Headers: content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, QUERY
Access-Control-Allow-Headers: content-type

Before relying on caching, check how the cache handles body-inclusive keys.
A CDN that does not understand QUERY may simply pass everything to the origin; worse, an implementation that caches by URL without reading the body will serve one query’s response to a different query.

For the server-side Content-Type check, RFC 10008 requires failing requests whose Content-Type is missing or inconsistent with the body.
If you accept application/json, parse it as JSON; if you use a custom DSL, define its media type and advertise it via Accept-Query.
Leave this vague and you have only moved the implicit rules of the POST /search era onto a new method.

URIs tend to persist in access logs, analytics, browser history, Referer headers, and bookmarks.
With QUERY, search conditions you do not want in a URL can move into the body.
But if an API gateway or application log stores request bodies, the conditions end up recorded anyway.

There are also cases where QUERY is the wrong choice.
Anything that changes state, launches jobs, triggers billing or sending, or where “executed exactly once” matters for auditing is not QUERY territory.
And for reads that fit in a URL and want to be shared, GET remains the right method.

References