Skip to main content

Minimum requirements

Overview

Cost Control prevents a single GraphQL request from using too many system resources and slowing everything else down. A query requesting deeply nested lists can generate thousands of resolver calls and overwhelm subgraphs, while a simple field lookup costs almost nothing. Cost Control allows assigning weights to fields and estimating query complexity before execution begins. When Cost Control is enabled, the router calculates and records costs for each incoming operation. In enforce mode, operations exceeding the configured limit are rejected immediately, and no subgraph requests are made, protecting the infrastructure from resource exhaustion. The Cosmo Router implements the IBM GraphQL Cost Directive Specification, adapted for GraphQL Federation.

Key differences from the IBM specification

Weights use plain integers instead of stringified floats, as defined in the IBM spec. The IBM specification does not account for federation. Static cost is calculated based on the query plan, which uses a federation of subgraphs to create a chain of fetches. This chain is invisible at the supergraph level and may include entity calls, @requires fetches, and similar operations. These are accounted for because they make certain queries more expensive. When a field returns a list of objects with a type weight, the IBM spec does not multiply the type’s own weight by the list size — only the children’s costs are multiplied. This implementation multiplies both the type weight and children’s costs by the list size, resulting in higher cost estimates for list fields with weighted types. This is done because federation may trigger entity fetches between subgraphs. The router supports both static (estimated) and runtime (actual) cost measurements. Estimated cost is calculated before the execution, based on directive weights and list size estimates. Actual cost is calculated after the execution, using real list sizes returned by subgraphs.

How Cost is Calculated

The router walks through the query and sums the cost of each field. By default, object types (including interfaces and unions) cost 1, scalar and enum fields cost 0. For example, this query has a cost of 4:
The router also accounts for weights assigned to the same field coordinate in different subgraphs, allowing specific resolvers to be weighted differently per subgraph.

List Fields Multiply Cost

When a field returns a list, the cost of that field and all its children is multiplied by the expected list size. Since the router cannot determine actual list sizes during planning, it uses estimates.
With a default list size of 10, this query costs: 10 × (1 Employee + 1 Department) = 20

Configuration

Cost Control is enabled in the router configuration:

Customizing Cost with Directives

Default weights work for many APIs, but the @cost and @listSize directives allow fine-tuning.

The @cost Directive

@cost assigns custom weights to types, fields or arguments that are more expensive than average. When specified on a type — all fields returning this type inherit the weight:
When specified on a field — only that field carries the weight:
When specified on an argument — adds cost for expensive argument processing:
When specified on input object fields — weights on nested input fields are accumulated based on which fields the client provides in the query. Only the fields present in the input contribute to the cost:
Input object weights are evaluated per request, not cached with the query plan. Two requests using the same query but different input fields produce different cost estimates. When specified on a field returning a list, the list size multiplies the weight of this field:
When specified on a directive argument definition, it adds cost to every field where the directive is applied and the argument is active. See Weights on Directive Arguments.

The @listSize Directive

@listSize provides better list size estimates than the global default. Static size with assumedSize — when a field consistently returns a predictable number of items:
Dynamic size with slicingArguments — when the list size is controlled by a pagination argument:
The router reads the argument value from the query to determine the multiplier:
When multiple slicing arguments are provided, the router uses the maximum value among them. Targeting specific child fields with sizedFields — when a field returns an object that contains list fields. This is useful for Cursor connection types where the parent field returns an object containing paginated lists. sizedFields option states that the size of the list returned by Connection.edges is determined by the value of one of the slicing arguments of the parent field:
With this configuration, a query like usersConnection(first: 5) applies the multiplier 5 only to the edges field. Without sizedFields, when @listSize is applied to a field returning a list, all immediate list children inherit the size estimate.
sizedFields can only reference fields that return lists within the parent type. The parent field must return a composite type (object, interface, or union).

Nested paths in slicingArguments

When the slicing value lives inside an input object, reference it with a dot-separated path. The first segment is the argument name. Each following segment is a field on the preceding input object. The leaf must be Int or Int!.
A query like search(input: { pagination: { first: 25 } }) resolves the multiplier to 25. Rules for nested paths:
  • Every intermediate segment must be a field that returns an input object. Lists and scalars cannot appear in the middle of a path.
  • Flat and nested paths can be mixed in the same slicingArguments list, for example ["limit", "input.pagination.first"].

