skip to content

Why does an Apollo Client 4 support chat using subscribeToMore show the previous room's messages, some twice, after an agent switches rooms?

level: seniorimportance: should knowfreq 22%

answer

  1. whose subscription is still running?
  2. the query object outlives the room
  3. an effect with no cleanup
  4. updateQuery writes to current variables
  5. one connection, two callbacks

basics

~20 s

The effect that calls subscribeToMore never returns its unsubscribe function, so each room's subscription outlives the switch. useQuery keeps one query with new variables, so old subscriptions write into the current room, and two live subscriptions on one room append each message twice.

solid answer

~50 s

`subscribeToMore` attaches its subscription to the query's `ObservableQuery` and returns an unsubscribe function; without it, the subscription stops only when that `ObservableQuery` is torn down, typically when the component unmounts. Switching rooms changes `useQuery`'s `variables`, which re-runs the same `ObservableQuery` rather than creating a new one, so room A's subscription keeps running, and its `updateQuery` is applied to the query's current result, room B's list. Duplicates here usually mean two subscriptions for one room: an effect without cleanup run twice by React's Strict Mode in development, or re-run because its dependencies include `data`. With Apollo Client 4's deduplication they share one connection, so the network panel shows one operation while the list gets two copies. The fix is `return subscribeToMore(...)` from a `useEffect` keyed on `roomId`, plus an `updateQuery` that ignores messages for other rooms and ids already listed.

code

tsx · 16 lines
tsx
useEffect(() => {
  return subscribeToMore({
    document: MESSAGE_ADDED,
    variables: { roomId },
    updateQuery: (_prev, { previousData, complete, subscriptionData, variables }) => {
      const incoming = subscriptionData.data.messageAdded;
      if (!complete || incoming.roomId !== variables?.roomId) return;
      const messages = previousData.room.messages;
      if (messages.some((m) => m.id === incoming.id)) return;
      return {
        ...previousData,
        room: { ...previousData.room, messages: [...messages, incoming] },
      };
    },
  });
}, [roomId, subscribeToMore]);

go deeper

for a junior

Recall that subscribeToMore returns an unsubscribe function and that the effect calling it must return that function as its cleanup.

for a middle

Explain why a variables change reuses the same query object, so a leftover subscription's updateQuery writes into the new room's result.

for a senior

Diagnose from symptoms: tell leaked subscribers from duplicate server events, account for deduplication hiding duplicates on the wire, and add guards that make the merge safe anyway.

for a principal

Set a team rule for who owns live subscriptions, such as component-scoped hooks by default, and weigh it against the ergonomics of query-attached subscriptions.

