Skip to content
intermediate Phase 7 · Subscriptions and Realtime Delivery

Subscription Lifecycle and Event Flow

How a client starts a subscription and receives a stream of server-pushed events.

45m
1 problems
Topic Progress 0%

Why One Response Is Not Enough

Why One Response Is Not Enough

A query returns one payload and then the connection closes. A support dashboard that must show incoming tickets as they arrive has only three bad options: poll every 500 ms and burn traffic nobody looks at, hold a long request that proxies silently kill at 30 seconds, or hand-roll a socket protocol that no GraphQL client, gateway, or tracer understands.

A subscription is the fourth option: the client sends its GraphQL document once, and the server pushes responses until the client unsubscribes or the transport dies.

subscription OnOrderShipped($orderId: ID!) {
  orderShipped(orderId: $orderId) {
    id
    status
    trackingNumber
    eta { day window }
  }
}

That document travels once. What comes back is zero or more { data: { orderShipped: ... } } payloads, each shaped exactly like the selection set above, followed by a completion signal. Two structural facts matter for everything after this: the operation kind is subscription, and the root field takes arguments (orderId) exactly the way a query field does, so arguments decide which events this stream can ever see.

What actually changes

Query Subscription
Responses per operation exactly one zero to many
Connection lifetime one round trip minutes, until unsubscribe
Field execution once once per event
Dominant failure an error payload silent disconnect, expired auth, slow consumer

A query resolver may assume the database, the auth token, and the process are healthy for the 40 milliseconds it runs. A subscription runs as long as the tab stays open, so each of those assumptions has to survive deploys, token refresh, and a commute through a tunnel.

Server-side, the stream is driven by an event source that sits outside GraphQL: an in-process emitter, a Redis channel, a Kafka topic. GraphQL's job is to translate "something happened on channel X" into "here is another response matching the document you sent." Whether a feature earns that machinery is decided in realtime feature patterns.

The Subscribe Function and the Async Iterator

The Subscribe Function and the Async Iterator

Server frameworks split a subscription root field into two jobs that a query field performs as one.

const resolvers = {
  Subscription: {
    orderShipped: {
      // runs ONCE, when the operation starts
      subscribe: (_, args, { pubsub }) =>
        pubsub.asyncIterator('ORDER_SHIPPED:' + args.orderId),

      // runs for EVERY event that flows through the iterator
      resolve: (payload) => payload.orderShipped,
    },
  },
};

subscribe must return an object implementing the async iterator protocol ([Symbol.asyncIterator]()), and it is the only function that touches the event source. The event source itself knows nothing about GraphQL; it pushes plain objects and lets this adapter interpret them.

resolve is the per-event mapper. Publishers usually send an envelope carrying more than one key:

await pubsub.publish('ORDER_SHIPPED:' + orderId, {
  orderShipped: order,
  audit: { at: Date.now(), actor: 'shipping-service' },
});

Publishing audit on the same topic lets a second subscription read its own field without a second publish. Omit resolve and the envelope object is used as-is, which only works when it has exactly one key matching the field name.

Every event re-runs the selection set

Once resolve produces the value, GraphQL executes the subscription selection against it exactly as it would a query result: arguments are applied, nested field resolvers run, non-null violations propagate, and errors attach to that response only. A nested resolver that took 3 ms in GraphiQL now runs thirty times a second per subscriber, so the per-event cost — not the handshake — is the capacity number that matters.

An error in one event does not end the stream: it produces an error payload for that response, and the next event still arrives. The stream ends only on complete, an operation-level failure, or the transport closing.

Filtering belongs in two places

Filters the event source can use belong in the topic name, because they cut work before GraphQL sees anything. Filters that must inspect payload contents use withFilter:

subscribe: withFilter(
  () => pubsub.asyncIterator('MESSAGE_ADDED'),
  (payload, vars) => payload.messageAdded.roomId === vars.roomId,
),

That predicate runs inside the server on every event for every subscriber. A broadcast topic plus a selective filter still pays the fan-out cost on each instance, so scope the topic whenever the argument exists on the root field.

Teardown, Leaks, and the Per-Client Cap

Teardown, Leaks, and the Per-Client Cap

When a client unsubscribes — or its socket simply drops — the server calls return() on the iterator. A generator runs its finally block at that moment, which is where cleanup belongs:

async function* orderEvents(orderId) {
  const timer = setInterval(pollShipmentFeed, 1000);
  try {
    while (true) yield await nextShipment(orderId);
  } finally {
    clearInterval(timer); // unsubscribe, error, and socket close all land here
  }
}

Skip this and the symptom is quiet: departed clients leave live timers, open cursors, and broker subscriptions behind. Finding ten thousand "connected" users who are not connected usually happens during an incident, not during a code review. A hand-written iterator must expose return() so framework-driven cancellation reaches the same finally path.

The cap is a feature, not a courtesy

Connections are cheap to open and expensive to keep:

function makeContext(user, registry) {
  return {
    user,
    claimSlot() {
      const active = registry.count(user.id);
      if (active >= MAX_STREAMS_PER_USER) {
        throw new GraphQLError(
          'STREAM_LIMIT: ' + active + '/' + MAX_STREAMS_PER_USER,
          { extensions: { code: 'STREAM_LIMIT' } },
        );
      }
      registry.increment(user.id);
      return () => registry.decrement(user.id); // invoked from the iterator finally
    },
  };
}

