25
Backend
04
Security
08
iOS
07
Infra

Bound GraphQL request cost at input size instead of per-field query complexity

ADR-0079 ACCEPTED · 2026-08-23
Bound GraphQL request cost at input size instead of per-field query complexity

Decision

Bound request cost where data enters the system. Every router that accepts a request body applies one shared body-size limit. Each bound on a free-text or list input is a single constant, and every transport that accepts the input enforces it. The transports may enforce it differently: the GraphQL API rejects an over-long summary so the person typing it can fix it, and the MCP server truncates one composed by a program. A test that reads the stored modification log checks the bound whichever transport wrote the row.

Treat an operation whose cost grows faster than linearly with the data it reads as a defect to fix. A price on a quadratic operation hides it, and no bound stays right as the data grows.

Keep async-graphql's overall complexity ceiling and depth limit as a backstop against pathological documents. Price a query by its shape only where the shape is the cost, such as a document that aliases many credential checks into one unauthenticated request.

Why

A GraphQL server can limit cost in two places. It can price the query with per-field charges under a total ceiling, or it can bound the inputs that decide how much stored data a query touches. Per-field pricing was proposed for the expensive authenticated resolvers, because the existing budget counts fields and ignores their cost.

Measurement against seeded trips showed that the latency of the heaviest query the app sends barely moved with the amount of data it returned; a fixed per-request floor dominated. Every expensive request we had actually observed was an ordinary query over oversized stored data. One huge note made every read of its trip quadratic, and no charge on the reading query would have caught it, because the query's shape was unremarkable.

Rejected alternatives

  • Per-field complexity charges on the expensive authenticated resolvers. A charge is fixed before the request touches any data, so it can't see the cases that have been expensive here, and once real data outgrows the numbers it rejects legitimate queries.
  • One enforcement point shared by every transport. A person typing past a limit should be told and a program composing past it is better truncated. A single enforcement point would make one of them worse to make the architecture tidier.

Consequences

Until a field is priced, a query that is expensive because of its shape and not its data is caught only by the coarse ceiling and depth limit.

A bound that is too tight refuses a real user's long note, so each bound sits next to the domain rule that justifies it, and changing one is a product decision.

The stored-result test checks only the transports it drives, so a new transport that accepts one of these inputs needs adding to it before its bound is verified.