# HTTP Has a New Method: Let's Try QUERY

```shell
QUERY /products/search HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "filters": {
    "minPrice": 50,
    "maxPrice": 300,
    "tags": ["workspace"],
    "inStock": true
  },
  "sort": { "field": "price", "direction": "asc" },
  "limit": 5
}
```

That first line is real! In June 2026, almost after a decade, the IETF published [RFC 10008, “The HTTP QUERY Method”](https://www.rfc-editor.org/rfc/rfc10008.html). HTTP now has a standard method for asking a resource a potentially complicated question without losing the fact that the operation is safe.

`QUERY` occupies a gap that API designers have worked around for years. `GET` is the natural choice for a read, but complex query input can be a poor fit for a URL. `POST` carries a body comfortably, but the method itself does not say, "This is only a read, and repeating it is safe." `QUERY` combines those missing properties: request content is expected, while the operation is explicitly safe and idempotent.

That sounds like "`GET` with a body," but the distinction is more useful than that phrase suggests. In this blog post, we will build a working `QUERY` endpoint with Fastify, call it from several clients, and then look at the harder question: what does it mean for the rest of the HTTP stack to support the method properly?

## Complex reads have never fit neatly between GET and POST

Imagine a product search with a price range, several tags, stock status, sorting, pagination, selected fields, and perhaps a nested expression built by a visual filter editor. A `GET` endpoint starts pleasantly enough:

```shell
GET /products?minPrice=50&maxPrice=300&inStock=true&sort=price
```

As the query grows, so does the encoding work:

```shell
GET /products?filter[price][gte]=50&filter[price][lte]=300&filter[tags][]=workspace&filter[stock][available]=true&sort[0][field]=price&sort[0][direction]=asc&fields[]=id&fields[]=name&fields[]=price&limit=5
```

There is no single universal maximum URL length that makes this fail at a predictable point. A request can cross browsers, SDKs, gateways, proxies, servers, logging systems, and security products with different limits and assumptions. Long URLs are also awkward to generate and inspect, and query values are more likely to appear in histories and routine URL logs.

So, why not put a body on `GET`? HTTP does not assign generally applicable semantics to `GET` request content. Some clients can send it, some frameworks will parse it, and some intermediaries will ignore or reject it. Every participant needs out-of-band knowledge of what the body means. That is a fragile basis for an API.

The common escape hatch for this sort of situation is `POST /products/search`. It works almost everywhere, accepts a body, and may be the right compatibility choice. The compromise is semantic. `POST` is not inherently destructive, but HTTP also does not promise that a `POST` operation is safe or idempotent. An intermediary cannot infer from the method alone that retrying it will not create another order, enqueue another job, or repeat some other effect.

`QUERY` makes that promise explicit.

## GET, POST, and QUERY make different promises

In HTTP, a **safe** method does not ask the target resource to change state. The server may still log the request, update metrics, allocate temporary resources, or do expensive work. Safety describes what the client requested, not an implementation, with zero side effects.

On the other hand, an **idempotent** method can be repeated without compounding the intended effect. However, it does not guarantee the same response bytes every time. Repeating a product query tomorrow may return different products because the catalog changed, but running the query again did not cause that change.

| Property | GET | QUERY | POST |
| --- | --- | --- | --- |
| **Primary intent** | Retrieve a representation identified by the URI | Run a query described by request content | Ask the target to process request content |
| **Safe** | Yes | Yes | Not guaranteed |
| **Idempotent** | Yes | Yes | Not guaranteed |
| **Request content** | No generally defined semantics | Expected; meaning comes from the target and media type | Expected; meaning comes from the target |
| **Caching** | Mature URI-based support | Cacheable, but the key must include content and relevant metadata | Possible under HTTP rules, but not the usual reusable-query path |
| **Best fit** | Compact, addressable, shareable reads | Complex reads with controlled clients and infrastructure | Actions, processing, or maximum ecosystem compatibility |

Additionally, with `QUERY`, the request body contains the payload, and the `Content-Type` which tells the server how to interpret it. If the header is missing or does not match the body, *RFC 10008* requires the server to reject the request. It also defines `Accept-Query`, a response header a resource can use to advertise the query types it understands.

## Build a QUERY endpoint with Fastify

[Fastify 5.11 added first-class RFC 10008 support](https://github.com/fastify/fastify/releases/tag/v5.11.0), including the `fastify.query()` route shorthand and request-content checks.

> Fastify 5 itself requires Node.js 20 or newer, but this demo needs a Node HTTP parser that recognizes `QUERY`. [Node added working QUERY support in 22.2.0](https://github.com/nodejs/node/issues/51562), so use Node 22.2 or newer here.

Create a project and install the current Fastify 5.x release:

```shell
mkdir query-demo
cd query-demo
npm init -y
npm install fastify@^5.12.0
```

Save this code as `server.mjs`:

```javascript
import Fastify from 'fastify'

const fastify = Fastify({ logger: true })

const products = [
  { id: 1, name: 'Oak Desk', price: 280, tags: ['workspace', 'wood'], inStock: true },
  { id: 2, name: 'Monitor Stand', price: 75, tags: ['workspace', 'metal'], inStock: true },
  { id: 3, name: 'Desk Lamp', price: 45, tags: ['workspace', 'lighting'], inStock: true },
  { id: 4, name: 'Mechanical Keyboard', price: 140, tags: ['workspace', 'keyboard'], inStock: false },
  { id: 5, name: 'Felt Desk Mat', price: 55, tags: ['workspace', 'textile'], inStock: true },
  { id: 6, name: 'Wall Shelf', price: 190, tags: ['storage', 'wood'], inStock: true }
]

fastify.get('/', async () => {
  return 'QUERY demo is running. Open DevTools and try the fetch example.'
})

const bodySchema = {
  type: 'object',
  required: ['filters', 'sort', 'limit'],
  additionalProperties: false,
  properties: {
    filters: {
      type: 'object',
      additionalProperties: false,
      properties: {
        minPrice: { type: 'number', minimum: 0 },
        maxPrice: { type: 'number', minimum: 0 },
        tags: { type: 'array', items: { type: 'string' } },
        inStock: { type: 'boolean' }
      }
    },
    sort: {
      type: 'object',
      required: ['field', 'direction'],
      additionalProperties: false,
      properties: {
        field: { type: 'string', enum: ['price', 'name'] },
        direction: { type: 'string', enum: ['asc', 'desc'] }
      }
    },
    limit: { type: 'integer', minimum: 1, maximum: 50 }
  }
}

fastify.query('/products/search', {
  schema: { body: bodySchema }
}, async (request, reply) => {
  const { filters, sort, limit } = request.body
  const direction = sort.direction === 'asc' ? 1 : -1

  const matches = products
    .filter(product => filters.minPrice === undefined || product.price >= filters.minPrice)
    .filter(product => filters.maxPrice === undefined || product.price <= filters.maxPrice)
    .filter(product => filters.inStock === undefined || product.inStock === filters.inStock)
    .filter(product => filters.tags === undefined ||
      filters.tags.every(tag => product.tags.includes(tag)))
    .sort((left, right) => {
      const a = left[sort.field]
      const b = right[sort.field]
      return (typeof a === 'string' ? a.localeCompare(b) : a - b) * direction
    })
    .slice(0, limit)

  reply.header('Accept-Query', 'application/json')
  return { count: matches.length, products: matches }
})

await fastify.listen({ port: 3000 })
```

> `QUERY` promises that the request will not change application state. Fastify does not enforce that promise; the handler must honor it. This example only filters the catalog, so it behaves as a safe query.

Start the server:

```shell
node server.mjs
```

## Send the request three ways

The server is running, so we can send the same query from various development tools.

### 1\. curl

curl’s `--request` option, shortened to `-X`, lets you choose a [custom method string](https://curl.se/docs/httpscripting.html). `-X QUERY` selects the method, while `--data-binary` supplies the content:

```bash
curl -i -X QUERY http://localhost:3000/products/search \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "filters": {
      "minPrice": 50,
      "maxPrice": 300,
      "tags": ["workspace"],
      "inStock": true
    },
    "sort": { "field": "price", "direction": "asc" },
    "limit": 5
  }'
```

### 2\. Browser `fetch()`

Browser `fetch()` accepts method tokens other than its small forbidden set, so `QUERY` can be sent programmatically.

Visit `http://localhost:3000/`, open DevTools, paste this into the Console, and press Enter:

```js
const query = {
  filters: {
    minPrice: 50,
    maxPrice: 300,
    tags: ['workspace'],
    inStock: true
  },
  sort: { field: 'price', direction: 'asc' },
  limit: 5
}

const response = await fetch('/products/search', {
  method: 'QUERY',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(query)
})

console.log(await response.json())
```

> Browser may initially block pasted code as a security precaution. After reviewing the snippet, manually type `allow pasting` and press Enter.

Once the query is processed successfully, the result appears in the Console. Navigate to the Network tab and look for a request named `search`; its Method column should show `QUERY`.

> Entering `http://localhost:3000/products/search` navigates with `GET`; it does not infer `QUERY` and cannot attach the JSON body. In this demo, that navigation reaches no matching `GET` route and returns a not-found response.

### 3\. Postman

Postman supports [custom HTTP methods](https://blog.postman.com/custom-http-methods-more-flexibility-and-autonomy/). So, you can configure the request like this:

```text
Method: QUERY
URL: http://localhost:3000/products/search
Header: Content-Type: application/json
Body: raw JSON
```

And use the same query as the request body:

```json
{
  "filters": {
    "minPrice": 50,
    "maxPrice": 300,
    "tags": ["workspace"],
    "inStock": true
  },
  "sort": { "field": "price", "direction": "asc" },
  "limit": 5
}
```

## Cross-origin QUERY requests will preflight

If a frontend at `http://localhost:5173` calls this API at `http://localhost:3000`, the browser sees a cross-origin request. Since `QUERY` is not one of the [CORS-safelisted methods](https://fetch.spec.whatwg.org/#cors-safelisted-method), the browser therefore sends an `OPTIONS` preflight without `QUERY`. Thus, it needs to be configured.

Install Fastify’s CORS plugin:

```shell
npm install @fastify/cors
```

Then import and register it before the routes:

```javascript
import cors from '@fastify/cors'

await fastify.register(cors, {
  origin: 'http://localhost:5173',
  methods: ['GET', 'QUERY', 'OPTIONS'],
  allowedHeaders: ['Content-Type'],
  exposedHeaders: ['Accept-Query']
})
```

Verify the `Access-Control-Allow-Methods`:

```bash
curl -i -X OPTIONS http://localhost:3000/products/search \
  -H 'Origin: http://localhost:5173' \
  -H 'Access-Control-Request-Method: QUERY' \
  -H 'Access-Control-Request-Headers: Content-Type'
```

The response should allow the origin, the `Content-Type` header, and the `QUERY` method.

## Accepting `QUERY` is not the same as supporting it

An HTTP method begins as a token in a request line or a `:method` pseudo-header. Many clients and servers let applications supply an arbitrary valid token. For example, older Fastify versions could be taught to parse the body with:

```js
fastify.addHttpMethod('QUERY', { hasBody: true })
```

That can be enough to move bytes between a client and a handler. However, the official RFC support has more layers:

1.  The client must send the method and content correctly.
    
2.  The server must recognize `QUERY`, require content metadata, parse the body, and route the request.
    
3.  The handler must honor the safe and idempotent contract.
    
4.  Gateways, WAFs, load balancers, and proxies must forward the method and body instead of rejecting or rewriting them.
    
5.  Retry logic must recognize the method as idempotent and be able to replay its content.
    
6.  A cache must include the request content and relevant metadata in its key, not just the URI.
    

## Current State

This is where adoption stands in August 2026: the method is registered, clients can often send it, and Fastify has first-class support, but the surrounding infrastructure is still catching up. For example, active implementation work in [Envoy](https://github.com/envoyproxy/envoy/pull/46496) and [nginx](https://github.com/nginx/nginx/pull/1488) separates basic method recognition from harder questions such as retry semantics and body-aware caching.

Also, a successful local request proves your two endpoints can talk. It says nothing about an API gateway, production WAF, CDN, SDK generator, tracing system, or cache you have not tested.

It will be interesting to see how quickly `QUERY` moves from early experimentation to dependable, end-to-end adoption.

## Should you use QUERY now?

Keep `GET` when the query is compact and its URI is useful. `GET` has unmatched support for links, bookmarks, shared URLs, ordinary caches, browser navigation, and crawlers. A new method is not an improvement when a familiar one already models the operation well.

Consider `QUERY` when the operation is genuinely safe and idempotent, the input is too structured or voluminous for a comfortable URI, and you control enough of the client-to-server path to test it. Internal APIs, experiments, and tightly managed systems are natural early candidates.

Keep `POST` as the pragmatic fallback when public clients, existing gateways, API description tools, generated SDKs, or organizational conventions cannot handle `QUERY` reliably. `POST /search` may communicate less through the method, but compatibility is a real design requirement. Documenting that the operation is read-only and defining retry behavior explicitly can be safer than deploying a precise method that part of the stack misunderstands.
