Subscriptions in Federated GraphQL

Subscriptions are the one operation type where federation’s usual model — plan a tree of fetches, merge the results, return once — does not apply. A subscription is a long-lived stream owned by exactly one subgraph, and everything difficult about running them in production follows from that single asymmetry. This guide, part of Federated GraphQL Operations in Production, covers how the router carries a stream, what it can and cannot federate onto each event, and how to operate the result at scale.

Prerequisites

Concept Deep-Dive: What the Router Actually Federates

A federated subscription has two phases that people routinely conflate. The stream is opened against exactly one subgraph — the one that owns the Subscription root field — and only that subgraph. Federation does not merge streams, interleave two subscriptions, or resolve a subscription root field across services. If Subscription.orderStatusChanged lives in the orders subgraph, every event on that stream originates there.

The enrichment is where federation returns. Each event the subgraph emits is a payload the router treats like the root of an ordinary query result. If the client’s selection set reaches into fields owned elsewhere, the router builds a query plan for that selection and executes it per event, exactly as it would for a query. The event carries entity keys; the router turns those keys into fields from other subgraphs.

That structure has a direct operational consequence: the cost of a subscription is not the cost of the stream, it is the cost of the enrichment multiplied by the event rate multiplied by the number of subscribers. A stream emitting ten events per second to a thousand clients, where each event needs one entity fetch, is ten thousand subgraph fetches per second — from an operation the client wrote as a single subscription.

One stream, many enrichment plans The router opens a single stream against the owning subgraph. Every event it receives is enriched with a query plan that fetches fields from other subgraphs, so enrichment cost scales with event rate rather than with subscription count. The stream is cheap; the enrichment is not client one subscription router plan per event orders subgraph owns the stream accounts — per event catalog — per event 1 stream N fetches Every field a client selects outside the owning subgraph turns one event into an extra fetch.

Directive & Config Spec Table

Key / element Where Syntax Composition-time effect Runtime effect
Subscription root type subgraph SDL type Subscription { … } Only one subgraph may own a given root field Opens the stream against that subgraph
subscription.enabled router.yaml enabled: true None Subscriptions rejected entirely when false
subscription.mode.passthrough router.yaml per-subgraph WebSocket config None Router holds a WebSocket to the subgraph
subscription.mode.preview_callback router.yaml public_url, listen, path None Subgraph posts events back over HTTP
subscription.queue_capacity router.yaml queue_capacity: 1000 None Bounds buffered events per subscription
@key on the event payload type subgraph SDL @key(fields: "id") Enables enrichment across subgraphs Each event resolves entities by key

Step-by-Step Implementation

Step 1 — Declare the stream where the events originate

The subgraph that owns the state change owns the subscription. Returning an entity — rather than a bag of scalars — is what makes enrichment possible at all.

# orders subgraph
extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.9", import: ["@key"])

type Subscription {
  orderStatusChanged(orderId: ID!): Order!
}

type Order @key(fields: "id") {
  id: ID!
  status: OrderStatus!
  updatedAt: DateTime!
}

Step 2 — Publish events from a shared source, never from process memory

import { RedisPubSub } from "graphql-redis-subscriptions";

// Shared across every replica: an event published by one pod must reach a
// subscriber connected to any other pod.
const pubsub = new RedisPubSub({ connection: process.env.REDIS_URL });

export const resolvers = {
  Subscription: {
    orderStatusChanged: {
      subscribe: (_: unknown, { orderId }: { orderId: string }) =>
        pubsub.asyncIterator(`order:${orderId}`),
    },
  },
};

// Called wherever the status actually changes — including from a worker
// process that serves no GraphQL traffic at all.
export async function publishStatusChange(order: { id: string }) {
  await pubsub.publish(`order:${order.id}`, { orderStatusChanged: order });
}

Step 3 — Enable subscriptions in the router and choose a transport

# router.yaml
subscription:
  enabled: true
  queue_capacity: 1000
  mode:
    passthrough:
      subgraphs:
        orders:
          path: /ws
          protocol: graphql_ws

Step 4 — Keep connections alive through the infrastructure

Long-lived connections die to idle timeouts you did not know you had. Set the load balancer idle timeout above the heartbeat interval, enable WebSocket upgrades on every proxy in the path, and confirm that a rolling deploy drains connections rather than cutting them.

Composition Pipeline Integration

Subscriptions add exactly one new composition rule worth remembering: a subscription root field may be defined by only one subgraph, and there is no @shareable escape hatch for it. Two subgraphs declaring Subscription.orderStatusChanged is a composition error, and the fix is always a naming decision rather than a directive.

