Configuring GraphQL Subscriptions in Apollo Router

Enabling subscriptions in Apollo Router is one configuration block, but the block has four decisions inside it and each one has a failure mode that only shows up under load. This page walks the complete router.yaml configuration, explains what every key does at runtime, and shows how to verify the stream end to end before real clients depend on it. It sits under Subscriptions in Federated GraphQL.

When to use this pattern

  • Use it when at least one subgraph owns a Subscription root field and clients need to receive events without polling.
  • Use it when you are moving off a bespoke WebSocket service and want the same authorization, observability, and schema governance the rest of the graph already has.
  • Skip it when clients only need eventual awareness of a change. A notification plus a refetch survives disconnection and costs nothing while idle; a subscription holds a connection for the whole session.

Prerequisites

The four decisions inside the configuration block

Everything in the subscription block answers one of four questions, and it helps to name them before reading any YAML.

Is the feature on? subscription.enabled gates the whole capability. With it false, the router rejects subscription operations at validation, which is the correct default until the rest is in place.

How does the router reach each subgraph? subscription.mode picks between holding a WebSocket to the subgraph (passthrough) and having the subgraph POST events back (callback). The choice is per subgraph, and the trade-offs are covered in subscriptions over WebSockets vs HTTP callbacks.

What happens when a client falls behind? queue_capacity bounds the per-subscription buffer. Unset, it is unbounded, and an unbounded buffer is how one stalled mobile client becomes a router memory incident.

How many streams may exist at once? max_opened_subscriptions caps concurrency so a client bug or a retry storm cannot exhaust the process.

Each key exists to prevent one specific failure Enabled gates the feature, mode selects the subgraph transport, queue capacity bounds buffering for slow consumers, and max opened subscriptions caps total concurrency. Four keys, four incidents avoided key what goes wrong without it subscription.enabled clients get a validation error they cannot interpret subscription.mode the router cannot open the stream at all queue_capacity one slow client grows router memory without limit max_opened_subscriptions a client retry loop exhausts the whole process The bottom two are the ones people discover in production rather than in staging.

Implementation walkthrough

The configuration below is a complete, production-shaped block. Every non-obvious line is annotated with what it does and why the value is what it is.

# router.yaml
supergraph:
  listen: 0.0.0.0:4000

subscription:
  enabled: true

  # Hard cap on concurrent streams this router process will hold. Size it from
  # peak concurrent users, not from request rate — a subscription occupies a
  # slot for its whole session, including while the client is backgrounded.
  max_opened_subscriptions: 20000

  # Per-subscription buffer. When a consumer cannot keep up and this fills,
  # the router closes that subscription rather than growing memory. Small is
  # correct: a client far enough behind should reconnect and refetch.
  queue_capacity: 500

  mode:
    passthrough:
      subgraphs:
        orders:
          # The subgraph's WebSocket endpoint path, not a full URL — the host
          # comes from the routing URL published with the subgraph schema.
          path: /ws
          protocol: graphql_ws
          # Sent on the upgrade request. Subgraphs commonly authorise the
          # connection once here rather than per event.
          headers:
            x-internal-caller: router

# Subscriptions still ride the normal header rules: without this, the subgraph
# receives no Authorization header and every stream is anonymous.
headers:
  all:
    request:
      - propagate:
          named: "authorization"
      - propagate:
          matching: "^x-b3-.*$"

# Long-lived connections make these limits matter more, not less: a client that
# opens a subscription is a client that stays.
limits:
  max_depth: 12
  max_aliases: 30

telemetry:
  instrumentation:
    events:
      router:
        request: info

Two of those blocks are easy to omit and expensive to omit. The headers block is not subscription-specific, but its absence is far more visible here than on queries: an unauthenticated stream tends to open successfully and then deliver either nothing or, worse, everything. And the limits block matters because a subscription’s selection set is evaluated on every event, so a deep or heavily aliased selection multiplies its cost by the event rate rather than being paid once.

Verification steps

Verify in three stages, because a failure at each stage looks identical from the client: no events.

First, confirm the router accepts the operation at all. A validation error here means enabled is false or the schema has no such field:

curl -s localhost:4000/graphql -H 'content-type: application/json' \
  -d '{"query":"subscription { orderStatusChanged(orderId:\"O-1\"){ id status } }"}' \
  | head -c 200
# A subscription over plain POST is rejected — that rejection proves the field exists.

Second, open a real stream and confirm the handshake completes:

