Skip to content
advanced Phase 7 · Subscriptions and Realtime Delivery

Subscription Resilience and Delivery Guarantees

Handling reconnects, missed events, and backpressure in production subscriptions.

50m
1 problems
Topic Progress 0%

At-Most-Once and Why That Scares Finance

At-Most-Once and Why That Scares Finance

GraphQL subscriptions are fire-and-forget. If the socket drops between the moment the server writes a frame and the moment the client reads it, that event exists nowhere. The protocol has no acknowledgement, no replay cursor, and no redelivery — the guarantee is at-most-once.

That is acceptable for some consumers and unacceptable for others:

Consumer If an event is lost Required design
Chat transcript a newer message supersedes it at-most-once, plus history on rejoin
Presence and typing state state re-converges on the next event at-most-once
Notification badge the user sees one stale count at-most-once plus periodic refresh
Payment or alert feed money or an SLA is affected outbox table plus query fallback
Live scoreboard visible until the next tick periodic snapshot covers the gap

The rule that follows: a subscription is a fast path, never the only path to a fact that matters. Anything important must also be readable through a query the client can run after reconnect. Deleting the poll "because we have subscriptions" removes the safety net the subscription was built on.

Closing the loop when loss is visible

Streams where loss shows up to a human need explicit acknowledgement: the server tracks events it has written but not confirmed, and the client confirms ids as it persists them.

server:  next id:41   (pending)
server:  next id:42   (pending)
client:  mutation acknowledge(ids: [41, 42])   -> pending cleared
client disconnects before acking 43
reconnect: client asks for events after its last stored id
server:   redelivers 43 from the replay buffer

Acknowledgement converts at-most-once into at-least-once, which moves the work onto the client: every consumer must deduplicate by id before applying. That cost is worth paying for the payment feed row in the table and never for chat, where a duplicate message is more annoying than a missing one.

There is also a category no transport fixes: events that were never published because the producer failed between committing the database write and calling publish. A transactional outbox — write the event in the same transaction as the state change, then publish from a relay — is the fix, and it is orthogonal to which GraphQL transport you chose.

Resume Points and Bounded Buffers

Resume Points and Bounded Buffers

Resume is two mechanisms cooperating: an id on every event and a buffer that can still produce anything a client asks for.

class ReplayBuffer {
  constructor(limit = 500) {
    this.limit = limit;
    this.items = [];
    this.seq = 0;
  }

  push(payload) {
    const id = ++this.seq;
    this.items.push({ id, payload });
    if (this.items.length > this.limit) this.items.shift(); // drop oldest
    return id;
  }

  since(lastEventId) {
    if (lastEventId == null) return []; // fresh subscriber: live from now
    const gap = this.seq - lastEventId;
    if (gap > this.limit) return null; // too old: client must refetch
    return this.items.filter((e) => e.id > lastEventId);
  }
}

since() returning null is the important branch. Silently streaming from the oldest buffered event pretends a continuity that does not exist; telling the client "your gap is too large, refetch state" is the honest contract, and the client answers with a normal query that rebuilds its view.

Note that ids are per topic, not global. A client resuming a chat room only needs that room's sequence, which keeps buffers small and lets different topics keep different retention windows.

Slow consumers

Every subscriber that reads slower than the publish rate grows a queue somewhere. Unbounded, that queue is an out-of-memory incident with extra steps. Pick a policy per topic and write it down:

  • Drop oldest — right for tick and metric streams where the newest value is the only one that matters.
  • Drop newest, counted — right when the client should detect loss and resync rather than silently skip.
  • Disconnect the consumer — right when a silent gap would corrupt the client's model; closing the socket forces a deliberate reconnect and refetch.
if (queue.length >= MAX_QUEUE) {
  metrics.increment('subscription.slow_consumer_dropped');
  queue.shift();
  if (queue.length >= MAX_QUEUE * 2) {
    socket.close(1013, 'slow consumer'); // 1013 = try again later
  }
}

Bound the queue, count the drops, alert on the count. A rising slow_consumer_dropped is the earliest signal that one consumer — or one region — is falling behind, and it is far better than the alternative: an unbounded buffer that fails for everyone at once.

Keeping the Broker Path Honest

Keeping the Broker Path Honest

In a multi-instance deployment the delivery chain is:

publish -> broker -> each instance's local subscriber table -> socket write

Each hop fails differently, and they do not fail together.

  • Publish without a broker. An in-process emitter works with one process. With two instances it silently drops half the events, because the publish landed on the node that does not own the subscriber.
  • Broker without a local table. Forwarding raw events through the load balancer breaks the moment a subscriber's socket lives on a different instance than the publish arrives on.
  • Local table without cleanup. The failure that hides: sockets that closed without triggering teardown leave entries delivering into the void until memory alerts fire. Audit entry count against live sockets on a timer.

Draining a deploy

SIGTERM -> reject new subscribe messages
        -> send complete for each active operation
        -> wait for in-flight writes (bounded, about 5s)
        -> close broker subscriptions, then sockets

Clients that receive complete know the server ended the stream on purpose and reconnect with backoff. Clients that only see a TCP reset reconnect immediately, all at once, against a fleet that is still starting — the classic post-deploy thundering herd. Backoff with jitter on the client and a bounded drain on the server solve it from both ends.

Delivery guarantees per hop

