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 });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. |
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. Your server must handle
OPTIONSaccordingly. - 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