rover subgraph check my-graph@current \
  --name orders --schema ./orders/schema.graphql

What the check cannot tell you is whether the subscription is operationally sound, and this is where a federated subscription differs most from a query. Composition happily accepts a subscription whose payload type reaches into four other subgraphs; nothing in the schema records that each event will therefore cost four fetches. Add a review rule for subscription payload types to compensate: the selection set clients can write against an event is the thing to keep small, and the cheapest way to keep it small is to return an entity with few fields rather than a rich aggregate. The wider CI workflow is covered in federated schema validation in CI/CD pipelines.

Performance & Scale Considerations

Three numbers govern a subscription deployment, and they multiply rather than add.

Connection count is bounded by memory and file descriptors on the router. Each subscriber holds a connection for its whole session, so a router serving mobile clients holds connections through backgrounding, network changes, and sleep. Size for peak concurrent connections, not for request rate.

Event rate × subscriber count is the fan-out. A single popular topic with ten thousand subscribers turns one publish into ten thousand deliveries, and the router does that work on every event.

Enrichment fetches per event is the multiplier that surprises people, because it is invisible in the subscription’s own definition. It is set by what clients select, not by what you publish.

Enrichment turns subscribers into subgraph load With a payload clients can consume directly, subgraph fetch volume stays flat as subscribers grow. With a payload that requires two entity fetches per event, fetch volume grows linearly with the number of subscribers. Subgraph fetches per second, 10 events/s topic 2 enrichment fetches per event self-contained payload — no enrichment 100 subscribers 10,000 The green line is a design choice, not an optimisation: publish the fields clients actually render, so the common selection needs no cross-subgraph work at all.

The practical lever is payload design. If the fields clients render on every event are published in the event itself, the common selection needs no enrichment and the green line applies. Reserve cross-subgraph fields for events where they genuinely change, and consider whether the client can fetch them once with an ordinary query instead of on every tick. Where enrichment is unavoidable, entity caching at the router — see caching strategies for federated GraphQL — converts repeated identical fetches into cache hits, which is the single most effective mitigation available.

Transport Between Router and Subgraph

The client’s transport and the router’s transport to the subgraph are independent choices, and confusing them causes a surprising amount of wasted debugging. Clients talk to the router over WebSockets or Server-Sent Events, and that is a client-library decision. Behind the router, each subgraph is reached in one of two very different ways.

Passthrough keeps a WebSocket open from the router to the subgraph for the life of every subscription. It is the simpler model conceptually — a socket per subscription, events flow down it — and it works well when subscription counts are modest and both sides live inside one network. Its cost is connection state: a router holding fifty thousand client subscriptions holds fifty thousand subgraph sockets too, and every subgraph replacement during a deploy tears all of them down at once.

Callback inverts the direction. The router registers an interest with the subgraph over an ordinary HTTP request, telling it where to post events, and the subgraph then delivers each event as a normal HTTP POST back to the router. No long-lived connection exists between them. That makes subgraphs stateless with respect to subscriptions, lets them scale and deploy without dropping streams, and turns the delivery path into something every load balancer and observability tool already understands. The cost is that the subgraph must be able to reach the router, which means a routable address for the router and a shared secret to prevent anyone else posting events into it.

Passthrough holds a socket; callback holds nothing With passthrough the router keeps one WebSocket per subscription open to the subgraph. With the callback protocol the router registers once over HTTP and the subgraph posts each event back, so neither side holds connection state between events. Who holds the connection decides how you deploy passthrough — one WebSocket per subscription router orders subgraph N open sockets, held for the session a deploy drops them all at once callback — HTTP in both directions router orders subgraph register once: "post events here" one POST per event stateless — scales and deploys freely Callback needs a routable router address and a shared secret; passthrough needs neither.

Choose passthrough when the subgraph already speaks graphql-ws, the subscription volume is moderate, and you value having one fewer moving part. Choose callback when subgraphs deploy frequently, when subscription counts are large enough that connection state is a capacity concern, or when your platform makes long-lived internal connections awkward. The choice is per subgraph rather than global, so a graph can use both.

Backpressure and Slow Consumers

Every subscription system eventually meets a consumer that cannot keep up — a mobile client on a poor connection, a browser tab that has been backgrounded, a debugging session paused at a breakpoint. What the system does at that moment is a decision you make in advance or discover during an incident.

The router buffers events per subscription up to queue_capacity. Below that limit a brief slowdown is absorbed and the consumer catches up. Above it, something has to give, and there are only three possibilities: drop events for that subscriber, disconnect that subscriber, or buffer without limit and let memory grow until the whole router is at risk. The third is never the right answer, which is why leaving the capacity unbounded is a decision even when it does not feel like one.