Accessing Cost in Custom Modules

In custom modules, the calculated cost is accessible through the operation context:
Use cases:
  • Apply rate limiting based on query cost
  • Log expensive operations for analysis
  • Support billing based on query complexity
  • Throttle requests during high-load periods

Error Responses

When a query exceeds the estimated cost limit (in enforce mode), the router returns a 400 status:

Telemetry

When Cost Control is enabled, the router can emit OpenTelemetry metrics for every operation. These metrics are available through both OTEL and Prometheus exporters, carrying the same operation-level attributes as other router metrics (wg.operation.name, wg.operation.type, wg.operation.protocol, wg.client.name, wg.client.version, http.status_code). For more details on exporting and querying, see Metrics & Monitoring. Cost metrics are disabled by default and must be explicitly enabled through cost_stats under the OTLP or Prometheus telemetry configuration:
The environment variables corresponding to the config options above: METRICS_OTLP_COST_STATS_ESTIMATED_ENABLED METRICS_OTLP_COST_STATS_ACTUAL_ENABLED PROMETHEUS_COST_STATS_ESTIMATED_ENABLED PROMETHEUS_COST_STATS_ACTUAL_ENABLED

Metrics

Those metrics use the exact same attribute set as other router request-level metrics (like router.http.requests, router.http.request.duration_milliseconds).
Cost metrics require both Cost Control to be enabled (security.cost_control.enabled: true) and the corresponding cost_stats toggle to be set. The actual metrics additionally require execution to complete so the router can observe real list sizes.

Using cost metrics

By default, histogram bucket boundaries are the following: 0, 10, 50, 200, 1000, 5000, 10000 The best practice is to assign the smallest possible weights to fields. It is better to keep costs low. If the estimate exceeds 10000, the value will be placed in the infinite bucket. Non-scalar fields have a default weight of 1. It is a good idea to pick custom weights close to 1. In measure mode, use cost metrics to understand the traffic before enabling enforcement:
  • Compare estimated vs. actual costs to configure estimated_list_size and @listSize.
  • Identify operations where the delta is consistently large — these benefit from more precise @listSize annotations.

Response Headers

When expose_headers is enabled, the router adds cost information to every response as HTTP headers. This is useful for debugging, client-side observability, and integrating cost awareness into API tooling. Both headers are present on successful responses. On rejected responses (enforce mode), only X-WG-Cost-Estimated is set, since the query was not executed.

Best Practices

Start with Measure Mode

Start with mode: measure to calculate costs without rejecting queries. This reveals traffic patterns before enabling enforcement.

Pick Parameters for @listSize Carefully

The default list size significantly impacts cost calculations. If lists typically return 5–20 items, a default of 10 is reasonable. For fields that return large lists (100+ items), consider:
  1. Using @listSize(assumedSize: N) on those specific fields
  2. Requiring pagination with slicingArguments

Annotate Expensive Resolvers

Apply @cost to fields that trigger expensive operations:
  • External API calls
  • Complex computations
  • Large database queries
  • Fields that fan out to multiple subgraphs

Design for Pagination

Pagination improves cost accuracy. slicingArguments provides precise multipliers based on client requests:

Nested Lists

Nested list fields require special attention, as costs multiply:
With a default list size of 10, this query costs over 10,000. Use @listSize to provide realistic estimates for deeply nested structures.

Weights on Directive Arguments

@cost can be placed on arguments of custom directive definitions. When a field has such a directive applied, the weight of each active argument is added to the field’s cost.
In this example, the @approx directive has an argument tolerance with a cost weight of -2. Because tolerance has a default non-null value, it is considered active on every field where @approx is applied, even when the argument is not explicitly provided. The weight of -2 is added to the cost of Query.search. An argument is active when:
  • It has a non-null value at the directive usage site, or
  • It has a default value and is not explicitly set to null
When a directive is applied on fields of types implementing an interface, the router takes the maximum directive argument weight across all implementing types.