DEV Community

Poojan
Poojan

Posted on • Originally published at poojan.technokari.com on

I Have Idempotent Search Endpoints Doing POST. Should They Be QUERY?

TL;DR — In June 2026, HTTP got a new method: QUERY (RFC 10008). It's safe, idempotent, and cacheable like GET, but carries a request body like POST — exactly the shape of every "search with a big filter payload" endpoint you've ever been forced to build as a POST. It's real and standards-track. It is also barely deployable in a browser today (no fetch support, needs a CORS preflight, and plenty of proxies still reject unknown methods). My verdict: model your API around it now, keep POST on the wire until the ecosystem catches up.

The endpoint I've apologized for a hundred times

Every backend I've built has one endpoint that lies about what it is. On my multi-tenant food-tech platform it's the order search:

POST /orders/search
{
  "outletIds": [12, 44, 91, 156],
  "status": ["preparing", "out_for_delivery"],
  "dateRange": { "from": "2026-06-01", "to": "2026-06-30" },
  "customer": { "phoneSuffix": "8821" },
  "sort": [{ "field": "placedAt", "dir": "desc" }],
  "page": 3, "pageSize": 50
}

Enter fullscreen mode Exit fullscreen mode

That is a read. It changes nothing. Call it a hundred times, get the same answer a hundred times. Semantically it is a GET. But it's a POST, and it's a POST for one dumb, unavoidable reason: the filter doesn't fit in a URL. Try to encode that nesting into a query string and you get an unreadable ?outletIds[]=12&outletIds[]=44&status[]=...&dateRange[from]=... mess that flirts with URL length limits, has no agreed-upon syntax for arrays and nested objects, and leaks a customer's phone digits into every proxy log, browser history entry, and Referer header along the way.

So we all reach for POST. And the moment we do, we lie to the entire HTTP stack:

  • We tell caches "this is dangerous, never cache it" — even though it's the single most cacheable thing in the system. Our CDN and reverse proxy shrug and pass every identical search straight through to the database.
  • We tell every retry layer "do not automatically retry this" — because POST is defined as non-idempotent, meant for creating things. So a dropped response on a pure read can't be safely re-sent by middleware, even though re-sending is completely harmless.

This is the same theme I keep hitting: HTTP semantics are load-bearing, and when you pick the wrong verb, you silently switch off the infrastructure built to help you. (It's the mirror image of the payment webhook post — there the problem was making a write idempotent; here it's that a read has been mislabeled as a write.)

What QUERY actually is

RFC 10008, "The HTTP QUERY Method," was published as a Proposed Standard in June 2026. It defines exactly the method that gap has needed since the beginning of HTTP. In the spec's own words, it's for cases where the "data conveyed is too voluminous to be encoded in the request's URI."

Put my three verbs side by side and the hole QUERY fills is obvious:

Property GET POST QUERY
Safe (no state change)
Idempotent (retry-safe)
Cacheable ✗ (effectively no)
Carries a request body

QUERY is safe (the client isn't asking to change anything), idempotent (it "can be automatically repeated or restarted without concern for partial state changes" — so retry layers are free to retry it), and cacheable. The POST /orders/search above becomes an honest QUERY /orders, with the same JSON body, and every layer of the stack now understands what it's looking at.

The genuinely new trick: a cache key that includes the body

This is the part I find most interesting, and it's the reason QUERY isn't just "GET with a body" cosplay.

For a GET, the cache key is the URL. Simple. But a QUERY's meaningful input is in the body, so RFC 10008 says the cache key MUST incorporate the request content and its metadata. A reverse proxy or CDN looks at the Content-Type and the body bytes together and can serve a cached response for two identical queries.

The spec even anticipates the obvious objection — "but {"a":1,"b":2} and {"b":2,"a":1} are the same query with different bytes." Caches are permitted to normalize away "semantically insignificant differences" (formatting, key order, content encoding) when computing the key, without altering the request itself.

That's a real capability GET-with-body never had and POST fundamentally can't have. My identical dashboard searches — same filter, fired by twenty operators every morning at the lunch-rush ramp-up — could be served from the edge instead of hammering Postgres. (I've written before about what that read load does to a database under peak; pushing repeat reads to a cache is precisely the pressure valve.)

So why haven't I migrated?

Because "standardized" and "deployable" are two very different dates, and in July 2026 the gap between them is wide. Here's the honest state of the ecosystem, and it's the part the explainer posts skip:

Browsers can't send it. There is no QUERY support in fetch() or XMLHttpRequest today. MDN has no page for it, there's no caniuse entry. If your caller is a web frontend, you literally cannot issue a QUERY from the browser right now. That alone rules it out for most user-facing traffic.

It needs a CORS preflight. QUERY is not on the CORS-safelist, so any cross-origin call triggers an OPTIONS preflight round-trip. Not fatal, but it's latency and server config you don't pay for with a plain GET.

The middle of the network is hostile to unknown methods. Plenty of proxies, WAFs, API gateways, and legacy load balancers still reject or mangle any method they don't recognize. Your request has to survive every hop between client and origin, and some of those hops were configured in 2015.

Framework support is young and uneven. It exists — .NET 10 shipped client and server support, and various gateways that already allow custom methods can pass it through — but this is early-adopter territory, not "npm install and go."

You lose shareable URLs. A filtered view expressed as a QUERY body can't be bookmarked, pasted into Slack, or deep-linked the way ?status=preparing can. For some search UIs that's a real downgrade.

What I'm actually doing about it

Not a rewrite. A cheap bit of future-proofing that costs almost nothing and pays off the day the browser gap closes:

1. Model the endpoint as a query, whatever verb carries it. I'm making sure my search handler is a pure, side-effect-free function of its request body — no logging that mutates, no "record this search" write sneaking into the read path. If it's genuinely safe and idempotent in behavior, flipping the wire method later is a one-line routing change, not a redesign. Same discipline as isolating order creation into one pure service function: keep the semantics clean and the transport swappable.

2. Keep POST on the wire for now , but treat that as a deployment detail, not the design. Where I control both ends (server-to-server, internal services, a CLI, my own SDK) I can experiment with real QUERY today, because there's no browser and often no hostile proxy in the path — that's where adoption will actually start.

3. Set up cache keys to include the body deliberately , so that when I do switch, the caching semantics RFC 10008 describes are something I've already reasoned about rather than a surprise.

The Kreya team put the pragmatic rule best in their write-up: if there's no immediate need to change an endpoint, leave it be. QUERY isn't a migration you rush. It's a gap in HTTP that's finally being filled, and the right move is to stop fighting the mismatch — stop pretending your reads are writes — so that adopting the correct method later is trivial.

The one idea to take away

For years we've been shipping reads disguised as writes because HTTP gave us no honest option for "safe request, big body." QUERY is that option. You can't put it on browser traffic yet — but you can stop building your search endpoints in a way that assumes the lie is permanent. Keep the behavior genuinely safe and idempotent, keep the body-as-input clean, and the day fetch learns the verb, your migration is a routing table edit instead of a rewrite.

Top comments (0)