Rejecting loudly at subscribe time beats accepting everything and letting one client swap your sockets for its memory. Enforce two ceilings: per connection, so a single socket cannot open a thousand operations, and per authenticated user, so reconnect storms and duplicate tabs do not multiply. Rate-limit the subscribe messages themselves — a client starting streams in a loop churns your event source faster than a downstream cap can catch.

Signals worth watching

Export active_streams as a gauge per instance and per user, plus streams_started and streams_closed as counters. If the gauge keeps climbing after clients disconnect, the cleanup path is not running — the difference between a leak found in staging and a leak found at 3 a.m.

Interview framing: define a subscription as an event source plus a selection set, name the cleanup hook (return() triggering finally), and state the resource you cap along with why the cap is enforced at subscribe time.

Practice Problems

0 / 1 solved
Implement the orderShipped Subscription
Pub/Sub Async Iterator

Write the SDL and resolvers for orderShipped(orderId: ID!). One event source must be created when the operation starts, each published envelope must be resolved through the client selection set, and every handle opened for the stream must be released when the client unsubscribes.

Example:

Input: subscription { orderShipped(orderId: "42") { id status } } then publish { orderShipped: { id: "42", status: "SHIPPED" }, audit: {...} }

Output: {"data":{"orderShipped":{"id":"42","status":"SHIPPED"}}}

The published envelope carries two keys, but only orderShipped is selected, so audit never reaches the socket and status is resolved through the field resolver.

Optimal Solution — O(1) per event time, O(1) per stream plus O(k) for k queued events space

Scope the topic by the orderId argument inside subscribe, map the envelope in resolve, release resources in a generator finally block, and count stream slots in context so a full account is rejected at subscribe time.

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

const resolvers = {
  Subscription: {
    orderShipped: {
      subscribe: async function* (_, args, ctx) {
        const stop = ctx.claimSlot();          // throws STREAM_LIMIT when full
        const timer = setInterval(pollFeed, 1000);
        const topic = 'ORDER_SHIPPED:' + args.orderId; // scope before delivery
        try {
          for await (const order of eventsOn(topic)) {
            yield { orderShipped: order };
          }
        } finally {
          clearInterval(timer);
          stop();                               // runs on unsubscribe or socket close
        }
      },
      resolve: (payload) => payload.orderShipped,
    },
  },
};

Edge Cases:

  • Client unsubscribes before the first event arrives
  • Publish targets an order with no subscribers
  • Iterator throws mid-stream — finally must still release the slot
  • User already at the 8-stream ceiling

Quiz

1. When does a subscription field subscribe function run on the server?

Question 1 options

2. An event arrives as { orderShipped: { id, status, trackingNumber } } and the client selected orderShipped { id status }. What does the server do with it?

Question 2 options

3. A chat server never runs iterator.return() when a user leaves a room. What is the most likely production symptom?

Question 3 options

Flashcards

Question

subscribe vs resolve on a subscription root field

Answer

subscribe runs once at operation start and returns the async iterator; resolve runs for every event to map the published envelope onto the selection set.

Question

What re-executing the selection set means per event

Answer

Each payload goes through normal execution: arguments applied, nested field resolvers invoked, non-null rules enforced — so per-event resolver cost drives subscription capacity.

Question

Cleanup hook for a subscription

Answer

Unsubscribe or socket close triggers iterator.return(), which runs a generator finally block — the only place timers, cursors, and broker handles may be released.

Question

Why scope a subscription by argument

Answer

Arguments narrow the topic or drive withFilter, cutting fan-out before execution; an unscoped root field streams the whole system to every subscriber.

Question

Two ceilings to enforce on concurrent streams

Answer

Per connection, so one socket cannot open thousands of operations, and per authenticated user, so reconnect storms and tabs do not multiply — both rejected at subscribe time.

Revision Notes

Key Takeaways

  • 1. A subscription sends one document and receives zero to many responses shaped by that same selection set
  • 2. subscribe returns the async iterator once; resolve plus field resolvers run for every event
  • 3. Teardown runs through iterator.return() into a generator finally block — skipping it leaks timers and cursors
  • 4. Cap streams per connection and per authenticated user, and reject the excess at subscribe time

Interview Tips

  • • Describe a subscription as an event source glued to a selection set, then name the two resolver entry points and when each runs
  • • Name what leaks when teardown is skipped and the counter you would export to detect it in production
  • • Explain why per-event cost, not connection setup, is the number you capacity-plan a subscription service on

Cheat Sheet

Subscription Lifecycle Reference

Resolver shape

Subscription: {
  orderShipped: {
    subscribe: (_, args, { pubsub }) => pubsub.asyncIterator('ORDER:' + args.orderId),
    resolve: (payload) => payload.orderShipped, // runs per event
  },
}

Lifecycle

  1. Client sends the subscription document once.
  2. subscribe runs once and returns an async iterator.
  3. Each event is resolved through the selection set (nested resolvers run again).
  4. Errors are per-response; the stream continues.
  5. complete, transport close, or unsubscribe ends it — always run return()/finally.

Production rules

  • Scope topics by root-field argument before filtering in code.
  • Cap streams per connection and per user; reject with STREAM_LIMIT.
  • Track active_streams; a rising floor means cleanup is not running.

Continue Learning