A tiny, dependency-free client for the HTTP QUERY method (RFC 10008) — the request that is safe and idempotent like GET, but carries a body like POST, and caches like neither before it could.
Built on native fetch. Works in Node 18+, Deno, Bun, Cloudflare Workers, and the browser.
import { query } from "@danmat/query-fetch";
const res = await query("https://api.example.com/search", {
json: { filter: { status: "active" }, sort: "-createdAt", limit: 50 },
});For years you had two bad options for a search endpoint:
GETwith a query string — safe, idempotent, cacheable… but your filter blows past URL length limits and leaks into logs.POSTwith a body — room for a rich query… but it's neither safe, idempotent, nor cacheable, so proxies and clients treat it as a state change.
QUERY is the missing third option: a body-carrying request that intermediaries may cache and clients may safely retry. This library handles the sharp edges the spec introduces.
npm install @danmat/query-fetchScripted fetch(url, { method: "QUERY", body }) already works in modern runtimes — but the semantics of RFC 10008 are on you. This library covers them:
- ✅ Enforces
Content-Type— the RFC requires servers to reject a QUERY whose body has no content type. We throw before the round-trip instead of letting you debug a400. - ✅ Transparent
POSTfallback — servers that don't understand QUERY yet respond405/501; we automatically retry asPOSTand advertise the original method viaX-HTTP-Method-Overrideso override-aware backends still route it correctly. - ✅
Acceptnegotiation — pass a media type (or list) to negotiate the response format the RFC'sAccept-Querydance is built around. - ✅ Safe automatic retry (opt-in) — QUERY is idempotent by definition, so retrying transient failures is safe here in a way it never is for
POST. Exponential backoff + jitter, honoursRetry-After. - ✅ Redirect-safe — the RFC's
303 See Otherindirect-result pattern is handled byfetch's own redirect following; nothing surprising here. - ✅ Zero dependencies, fully typed, tree-shakeable, dual ESM/CJS.
import { queryJson } from "@danmat/query-fetch";
const { data, response } = await queryJson<{ total: number }>(
"https://api.example.com/search",
{ json: { q: "http query method" } },
);
console.log(data.total, response.headers.get("age"));queryJson sets Accept: application/json, throws on a non-2xx status, and returns the parsed body alongside the raw Response.
await query("https://api.example.com/search", {
body: "SELECT * WHERE status = 'active'",
contentType: "application/sql",
accept: "application/json",
});RFC 10008 defines QUERY as safe and idempotent — so unlike POST, retrying a
failed request can't cause a double-effect. Opt in with a count, or an object
for full control:
// Retry up to 3 times with exponential backoff + jitter.
await query(url, { json, retry: 3 });
// Full control.
await query(url, {
json,
retry: {
retries: 5,
minDelay: 200, // base backoff (ms)
maxDelay: 10_000,
factor: 2,
jitter: true,
respectRetryAfter: true, // honour Retry-After on 429/503
retryOn: ({ response, error }) =>
Boolean(error) || (response?.status ?? 0) >= 500,
onRetry: ({ attempt, delay }) => console.warn(`retry #${attempt} in ${delay}ms`),
},
});By default it retries network errors and 408/425/429/500/502/503/504, and
does not retry aborts. Retry is off unless you set it. (Retries reuse a
buffered body — a string, bytes, or json; a streaming body is sent once.)
await query(url, { json, fallbackToPost: false });The automatic fallback only triggers when the server answers 405/501. Some
origins reject QUERY before that — a legacy proxy, or a cross-origin server whose
CORS allows POST but not QUERY yet — so the QUERY throws (or its preflight fails)
with no status to react to. When you already know an origin is like that, skip the
doomed QUERY and POST with the override from the start:
await query(url, { json, transport: "post-override" });This is a deliberate, per-call opt-in. The library never switches to it on its own, because a POST is treated as unsafe and uncacheable by intermediaries unless the server honors the override — so the choice stays yours.
import { fetch as undiciFetch } from "undici";
await query(url, { json, fetch: undiciFetch });Performs a QUERY request. options extends RequestInit (so signal, credentials, redirect, etc. all work), minus method and with a richer body:
| Option | Type | Default | Description |
|---|---|---|---|
body |
BodyInit | null |
— | Raw query body. Pair with contentType. |
json |
unknown |
— | Value serialized to JSON; sets application/json. |
contentType |
string |
— | MIME type of body. Required when a body is present. |
accept |
string | string[] |
— | Sets the Accept header. |
fallbackToPost |
boolean |
true |
Retry as POST on 405/501. |
methodOverrideHeader |
string | false |
"X-HTTP-Method-Override" |
Header advertising the original method on fallback. |
transport |
"query" | "post-override" |
"query" |
"post-override" sends POST + override from the start, for an origin you already know rejects QUERY. |
retry |
number | RetryOptions |
off | Auto-retry transient failures (safe: QUERY is idempotent). |
fetch |
typeof fetch |
globalThis.fetch |
Custom fetch implementation. |
query + JSON parsing + a non-2xx guard.
Thrown for construction-time problems (a body without a content type, no available fetch) and non-2xx responses in queryJson.
QUERY is a Proposed Standard (June 2026). Two things to know:
- CORS: QUERY is not a CORS-safelisted method, so a cross-origin QUERY triggers a preflight, and the browser blocks it unless the server returns
QUERYinAccess-Control-Allow-Methods. When it doesn't,fetchthrows with no status, so the automatic405/501fallback can't see it. For an origin you know is in that state, passtransport: "post-override"to POST from the start — provided that server allowsPOST(and the override header) via CORS. There is no client-only way around a server that CORS-blocks both. - Spec churn: browser-integration details (method normalization, caching) are still being ironed out in whatwg/fetch#1938. This library tracks runtime behavior as it ships.
@danmat/query-fetch— client for the QUERY method (you are here).@danmat/accept-query— parse/build/negotiate theAccept-Queryheader.@danmat/query-cache— body-aware response caching.@danmat/query-server— server-side request validation & negotiation.
MIT © Dan Matthew