## The setup that produces the bug An agent console shows one support room at a time. The history comes from `useQuery(ROOM_MESSAGES, { variables: { roomId } })`, and new messages arrive through `subscribeToMore` with a `MESSAGE_ADDED(roomId)` subscription. The effect that starts it looks harmless: ```tsx useEffect(() => { subscribeToMore({ document: MESSAGE_ADDED, variables: { roomId }, updateQuery }); }, [roomId]); ``` It discards the function `subscribeToMore` returns. That function is the **only** handle for stopping this one subscription while the query stays alive. ## Who owns a subscribeToMore subscription `subscribeToMore` belongs to the **`ObservableQuery`**, the object Apollo Client creates for a watched query. Each call adds a subscription to a set that object holds. Unless the server ends it first, a subscription in that set stops only when one of two things happens: - the unsubscribe function the call returned is called; - the `ObservableQuery` is torn down, which happens when its last observer goes away, typically when the component using `useQuery` unmounts. The leak is therefore bounded by the query's lifetime, and it bites precisely while the query stays mounted: room switches and re-runs of the effect. ## Why room A's messages land in room B 1. The agent opens room A. The effect subscribes to `MESSAGE_ADDED(roomId: A)`. 2. The agent switches to room B. `useQuery` sees new `variables` and re-runs the **same** `ObservableQuery` with them; it does not build a new one. 3. The effect runs again and subscribes to `MESSAGE_ADDED(roomId: B)`. Nothing unsubscribed A. 4. A customer writes in room A. A's subscription fires, and its `updateQuery` is applied through the `ObservableQuery`'s own `updateQuery`, which reads and writes the result for the query's **current** variables, room B. 5. Room A's message is appended to room B's list. ## Where the duplicates come from Two live subscriptions for the same room each run `updateQuery` for every event: - **Strict Mode in development** mounts, cleans up and re-mounts effects. With no cleanup there is nothing to undo, so two subscriptions start. - **Dependencies that change per event**, such as `[data, roomId]`, re-run the effect on every message; each run adds one more subscription. - **Switching back** to a room that still has a leaked subscription adds a second one. Apollo Client 4 deduplicates identical subscriptions by default: same document, same variables, one connection. That hides the bug from the network panel, which shows a single subscription, while the updates are doubled in the list. | Symptom | Cause | Fix | |---|---|---| | other room's messages appear | old subscription writing to current variables | return the unsubscribe function | | each message twice | two subscriptions for one room | cleanup, stable dependencies | | duplicates only in development | Strict Mode double effect with no cleanup | cleanup | | one operation on the wire, doubled list | deduplication sharing one connection | look for duplicate subscribers, not duplicate events | ## Fixing it 1. Return the unsubscribe function from the effect: `return subscribeToMore({...})`. 2. Key the effect on `[roomId, subscribeToMore]`; `subscribeToMore` is stable for the lifetime of the query object. 3. Guard the merge: `updateQuery` receives `options.variables`, the query's current variables. Ignore an event whose message belongs to another room. 4. Deduplicate by message `id` before appending, which also absorbs a message the sender's own mutation already added to the list. With those in place a room switch unsubscribes A before subscribing B, and a stray event is dropped instead of corrupting the list. The alternative is to let a hook own the subscription: `useSubscription` ends the old subscription itself when its `variables` change and when the component unmounts. You then append in its `onData` callback, for example with the `updateQuery` function that `useQuery` also returns, and keep the same id and room guards. ## How to confirm the fix - Log inside `updateQuery` with the subscription's room and `options.variables.roomId`; they must always match. - Switch rooms back and forth and watch the WebSocket frames: each switch should end one operation and start another. - Run the console with Strict Mode on; duplicates there mean a missing cleanup, not a quirk to ignore. - While debugging, pass `context: { queryDeduplication: false }` to `subscribeToMore`. Deduplication is then off for that call, so every subscriber opens its own operation, and a leaked subscriber shows up on the wire as an extra one instead of hiding behind a shared connection.

  • Why does the Apollo Client 4 network panel show one subscription while the chat list gets every message twice?
    Apollo Client 4 deduplicates subscriptions with the same document and variables while one is open, so two `subscribeToMore` calls for one room share a single connection. Each caller is still its own subscriber and runs its own `updateQuery` for every event. The wire is fine; the component registered twice.
  • Would switching to useSubscription have avoided this leak?
    Mostly. `useSubscription` ties the subscription to the component: when `variables` change it ends the old one and starts the new one, and it unsubscribes on unmount, after a zero-delay timeout so a deduplicated sibling can take the connection over. It does not append to the query's list, though; you do that in `onData`, for example with the `updateQuery` function `useQuery` also returns.
  • What happens to leaked subscribeToMore subscriptions when the chat component unmounts?
    When the `useQuery` hook's `ObservableQuery` loses its last observer, it is torn down, and teardown unsubscribes every subscription started through its `subscribeToMore`. So the leak does not outlive the query; it causes damage only while the query stays mounted and its variables change underneath.

saying these in an interview costs you the question

  • subscribeToMore subscriptions stop automatically whenever the query's variables change.
  • Changing useQuery's variables creates a new query object, so old subscriptions cannot write into it.
  • Doubled messages prove the server published each event twice.
  • One subscription in the network panel proves only one updateQuery is running.
  • Strict Mode double effects are a development quirk you can ignore for subscriptions.