websocat -H='Sec-WebSocket-Protocol: graphql-transport-ws' \
  ws://localhost:4000/ws <<'EOF'
{"type":"connection_init"}
{"id":"1","type":"subscribe","payload":{"query":"subscription { orderStatusChanged(orderId:\"O-1\"){ id status } }"}}
EOF
# expect: connection_ack, then a "next" message per published event

Third, publish an event from the subgraph’s own side — not through the router — and confirm it arrives. This separates “the router is not carrying events” from “the subgraph is not producing them”, which is the single most useful split when debugging a silent stream.

Finally, check the metrics the router exposes for subscriptions: active count, events delivered, and queue depth. A stream that is established but idle looks identical to a broken one until you can see that events delivered is zero while the subgraph’s publish count is not.

Three places a silent stream can be broken Validation, handshake, and delivery each fail with the same client symptom of no events. Testing them in order isolates which stage is at fault. Same symptom, three causes — test in this order 1. validation is the field in the schema and the feature enabled? 2. handshake does connection_ack come back? 3. delivery is the subgraph actually publishing anything? The metric that separates 2 from 3 Active subscriptions greater than zero with events delivered at zero means the stream is open and nothing is being published — look at the subgraph, not the router. Skipping straight to stage 3 is why silent-stream debugging so often takes an afternoon.

Both numeric caps in that block deserve a measurement behind them rather than a round number, and the right measurements are not the ones people reach for first. The table pairs each setting with the quantity that should determine it, alongside the substitution usually made instead — each of which reads as reasonable right up until the first busy day.

Every cap needs a measurement, not a round number Concurrent sessions feed the subscription cap, consumer tolerance feeds queue capacity, per-topic event rate feeds broker sizing, and selection breadth feeds subgraph capacity. Sizing the caps from something real setting measure this what people use instead max_opened_subscriptions peak concurrent sessions request rate queue_capacity slowest tolerable consumer nothing — left unbounded broker capacity events per second per topic router metrics only subgraph capacity fields selected per event the stream rate alone The last row catches teams out: a subscription's cost is dominated by enrichment, not by the stream itself. Load-test with a realistic selection set rather than an empty one.

Common mistakes & gotchas

Leaving queue_capacity unset. The default is unbounded, and unbounded buffering under a slow consumer is a memory leak with a business justification attached. Set it small and let overflow close the subscription.

Forgetting header propagation. Subscriptions use the same headers rules as queries. Without an authorization propagation rule the subgraph sees an anonymous connection, which either fails confusingly or — if the subgraph is permissive — succeeds far too broadly.

Sizing max_opened_subscriptions from request rate. Requests complete; subscriptions do not. The number you need is peak concurrent sessions, which on mobile clients is much larger than concurrent active users because backgrounded apps hold connections.

Frequently Asked Questions

Do subscriptions need a separate listener or port?

No. The router serves subscriptions on the same listener as queries, upgrading the connection when the client asks for it. What you may need separately is infrastructure configuration: some load balancers require WebSocket support to be enabled per listener or per route, and that is where the “works locally, fails deployed” class of bug comes from.

Can I enable subscriptions for some subgraphs only?

Yes, and you generally should. The mode block is keyed by subgraph, so only the subgraphs you configure participate. A subgraph with no subscription configuration simply cannot own a stream, which is a useful way to keep the capability deliberate rather than ambient.

What happens to open subscriptions during a router deploy?

They close, and clients must reconnect. There is no state to hand over, so the practical mitigation is client-side: an exponential-backoff reconnect with a refetch on reconnect makes a rolling deploy invisible to users. Test that path deliberately, because a client that reconnects but does not refetch will show stale state indefinitely.

Does the router deduplicate identical subscriptions?

Not across clients — each subscriber gets its own stream and its own enrichment. That is why fan-out cost scales with subscriber count and why payload design, rather than router configuration, is the main lever on cost.

Are subscription errors reported the same way as query errors?

Establishment errors look like ordinary operation errors and are returned before the stream opens. Errors during a stream arrive as error entries inside individual event payloads, which means a subscription can deliver a long run of good events and then a degraded one. Clients need to handle a payload containing both data and errors rather than assuming an error ends the stream.

Can I roll subscriptions out to a subset of clients first?

Yes, and it is worth doing. Because the capability is gated per subgraph in the router configuration, you can enable a single low-traffic subscription first and leave the rest of the graph unchanged. Pair that with a client-side feature flag and you get a genuine canary: a small population of real users exercising connection handling, reconnection, and enrichment cost before anything high-volume depends on it. The failures that matter here are infrastructure-shaped rather than schema-shaped, so a small amount of real traffic teaches you far more than a load test against a staging load balancer that nobody configured the same way.