The broker itself may be at-least-once (Redis pub/sub is actually at-most-once: it drops messages for consumers that disconnected). Know which one you bought: Kafka with consumer offsets gives you replay, Redis pub/sub gives you none, and a managed stream gives you whatever its retention window says. GraphQL's guarantee is the weakest link in that chain, never stronger.

Metrics worth alerting on

active_streams, events_published versus events_delivered (the gap is filtering, loss, or a dead instance), replay_gap_expired, slow_consumer_dropped, and reconnect rate after deploys. Publish-to-socket p99 latency tells you whether the broker or the consumers are the constraint.

Interview framing: state the guarantee (at-most-once), name the mechanism that upgrades it (client ack plus replay buffer), and identify the bounded queue as what protects the process from a slow consumer.

Practice Problems

0 / 1 solved
Build an Acknowledgement Ledger for a Critical Feed
At-Least-Once Delivery

A payment feed cannot lose events. Implement the server side of an acknowledgement protocol: every published event gets a per-topic sequence id, ids stay pending until the client acknowledges them, a reconnecting client resubmits its last stored id and receives the unacked events, and a gap wider than the buffer is refused with an explicit refetch signal instead of a partial replay.

Example:

Input: Events 41, 42, 43 published; client acks 41 and 42; client disconnects, then reconnects with lastStoredId = 42

Output: replay [43]

Only the unacknowledged event is redelivered, so the client sees at-least-once semantics and deduplicates by id before applying.

Optimal Solution — O(1) publish and ack, O(k) resume over k buffered events time, O(limit) per topic space

Track a sequence and a bounded ring per topic, keep a pending set until acked, and branch on the gap between the requested id and the oldest buffered id before deciding between replay and refetch.

class AckLedger {
  constructor(limit = 500) {
    this.limit = limit;
    this.seq = 0;
    this.buffer = [];
    this.pending = new Map(); // id -> payload
    this.evicted = 0;
  }

  publish(topic, payload) {
    const id = ++this.seq;
    this.buffer.push({ id, payload });
    this.pending.set(id, payload);
    if (this.buffer.length > this.limit) {
      this.buffer.shift();
      this.evicted++;
    }
    return id;
  }

  ack(ids) {
    for (const id of ids) this.pending.delete(id);
  }

  resume(lastStoredId) {
    if (lastStoredId == null) return { status: 'LIVE' };
    const oldest = this.buffer.length ? this.buffer[0].id : this.seq + 1;
    if (lastStoredId < oldest - 1) return { status: 'GAP_TOO_LARGE' };
    return {
      status: 'REPLAY',
      events: this.buffer.filter((e) => e.id > lastStoredId),
    };
  }
}

Edge Cases:

  • Ack arrives twice for the same id
  • Reconnect with no stored id (fresh subscriber, stream live from now)
  • Eviction pushes a still-pending event out of the buffer
  • Two topics resuming with the same lastStoredId

Quiz

1. What delivery guarantee does a GraphQL subscription provide by default?

Question 1 options

2. A client reconnects after a 10-minute outage and its last id is far behind the buffer. What should the server do?

Question 2 options

3. A subscriber consistently reads slower than the publish rate. What is the safest server behavior?

Question 3 options

Flashcards

Question

Default delivery guarantee of GraphQL subscriptions

Answer

At-most-once. Frames lost on disconnect are never redelivered; anything critical needs client acks plus a replay buffer or a query fallback.

Question

Resume protocol ingredients

Answer

Sequential id on every event, a bounded per-topic replay buffer, and Last-Event-ID sent on reconnect — plus an explicit "gap too large, refetch" signal.

Question

Slow-consumer policies

Answer

Drop oldest (ticks), drop newest with a counter (client should detect loss), or disconnect with code 1013 (silent gap would corrupt state).

Question

Fan-out topology for delivery across instances

Answer

publish -> shared broker -> each instance local subscriber table -> socket write. GraphQL delivery is only as strong as the weakest hop in that chain.

Question

Graceful drain sequence on SIGTERM

Answer

Reject new subscribes, send complete for active operations, wait for in-flight writes, then close broker subscriptions and sockets.

Revision Notes

Key Takeaways

  • 1. Subscriptions are at-most-once by default; a stream is a fast path, never the only path to a fact that matters
  • 2. Resume needs ids, a bounded buffer, and an honest signal when the gap exceeds the buffer
  • 3. Bound every subscriber queue and count drops — memory protection beats silent completeness
  • 4. Delivery quality is set by the broker hop, and deploys need a drain that sends complete

Interview Tips

  • • Name the guarantee first, then the mechanism that upgrades it and the cost that mechanism moves onto the client
  • • Be ready to distinguish producer failures (fix with a transactional outbox) from transport losses (fix with acks and replay)
  • • Walk the deploy drain step by step and explain what clients do differently after complete versus a TCP reset

Cheat Sheet

Subscription Resilience

Guarantees

  • GraphQL subscriptions: at-most-once. No ack, no redelivery.
  • Client ack + pending set + replay buffer: at-least-once (dedupe by id).
  • Producer crash between commit and publish: transactional outbox.

Resume pattern

push: id = ++seq; items.push({id, payload}); trim to limit
since(lastId): null if seq - lastId > limit -> client refetches
               else replay items with id > lastId

Backpressure: bound the queue per subscriber; drop oldest, drop newest with a counter, or close with 1013; alert on drop counts.

Deploy drain: stop new subscribes -> complete active ops -> drain writes -> close.

Watch: active_streams, published vs delivered gap, replay_gap_expired, slow_consumer_dropped, post-deploy reconnect rate.

Continue Learning