Subscriptions over WebSockets vs HTTP Callbacks
Between the router and a subgraph, a federated subscription can travel two ways: a WebSocket the router holds open, or an HTTP callback the subgraph posts into. They deliver identical events to clients and have almost nothing else in common operationally. This page compares them on the dimensions that actually decide the choice — deploy behaviour, connection state, network requirements, and failure modes — as part of Subscriptions in Federated GraphQL.
When to use this pattern
- Choose passthrough WebSockets when subgraphs already speak
graphql-ws, subscription counts are moderate, and you want the fewest moving parts. - Choose HTTP callbacks when subgraphs deploy frequently, when connection state is a capacity concern, or when your platform makes long-lived internal connections awkward to route and observe.
- Skip the decision entirely if no subgraph owns a
Subscriptionroot field — this configuration only exists for the subgraph that produces events.
Prerequisites
The difference is who holds state
In passthrough mode the router opens one WebSocket per subscription to the owning subgraph and holds it for the life of that subscription. The subgraph therefore knows about every active subscriber, because each one is a connection it is serving. Events flow down the socket as they are produced.
In callback mode the router makes a single ordinary HTTP request to the subgraph saying, in effect, “here is a subscription, post its events to this URL, here is the token to use”. The connection then closes. When an event occurs, the subgraph makes a fresh HTTP POST to the router’s callback endpoint. Between events, nothing is held open in either direction.
That inversion decides almost everything else. A subgraph in passthrough mode is stateful with respect to subscriptions — restarting it drops every stream it was serving. A subgraph in callback mode is stateless: any replica can deliver any event, because delivery is just an HTTP request to a URL that any replica can construct.
Implementation walkthrough
Both modes are configured in the same block, keyed per subgraph, and a graph can use both at once. Passthrough first:
# router.yaml — passthrough for a subgraph that already speaks graphql-ws
subscription:
enabled: true
queue_capacity: 500
mode:
passthrough:
subgraphs:
orders:
path: /ws # path only; host comes from the routing URL
protocol: graphql_ws
headers:
x-internal-caller: router
Callback mode needs three things the router must know: where it can be reached, where it listens for posted events, and how the subgraph proves an event is genuine.
# router.yaml — callback for a subgraph that should stay stateless
subscription:
enabled: true
queue_capacity: 500
mode:
preview_callback:
# The address the SUBGRAPH will POST to. Must be routable from the
# subgraph's network, which is the requirement passthrough does not have.
public_url: https://router.internal.example.com/callback
listen: 0.0.0.0:4000
path: /callback
# Only these subgraphs use callback; anything else falls back to passthrough.
subgraphs:
- inventory
On the subgraph side, callback delivery is an ordinary HTTP client call. The important detail is that it can happen from any process — including a background worker that serves no GraphQL traffic — which is exactly what makes the mode attractive:
// Any replica, any process, can deliver an event.
async function deliver(callbackUrl: string, verifier: string, subscriptionId: string, payload: unknown) {
await fetch(callbackUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
kind: "subscription",
action: "next",
id: subscriptionId, // supplied by the router at registration
verifier, // proves this POST came from the registered subgraph
payload,
}),
});
}
The verifier is the security boundary. Without it, the callback endpoint is an unauthenticated way to inject events into other people’s subscriptions, so treat it exactly as you would a webhook secret: never log it, rotate it, and reject any POST whose verifier does not match the subscription it names.
Verification steps
For passthrough, confirm the router actually holds a socket to the subgraph rather than merely accepting the client’s:
# On the subgraph host: one established connection per active subscription.
ss -tan | grep ESTAB | grep ':4001' | wc -l
For callback, confirm registration and delivery separately, because they fail for different reasons:
# 1. Registration: the router's outbound HTTP call to the subgraph.
# A 4xx here means the subgraph does not implement the callback protocol.
# 2. Delivery: the subgraph's inbound POST to the router.
curl -s -o /dev/null -w '%{http_code}\n' \
-X POST https://router.internal.example.com/callback \
-H 'content-type: application/json' \
-d '{"kind":"subscription","action":"check","id":"probe","verifier":"wrong"}'
# expect a rejection — proving the verifier is actually enforced
A deliberately wrong verifier returning success is the single most important negative test in callback mode, and it is easy to skip because everything appears to work without it.
Finally, exercise a subgraph restart under load in both modes. Passthrough should show every stream dropping and clients reconnecting; callback should show no client-visible interruption at all. If callback mode drops streams on a subgraph restart, the subgraph is holding subscription state it should not be.
In practice the deciding factor is often not preference but what the platform underneath already supports. The two modes have almost exactly opposite infrastructure requirements, so one usually fits an existing environment with no changes at all while the other needs a networking conversation first. Checking these four rows against your platform takes minutes and frequently settles the question outright.
Common mistakes & gotchas
Setting public_url to an address the subgraph cannot resolve. It is the router’s address as seen from the subgraph, not the address clients use. Inside an orchestrated network those are almost always different, and the symptom is registration succeeding while no event ever arrives.
Not enforcing the verifier. A callback endpoint that accepts any POST naming a subscription id is an event-injection vector into other users’ streams. Reject mismatches, and never log the verifier alongside the subscription id.
Assuming passthrough survives a subgraph rollout. It does not, by construction. If subscriptions must survive subgraph deploys, that requirement alone selects callback mode.
Frequently Asked Questions
Does the transport change what clients see?
No. Clients connect to the router the same way and receive identical events either way. The choice is invisible to them, which is what makes it a pure operations decision rather than an API decision.
Can different subgraphs use different modes?
Yes, and mixed configurations are common during a migration. Configure callback for the subgraphs that need deploy independence and leave the rest on passthrough; the router handles both simultaneously and clients cannot tell which is which.
Which mode handles a router restart better?
Neither preserves streams — the router holds the client connection in both cases, so restarting it disconnects clients regardless. What differs is recovery: in callback mode the subgraphs are unaffected and simply stop receiving valid callback registrations, whereas in passthrough mode the subgraph also sees every socket close at once, which can look like an incident on its own dashboards.
Is callback mode slower per event?
Marginally, because each event is a fresh HTTP request rather than a frame on an open socket. In practice connection reuse in the HTTP client makes the difference small enough to be invisible next to the enrichment cost of the event itself, which usually dominates by an order of magnitude.
How do retries work if a callback POST fails?
That is the subgraph’s responsibility, and it is worth deciding deliberately. A short bounded retry handles a transient router restart; unbounded retries turn a router outage into a thundering herd when it returns. Dropping the event after a couple of attempts is usually correct, given that subscriptions are a stream of current state rather than a durable log.
Can we migrate from one mode to the other without downtime?
Yes, and the migration is unusually painless because the mode is per subgraph and invisible to clients. Configure the new mode for a single subgraph, roll the router, and watch that subgraph’s delivery metrics; existing subscriptions on other subgraphs are untouched throughout. The only client-visible event is the router restart itself, which reconnecting clients already handle. Migrating in the other direction is equally safe, so trying callback mode on your busiest subscription subgraph is a reversible experiment rather than a commitment.
Related
- Subscriptions in Federated GraphQL — parent guide
- Configuring GraphQL Subscriptions in Apollo Router — the full configuration block
- Scaling Federated Subscriptions Across Router Instances — many replicas, one event bus
- Apollo Router vs Apollo Gateway Production Trade-offs — why only the router supports this at all
- Observability and Distributed Tracing in Federation — instrumenting either transport