HTTP Just Got a New Verb: Inside RFC 10008 and the QUERY Method
Published on Sep 2 hours ago · By FlipCode Team
For twenty years the web's read operations had to choose between a URL that breaks and a method that lies. In June 2026, the IETF finally closed the gap. Quick — how many HTTP methods can you name off the top of your head? Most developers land on the same five or six: GET, POST, PUT, DELETE, maybe PATCH, maybe OPTIONS if they've had to deal with CORS. That's basically been the entire vocabulary of the web for over two decades. New frameworks came and went, REST gave way to GraphQL and back again, JSON replaced XML as the default wire format — but the actual verbs used to talk to a server over HTTP have stayed frozen since the early 2000s. That changed in June 2026. The IETF's HTTP Working Group published RFC 10008: The HTTP QUERY Method, written by Julian Reschke, James M. Snell (Cloudflare), and Mike Bishop (Akamai). It defines a new method — literally named That's a big deal, and also a strange one, because on the surface QUERY doesn't sound like it does anything you couldn't already do. You could already send a body in a POST request. You could already run search endpoints. So why did it take an actual IETF standards process to formalize this? Once you dig into it, the answer says a lot about what's actually broken in how the web handles "read" operations — and it's been broken for a very long time. This article walks through what QUERY actually is, the exact gap it closes, what's genuinely new in the spec, and how to start using it in real code today — in JavaScript, Node/Express, and React — even while browser support is still catching up. Picture a fairly ordinary API. You've got a At that point, every API designer runs into the exact same wall, and reaches for one of two well-worn but flawed solutions. The first instinct is to keep it a GET, and stuff everything into the query string: This is the "correct" answer according to REST purism — GET is safe, it's idempotent, it's cacheable, the resource is fully identified by the URI. For simple filters, it works fine. But push it further — nested filter groups, nested boolean logic, nested arrays, a whole embedded GraphQL query — and this approach falls apart fast: So the second instinct — the pragmatic one almost every real-world API eventually settles on — is to just use POST: This works. It's flexible, the body can be as large and as structured as you want, and every framework and library handles it without complaint. But it comes at a cost that's easy to overlook until it bites you: POST is not safe, and it's not idempotent. Nothing in the protocol tells a cache, a proxy, a retry-on-failure HTTP client, or a browser's back button that this particular POST is actually harmless and repeatable. As far as the infrastructure is concerned, every POST might be charging a credit card or deleting a row, so none of it gets the automatic caching, safe retries, or prefetching that a "real" read operation deserves. You end up bolting on your own conventions — comments in the code, a line in the API docs — to say "trust me, this one's just a search." Infrastructure can't read comments. Not a missing feature — a missing promise. There was no method that meant "this carries a real body, and it's still just a read." RFC 10008 closes that gap directly. Here's the plainest way to describe it: QUERY is a request that carries a body like POST, but is guaranteed safe and idempotent — the same category as GET. Here's what it looks like on the wire: Look closely and it's almost indistinguishable from a POST request — same shape, same headers, same idea of sending a structured body. The difference is entirely at the protocol level: QUERY is formally registered with IANA as safe and idempotent, meaning any HTTP-aware piece of infrastructure — a cache, a CDN, a browser, a retrying client — can now know, without reading your API documentation, that this request is read-only and can be retried or cached without any risk of double-charging, double-deleting, or otherwise corrupting state. That single formal guarantee is the entire point of the spec. Everything else in RFC 10008 is really just working out the consequences of that guarantee. QUERY sits in IANA's HTTP Method Registry alongside GET, HEAD, and OPTIONS as safe and idempotent. That's not a documentation convention — it's the same formal classification that lets browsers safely prefetch a GET link or retry a failed GET without asking the user. QUERY inherits all of that. One underrated detail in the spec: a resource can support more than one query "language." Maybe A client can discover this ahead of time with a plain OPTIONS or HEAD request, before ever committing to a specific query format — genuinely useful for API clients, SDKs, and tooling that want to adapt to whatever a given server supports. Two response headers matter a lot here: This preserves one of the oldest principles in REST — anything meaningful deserves a URI — even for a query that's too complex to fit in one to begin with. Because QUERY is formally safe and idempotent, it gets full support for conditional requests: Here's the honest part of the story. The RFC is finished and published — Standards Track, final, not going to change. But the actual internet is still mid-migration, and that shows up in a few concrete ways: None of that means QUERY isn't usable today. It means today's usage looks like a bridge: real server-side support, paired with a client-side fallback that degrades gracefully until the rest of the stack catches up. Express doesn't ship an Advertise the capability so well-behaved clients can discover it ahead of time: Since native Using it is exactly as simple as a normal fetch call: And you can discover a server's supported query formats before committing to one: Wrapping the fetch helper in a small hook makes this genuinely pleasant to use from a component — especially for a "search with real filters" UI where a GET query string would already be getting unwieldy. Notice that everything QUERY-specific lives inside If you're weighing whether to start migrating, here's a realistic way to think about it: QUERY isn't CORS-safelisted, so cross-origin requests will trigger a preflight, same as PUT/PATCH/DELETE already do. And if you're assigning a It's easy to read all of this and think "fine, it's a POST that pinky-promises not to break anything" — but that undersells what actually changed. The interesting part isn't that you can now send a body on a safe request; you could always fake that with a POST and a comment in your API docs. The interesting part is that the promise is now legible to machines, not just to humans reading documentation. A cache doesn't read your API docs. A retrying HTTP client doesn't know your internal conventions. A CDN edge node has never seen your codebase. QUERY gives all of that infrastructure a way to know, from the method name alone, that a request is safe to retry, safe to cache, and safe to prefetch — the exact same guarantee GET has quietly provided since HTTP/1.0, now available for the messy, structured, oversized queries that GET was never built to carry. Twenty years is a long time to wait for one new verb. Given how much of the modern web runs on search-shaped POST requests pretending to be writes, it's a reasonable bet that QUERY becomes just as unremarkable and ubiquitous as GET and POST already are — it's just going to take the browsers, the frameworks, and the infrastructure a couple of years to get there.A Hole in HTTP That's Been There Since the Beginning
QUERY — and it's the first general-purpose addition to HTTP's method vocabulary in more than twenty years.The Problem: Every API Eventually Needs a Search Endpoint That Doesn't Fit Anywhere
/contacts resource. GET /contacts/42 fetches one contact. GET /contacts lists them all. Then, inevitably, someone on the product team asks for a search feature: filter contacts by name, by email domain, by tag, sorted a certain way, paginated, maybe with a few nested "OR this AND that" conditions thrown in.Attempt 1 — keep it a GET
GET /contacts?tag=customer&domain=example.com&sort=-created&limit=25
Attempt 2 — just use POST
POST /contacts/search HTTP/1.1
Content-Type: application/json
{ "tag": "customer", "domain": "example.com", "sort": "-created", "limit": 25 }Enter QUERY
QUERY /contacts HTTP/1.1
Host: example.org
Content-Type: application/x-www-form-urlencoded
Accept: application/json
select=surname,givenname,email&limit=10&match=%22email=*@example.*%22
HTTP/1.1 200 OK
Content-Type: application/json
[
{ "surname": "Smith", "givenname": "John", "email": "smith@example.org" },
{ "surname": "Jones", "givenname": "Sally", "email": "sally.jones@example.com" }
]What's Actually New, Mechanically
1 — A first-class citizen in the method registry
Method Safe Idempotent Has body GET yes yes undefined POST no no yes QUERY yes yes yes 2 — Accept-Query: a header for discovering what a server accepts
/contacts understands simple form-encoded filters and a more expressive SQL-like syntax and JSONPath. Servers can advertise all of them:HTTP/1.1 200 OK
Accept-Query: "application/x-www-form-urlencoded", application/sql, "application/jsonpath"3 — Query results can get their own URLs
Content-Location points to a URL for this exact result set — useful if you want to let someone share a link to "the search results I just got," even though the original request was a body-bearing QUERY, not a bookmarkable GET.Location, paired with a 303 See Other, points to a URL that will re-run the same query later — letting a server hand off from "run this expensive structured query once" to "serve it cheaply from cache via GET from now on."4 — Real caching, not just a promise of caching
ETag, If-None-Match, If-Modified-Since, 304 Not Modified — all of it, exactly as GET has always had. The one wrinkle is the cache key: since two QUERY requests to the same URL can carry completely different bodies (and therefore mean completely different things), a spec-compliant cache has to fold the request body — and relevant headers — into the cache key itself, not just the path. Get that wrong, and you open the door to cache poisoning, where one user's cached query result leaks to another user who asked something entirely different.The Catch Nobody's Pretending Isn't There
fetch() and XMLHttpRequest weren't built with this method in mind, and native support is expected to roll out gradually through 2026 and into 2027 as browser vendors catch up.Trying It Yourself: Server Side (Node.js / Express)
app.query() helper yet, but nothing stops you from handling the method manually:// server.js
import express from "express";
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Express normalizes methods internally, so we intercept at the
// raw request level to catch "QUERY" before routing kicks in
app.use((req, res, next) => {
if (req.method === "QUERY") {
return handleContactQuery(req, res);
}
next();
});
function handleContactQuery(req, res) {
const { select, limit, match } = req.body;
// Stand-in for a real data store lookup
const results = db.contacts.search({ select, limit, match });
const resultId = db.savedResults.store(results);
res
.status(200)
.set("Content-Type", "application/json")
.set("Content-Location", `/contacts/results/${resultId}`)
.json(results);
}
app.listen(3000, () => console.log("Listening on :3000"));app.options("/contacts", (req, res) => {
res.set("Allow", "GET, QUERY, OPTIONS, HEAD").sendStatus(200);
});
app.head("/contacts", (req, res) => {
res
.set("Accept-Query", '"application/x-www-form-urlencoded", application/sql')
.sendStatus(200);
});Trying It Yourself: Client Side (Plain JavaScript)
fetch() support for the literal QUERY verb is still rolling out across browsers, the practical pattern today is a thin wrapper: attempt QUERY, and fall back to a POST with an explicit override header if the environment doesn't cooperate.// httpQuery.js
export async function httpQuery(url, { body, headers = {}, ...rest } = {}) {
const requestInit = {
method: "QUERY",
headers: { "Content-Type": "application/x-www-form-urlencoded", ...headers },
body,
...rest,
};
try {
const res = await fetch(url, requestInit);
// Some intermediaries reject an unrecognized method outright
if (res.status === 405 || res.status === 501) {
throw new Error("QUERY not supported by this endpoint");
}
return res;
} catch {
// Transitional fallback while browser/infra support catches up
return fetch(url, {
...requestInit,
method: "POST",
headers: { ...requestInit.headers, "X-HTTP-Method-Override": "QUERY" },
});
}
}const res = await httpQuery("https://example.org/contacts", {
body: new URLSearchParams({
select: "surname,givenname,email",
limit: "10",
match: "email=*@example.*",
}),
headers: { Accept: "application/json" },
});
const contacts = await res.json();
console.log(contacts);async function discoverQueryFormats(url) {
const res = await fetch(url, { method: "HEAD" });
const acceptQuery = res.headers.get("Accept-Query");
return acceptQuery ? acceptQuery.split(",").map((s) => s.trim()) : [];
}Trying It Yourself: React
// useHttpQuery.js
import { useState, useCallback } from "react";
import { httpQuery } from "./httpQuery";
export function useHttpQuery(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const run = useCallback(
async (params) => {
setLoading(true);
setError(null);
try {
const res = await httpQuery(url, {
body: new URLSearchParams(params),
headers: { Accept: "application/json" },
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
setData(await res.json());
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
},
[url]
);
return { data, loading, error, run };
}// ContactSearch.jsx
import { useHttpQuery } from "./useHttpQuery";
export default function ContactSearch() {
const { data, loading, error, run } = useHttpQuery("https://example.org/contacts");
const handleSubmit = (e) => {
e.preventDefault();
const form = new FormData(e.target);
run({
select: "surname,givenname,email",
limit: form.get("limit") || "10",
match: form.get("match") || "",
});
};
return (
<div>
<form onSubmit={handleSubmit}>
<input name="match" placeholder="e.g. email=*@example.*" />
<input name="limit" type="number" placeholder="Limit" />
<button type="submit">Search</button>
</form>
{loading && <p>Loading…</p>}
{error && <p style={{ color: "red" }}>{error}</p>}
<ul>
{data?.map((c, i) => (
<li key={i}>
{c.givenname} {c.surname} — {c.email}
</li>
))}
</ul>
</div>
);
}httpQuery.js — the React component itself doesn't know or care that anything unusual is happening under the hood. That's deliberate. The same helper drops cleanly into Vue, Svelte, Angular, or plain vanilla JS without any changes, because the interesting part of this story is a protocol-level change, not a framework-level one.Should You Actually Adopt This Now?
POST /search, POST /reports, or GraphQL-style read endpoint that only ever reads data and never mutates anything. These are exactly the endpoints that have been quietly lying to your infrastructure by pretending to be writes.fetch() support. Build the POST-with-override fallback shown above as a deliberately temporary shim, and plan to delete it once browser support solidifies.Location or Content-Location URL to a query's results, don't undo the whole point of switching away from GET by encoding the sensitive parts of the query back into that URL.The Bigger Picture