The new HTTP QUERY method is here (RFC 10008, IETF, June 2026) — but there's no easy, safe way to use it in Node.
http-queryable gives you QUERY endpoints and correct body-aware caching in a few lines — for Express, Fastify, raw http, and an isomorphic browser/Node client.
Same-meaning body (re-spaced) → cache HIT and the handler doesn't re-run; a different body → the correct different result, never a stale hit.
🆕 New to the QUERY method? Understand it in 2 minutes
The problem QUERY solves. For decades, a "read with a big input" forced a bad choice:
| Method | Body? | Safe & idempotent? | Cacheable? |
|---|---|---|---|
GET |
❌ no (cram it into the URL, hit length limits) | ✅ yes | ✅ yes |
POST |
✅ yes | ❌ no (can't safely retry; caches treat it as a write) | ❌ no |
QUERY |
✅ yes | ✅ yes | ✅ yes |
QUERY is "GET with a body" — a body like POST, but the semantics of GET: safe (no side effects), idempotent (retryable), cacheable. It's built for search / filter / graph-style reads that don't fit in a URL, without abusing POST.
QUERY /search HTTP/1.1
Content-Type: application/json
{ "q": "cats", "filters": { "color": "black" } }Why caching it is the hard part. Shared HTTP caches key on method + URL. With QUERY, many different bodies hit the same URL:
QUERY /search {"q":"cats"} → cats results
QUERY /search {"q":"dogs"} → dogs results ← same URL!
A method+URL cache would happily serve the cats response to the dogs request. That's a correctness/security bug — RFC 10008 §2.7 requires the request body to be part of the cache key. http-queryable does that correctly and conservatively: it only treats bodies as equal when it can prove they mean the same thing.
The golden rule: a false cache miss is harmless (you recompute); a false cache hit (two different bodies sharing a key) is a bug — so when in doubt, it caches less.
📖 Want the full rationale and the tricky edge cases? Read docs/why-query.md (5-min deep dive).
npm install http-queryable expressimport express from "express";
import { queryable, QueryCache } from "http-queryable/express";
const app = express();
app.use(queryable({ cache: new QueryCache() })); // QUERY + safe caching
app.query("/search", (req, res) => res.json(search(req.body)));
app.listen(3000);Hit it with a body — QUERY is "GET with a body":
curl -X QUERY localhost:3000/search \
-H 'content-type: application/json' \
-d '{"q":"cats"}'The only QUERY library that caches correctly — a different request body never gets another body's cached response.
Shared HTTP caches key on method + URL only. With QUERY, many different bodies hit the same URL, so a naive cache can serve the wrong result. RFC 10008 §2.7 requires the request body to be part of the cache key. http-queryable does exactly that — and does it conservatively, so it never produces a false hit.
curl -X QUERY /search -d '{"q":"cats"}' # → X-Query-Cache: MISS → cats result
curl -X QUERY /search -d '{ "q" : "cats" }' # → X-Query-Cache: HIT (same meaning, re-spaced)
curl -X QUERY /search -d '{"q":"dogs"}' # → X-Query-Cache: MISS → dogs result (NOT stale cats)The second call is a hit because the body means the same thing (key order and whitespace don't matter). The third is a miss with the correct result because the body is genuinely different. Even the existing Fastify QUERY plugin punts on this and tells you to keep responses private — we solve it.
- GET-safe + POST-body + cacheable. Safe, idempotent reads that carry a real request body — no more cramming complex queries into the URL.
- It's a standard. RFC 10008 (June 2026) defines the method,
Accept-Querynegotiation, and that the cache key includes the body. - Kills real pain. No more URL-length limits on big search/filter payloads,
and no more abusing
POSTfor reads (which breaks caching and idempotency).
| Target | Support |
|---|---|
| Express (4 & 5) | ✓ app.query() + safe caching, CORS preflight, helpers |
| Fastify (≥5) | ✓ plugin reusing the same caching core |
raw node:http |
✓ createQueryServer / createQueryListener |
| Browser + Node client | ✓ isomorphic query(url, body, opts) |
Node >= 22. QUERY must be accepted by the runtime's HTTP parser; Node lists
QUERY in http.METHODS starting at v22. http-queryable detects this at
startup and fails with an actionable error on older Node.
import { query } from "http-queryable/client";
const { response } = await query("https://api.example.com/search", { q: "cats" });
const results = await response.json();- Sets
Content-Typeand serializes the body (JSON by default). - Optional
Accept-Querydiscovery viaOPTIONS({ discover: true }). - Optional follow of
Content-Location/LocationtoGETthe canonical result ({ followResult: true }), per RFC 9110 §10.2.2. - Safe auto-retry with backoff — sound because QUERY is idempotent (RFC 10008 §2).
import Fastify from "fastify";
import { fastifyQueryable, QueryCache } from "http-queryable/fastify";
const app = Fastify();
await app.register(fastifyQueryable, { cache: new QueryCache() });
app.route({
method: "QUERY",
url: "/search",
handler: (req, reply) => reply.send(search(req.body)),
});import { createQueryServer, QueryCache } from "http-queryable/http";
createQueryServer(
(req, res, ctx) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify(search(ctx.body)));
},
{ cache: new QueryCache() },
).listen(3000);The whole safety argument rests on one asymmetry:
- A false miss (two equal bodies get different keys) is harmless — you recompute.
- A false hit (two different bodies share a key) is a correctness/security bug — one client sees another's result. RFC 10008 Security Considerations flags exactly this.
So every normalization we apply is provably meaning-preserving, and when in doubt we normalize less:
| Content-Type | What we normalize | What we never do |
|---|---|---|
application/json, *+json |
insignificant whitespace, object key order, string-escape form | merge numeric literals (1000 ≠ 1e3); collapse big ints that lose precision; guess on duplicate keys (→ opaque) |
application/x-www-form-urlencoded |
percent-encoding case, +/%20 |
reorder pairs |
| anything else (text, xml, octet-stream) | nothing — treated as opaque | cache by default (opt in with cacheOpaqueBodies, still exact-byte only) |
Keys are length-prefixed, domain-separated, and SHA-256 hashed so no
concatenation of URL/type/body can forge a collision. The engine also honors
Cache-Control (no-store/private/max-age), cacheable status codes
(RFC 9111), and ETag/If-None-Match conditional requests.
import { advertiseAcceptQuery, negotiateQueryType } from "http-queryable";
res.setHeader("Accept-Query", advertiseAcceptQuery(["application/json", "application/sql"]));
const chosen = negotiateQueryType(req.headers["accept-query"], ["application/json"]);- Core (
http-queryable):deriveCacheKey,normalizeBody,canonicalizeJson,validateQueryRequest,advertiseAcceptQuery,parseAcceptQuery,negotiateQueryType,QueryCache,MemoryStore,CacheStore,isQueryMethodSupported,assertQueryMethodSupported. /express:queryable(),ensureQueryMethod,sendAcceptQuery,contentLocation,rejectInvalidQuery./fastify:fastifyQueryableplugin (+reply.acceptQuery,reply.contentLocation)./http:createQueryServer,createQueryListener./client:query,discoverAcceptQuery.
class RedisStore {
/* get/set/delete/clear */
}
new QueryCache({ store: new RedisStore() });http-queryable ships an in-memory LRU + the CacheStore interface — plug in
Redis/Memcached/etc. We deliberately don't ship a cache backend; the value is the
correct body-aware key.
- Edge/CDN pass-through guidance and adapters.
- GraphQL-over-QUERY integration.
- Additional structured body types (XML/CBOR) with conservative canonicalization.
Contributions are welcome — bug fixes, adapters, and conservative new body normalizers especially. Please read:
- CONTRIBUTING.md — dev setup, evals-first testing, Conventional Commits, branch naming, and the DCO sign-off.
- CODE_OF_CONDUCT.md — Contributor Covenant 2.1.
- SECURITY.md — report vulnerabilities privately, never in a public issue. Cache-key collisions are treated as security bugs.
Bugs and features go through the issue forms. See the changelog for release history.
MIT © http-queryable contributors