The right default for most graphs is a bounded queue and disconnection on overflow, because a client that has fallen far enough behind is usually better served by reconnecting and refetching current state than by receiving a backlog of stale events. That behaviour also makes the client contract explicit: a subscription is a stream of what is happening now, not a durable log, and clients must be able to recover by querying rather than by replaying. Where a durable log genuinely is the requirement, the answer is a message broker the client consumes directly, not a GraphQL subscription with a large buffer.

Watch two metrics to know where you stand: queue depth per subscription at the high percentiles, and the disconnection rate attributable to overflow. Healthy systems show a shallow queue almost always and a small, stable overflow rate. A queue depth that grows through the day is a fan-out problem that no buffer size will fix.

Observability for Streams

Query observability does not transfer to subscriptions, because the unit of work is different. A query has a start, an end, and a duration; a subscription has an establishment, an indefinite lifetime, and a sequence of deliveries. Instrumenting it as though it were a long query produces one span lasting four hours and tells you nothing.

Four signals cover almost every subscription incident. Active subscriptions, by operation name, tells you what the router is holding and is the number to compare against your capacity plan. Events delivered per second, split by operation, shows fan-out and is where a sudden spike in publishing shows up before it becomes a latency problem. Delivery latency — the time from the subgraph emitting an event to the router writing it to a client — is the closest analogue to query latency and the one users actually feel. Establishment failures separate “clients cannot subscribe” from “clients subscribed and receive nothing”, which are different bugs with different owners.

Trace each event’s enrichment as an ordinary operation rather than trying to nest it under the subscription’s lifetime. That gives you the same waterfalls, the same subgraph attribution, and the same tooling you already use for queries, with the subscription id carried as an attribute so a single stream’s events can be correlated. The general setup is covered in observability and distributed tracing in federation; the only subscription-specific addition is sampling per event rather than per operation, since a busy stream would otherwise dominate your trace volume entirely.

Alert on delivery latency and establishment failure rate, not on active connection count. Connection count is a capacity signal that moves for legitimate reasons — a marketing launch, a working day beginning — while the other two only move when something is wrong.

Failure Modes & Debugging

Subscribers receive nothing while events are clearly being published. Root cause: the publisher and the subscriber are in different processes and the event bus is in-process memory. Symptom is load-dependent — it works with one replica and fails intermittently at two. Fix by moving to a shared broker.

Connections drop every sixty seconds exactly. Root cause: an idle timeout in a load balancer or proxy, not in your application. Fix by raising the timeout above your heartbeat interval and confirming keepalives actually traverse every hop.

Memory grows steadily on the router under load. Root cause: a slow consumer with an unbounded queue. Fix by setting queue_capacity and deciding explicitly what happens when it fills — dropping the slowest subscriber is usually better than degrading everyone.

Events arrive but with fields null. Root cause: enrichment failing, not the stream. The event delivered fine and the entity fetch behind it did not. Check the subgraph the null fields belong to and look for the same errors you would see on a query.

Frequently Asked Questions

Can two subgraphs contribute fields to one subscription payload?

Yes, and this is the normal case — the payload is an entity, so any subgraph that contributes fields to that entity can contribute them to the event. What cannot be split is the subscription root field: the stream has exactly one owner. Think of it as one subgraph deciding when an event happens and the whole graph deciding what an event contains.

Do subscriptions work with the JS gateway?

No. Federated subscriptions are a router feature, and @apollo/gateway has no equivalent. If subscriptions are on your roadmap, that fact alone usually decides the runtime question covered in Apollo Router vs Apollo Gateway production trade-offs.

How do authorization directives behave on a subscription?

They are evaluated when the subscription is established, which is the right time for capability checks and the wrong time for anything that can change mid-stream. A subscriber whose permissions are revoked keeps receiving events until the connection closes, so anything security-critical needs either a short-lived connection policy or a check applied to each event’s enrichment.

Should every state change be a subscription?

No, and this is the most common design mistake. Subscriptions are for state a user is actively watching. For state a user needs to know about eventually — an order shipped, a report finished — a notification and a refetch is cheaper, survives disconnection, and does not hold a connection open for hours. Reach for a subscription when latency genuinely matters and the client is on screen.

Can a subscription be cached?

The stream cannot, but the enrichment can, and that distinction matters. Entity caching at the router serves the per-event fetches from cache when many subscribers see the same entity, which is exactly the fan-out case where the cost is worst.