Node.js
Node.js recognizes QUERY natively since version 21.7.2 (via the llhttp 9.2 update). In practice, treat Node 22 LTS as your reliable baseline. req.method arrives as 'QUERY' — no flags, no monkey-patching.
Here’s everything from http core to Fastify, including Express and the Fetch API.
Node.js http core — Server
Section titled “Node.js http core — Server”Minimal server that accepts QUERY /search with a JSON body. Body handling is identical to POST: collect chunks, concatenate, parse.
import { createServer } from 'node:http';
const server = createServer((req, res) => { if (req.method === 'QUERY' && req.url === '/search') { // RFC 10008: Content-Type is mandatory for QUERY if (!req.headers['content-type']) { res.writeHead(415, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Content-Type required' })); return; }
let body = ''; req.on('data', (chunk) => { body += chunk; });
req.on('end', () => { let parsed; try { parsed = JSON.parse(body); } catch { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid JSON' })); return; }
// Simulate a search based on received filters const result = { filters: parsed.filter, limit: parsed.limit ?? 10, items: [ { id: 1, name: 'Mechanical keyboard', price: 89 }, { id: 2, name: 'Ergonomic mouse', price: 65 }, ], };
// QUERY is safe + idempotent → caching is valid res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60', }); res.end(JSON.stringify(result)); }); } else { res.writeHead(405, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); }});
server.listen(3000, () => { console.log('Server running at http://localhost:3000');});Expected output (curl response)
Section titled “Expected output (curl response)”{ "filters": { "category": "peripherals", "inStock": true }, "limit": 5, "items": [ { "id": 1, "name": "Mechanical keyboard", "price": 89 }, { "id": 2, "name": "Ergonomic mouse", "price": 65 } ]}Node.js http core — Client
Section titled “Node.js http core — Client”Using http.request with method: 'QUERY' works out of the box — the parser doesn’t block custom methods.
import { request } from 'node:http';
const body = JSON.stringify({ filter: { category: 'peripherals', inStock: true }, limit: 5,});
const req = request( { hostname: 'localhost', port: 3000, path: '/search', method: 'QUERY', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), }, }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { console.log(`Status: ${res.statusCode}`); console.log(JSON.parse(data)); }); });
req.write(body);req.end();Expected output
Section titled “Expected output”Status: 200{ filters: { category: 'peripherals', inStock: true }, limit: 5, items: [ { id: 1, name: 'Mechanical keyboard', price: 89 }, { id: 2, name: 'Ergonomic mouse', price: 65 } ]}Express
Section titled “Express”Express doesn’t have app.query() — that name is already taken for reading URL query string params. The workaround is app.all() with a method check, or a helper to avoid repeating the boilerplate.
import express from 'express';
const app = express();app.use(express.json());
// Helper: register a route exclusively for QUERYfunction queryRoute(app, path, handler) { app.all(path, (req, res, next) => { if (req.method !== 'QUERY') return next(); handler(req, res, next); });}
// QUERY /search routequeryRoute(app, '/search', (req, res) => { // RFC 10008 requires Content-Type if (!req.headers['content-type']) { return res.status(415).json({ error: 'Content-Type required' }); }
const { filter, limit = 10, offset = 0 } = req.body;
const result = { filter, limit, offset, items: [ { id: 1, name: 'Laptop Pro', price: 1299 }, { id: 2, name: '4K Monitor', price: 799 }, ], };
// Cache is valid — QUERY is cacheable res.set('Cache-Control', 'max-age=60'); res.json(result);});
// Fallback for unsupported methodsapp.use((req, res) => { res.status(405).json({ error: 'Method Not Allowed' });});
app.listen(3000, () => { console.log('Express running at http://localhost:3000');});Why app.all() and not app.use()
Section titled “Why app.all() and not app.use()”app.all() matches a specific route (/search) for any HTTP method. app.use() would also work, but without exact path matching — you’d have to check req.url manually.
CORS: QUERY is not on the safelist (GET/HEAD/POST), so cross-origin requests will trigger a preflight. Add
QUERYtoAccess-Control-Allow-Methodsin your CORS middleware.
Fastify (addHttpMethod)
Section titled “Fastify (addHttpMethod)”Fastify supports GET, HEAD, TRACE, DELETE, OPTIONS, PATCH, PUT, and POST by default. For extra methods, use addHttpMethod — it registers the method and creates the route shorthand automatically.
import Fastify from 'fastify';
const app = Fastify({ logger: true });
// Register QUERY as a method that accepts a bodyapp.addHttpMethod('QUERY', { hasBody: true });
// Now app.query() exists as a shorthandapp.query('/search', { schema: { body: { type: 'object', properties: { filter: { type: 'object' }, limit: { type: 'integer', default: 10 }, offset: { type: 'integer', default: 0 }, }, }, response: { 200: { type: 'object', properties: { filter: { type: 'object' }, limit: { type: 'integer' }, offset: { type: 'integer' }, items: { type: 'array' }, }, }, }, }, handler: async (request, reply) => { const { filter, limit, offset } = request.body;
const result = { filter, limit, offset, items: [ { id: 1, name: '1TB SSD', price: 119 }, { id: 2, name: '32GB RAM', price: 189 }, ], };
// Cache headers — QUERY allows caching reply.header('Cache-Control', 'max-age=60'); return result; },});
app.listen({ port: 3000 });How addHttpMethod works
Section titled “How addHttpMethod works”app.addHttpMethod('QUERY', { hasBody: true })— registers the method in the internal router (find-my-way) and enables body parsing.- After that,
app.query(path, opts)works likeapp.get()orapp.post(). - Schema validation and serialization work normally — Fastify treats QUERY as a first-class citizen.
Tip: call
addHttpMethodbefore registering routes. If you call it after, the shorthand won’t exist yet.
Fetch API (Node 22+)
Section titled “Fetch API (Node 22+)”fetch accepts any string as a method — QUERY is not a “forbidden method” (only CONNECT, TRACE, and TRACK are forbidden per the Fetch Standard).
const response = await fetch('http://localhost:3000/search', { method: 'QUERY', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ filter: { category: 'peripherals', maxPrice: 500 }, limit: 20, offset: 0, }),});
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`);}
const data = await response.json();console.log(data);Expected output
Section titled “Expected output”{ "filter": { "category": "peripherals", "maxPrice": 500 }, "limit": 20, "offset": 0, "items": [ { "id": 1, "name": "1TB SSD", "price": 119 }, { "id": 2, "name": "32GB RAM", "price": 189 } ]}Notes on fetch + QUERY
Section titled “Notes on fetch + QUERY”- Duplex streams: if you need to send large bodies as a stream, pass
duplex: 'half'in the fetch options. - Automatic retry: since QUERY is idempotent, clients can retry on network failure with no risk of side effects.
- Axios: no
.query()shorthand exists. Useaxios({ method: 'QUERY', url, headers, data }).
Testing with curl
Section titled “Testing with curl”curl accepts any string in -X. The body goes in -d (or --data).
Basic request
Section titled “Basic request”curl -X QUERY http://localhost:3000/search \ -H "Content-Type: application/json" \ -d '{"filter": {"category": "peripherals"}, "limit": 5}'With visible response headers
Section titled “With visible response headers”curl -i -X QUERY http://localhost:3000/search \ -H "Content-Type: application/json" \ -d '{"filter": {"inStock": true}, "limit": 10}'Expected output
Section titled “Expected output”HTTP/1.1 200 OKContent-Type: application/jsonCache-Control: max-age=60Content-Length: 137
{"filters":{"inStock":true},"limit":10,"items":[{"id":1,"name":"Mechanical keyboard","price":89},{"id":2,"name":"Ergonomic mouse","price":65}]}Testing error without Content-Type
Section titled “Testing error without Content-Type”curl -i -X QUERY http://localhost:3000/search \ -d '{"filter": {}}'HTTP/1.1 415 Unsupported Media TypeContent-Type: application/json
{"error":"Content-Type required"}Verbose mode for debugging
Section titled “Verbose mode for debugging”curl -v -X QUERY http://localhost:3000/search \ -H "Content-Type: application/json" \ -d '{"filter": {"active": true}}'This shows the request line > QUERY /search HTTP/1.1 — confirming the method is being sent correctly.
Minimum versions
Section titled “Minimum versions”| Runtime/Framework | Min version | Notes |
|---|---|---|
| Node.js | 22 LTS | Parser recognizes QUERY natively |
| Express | 4.x / 5.x | Works via app.all(), no shorthand |
| Fastify | 5.x | addHttpMethod('QUERY', { hasBody: true }) |
| fetch (Node) | 22+ | Works directly — QUERY is not a forbidden method |
| curl | any | -X QUERY accepts any method string |
Common pitfalls
Section titled “Common pitfalls”- Forgetting Content-Type — RFC 10008 requires it. Without it, the server should reject with 415.
- Cache key — QUERY is cacheable, but the cache must include the body in its key. Most CDNs don’t do this automatically yet.
- CORS preflight — QUERY triggers an OPTIONS preflight on cross-origin requests. Configure
Access-Control-Allow-Methodson the server. - Node < 21.7.2 — the parser doesn’t recognize the method and rejects the connection. No workaround — upgrade Node.