Describe the bug
ActionCableLink generates the per-subscription channelId with low entropy,
causing occasional collisions when multiple subscriptions are opened
simultaneously (typical on initial page load). When a collision occurs, one
subscription's payload is delivered to another subscription's listener,
producing Apollo cache warnings and TypeError crashes in onData.
Versions
graphql-ruby-client: 1.14.8 (current master is also affected)
@rails/actioncable: 8.x
- Transport: ActionCable
Root cause
In javascript_client/src/subscriptions/ActionCableLink.ts#L37:
var channelId = Math.round(Date.now() + Math.random() * 100000).toString(16)
The random component is uniformly distributed over ~100,001 values. For two
subscriptions opened within the same millisecond, the collision probability is
~1/100,000. With 10–20 subscriptions opening at page boot, the probability
that at least one pair collides is roughly 0.05–0.2% per page load.
When a collision occurs:
- The Rails ActionCable server deduplicates the server-side subscribe by
identifier (return if subscriptions.key?(id_key) in
action_cable/connection/subscriptions.rb).
- The client retains both
Subscription objects — ActionCable JS does not
deduplicate.
- On message arrival, ActionCable JS routes by identifier and invokes
received on all local subscriptions whose identifier matches
(subscriptions.filter(s => s.identifier === identifier)).
- The "deduplicated" (locally surviving) subscription's listener receives the
other subscription's payload, which has the wrong shape.
Symptoms
Apollo logs (production minified error #13):
Missing field 'subscriptionAName' while writing result
{ subscriptionBName: { ... } }
…and downstream onData handlers crash:
TypeError: Cannot read properties of undefined (reading 'someField')
at onData (...)
Steps to reproduce
- Set up an Apollo Client with
ActionCableLink for ActionCable transport.
- Mount many
useSubscription calls simultaneously on a single page (e.g. a
navigation badge subscription + multiple form/detail subscriptions, ~10+).
- Reload repeatedly; occasionally two subscriptions get the same
channelId
and one of them starts receiving the other's payloads.
Expected behavior
Each subscription receives only its own payload.
Proposed fix
Use crypto.randomUUID() to guarantee uniqueness:
const channelId = crypto.randomUUID()
crypto.randomUUID() is available in all modern browsers (Chrome 92+, Firefox
95+, Safari 15.4+) and Node 14.17+. If wider compatibility is required, a
high-entropy random fallback (e.g. crypto.getRandomValues over a 128-bit
buffer) would also work.
I'm happy to submit a PR if this approach is acceptable.
Describe the bug
ActionCableLinkgenerates the per-subscriptionchannelIdwith low entropy,causing occasional collisions when multiple subscriptions are opened
simultaneously (typical on initial page load). When a collision occurs, one
subscription's payload is delivered to another subscription's listener,
producing Apollo cache warnings and
TypeErrorcrashes inonData.Versions
graphql-ruby-client: 1.14.8 (currentmasteris also affected)@rails/actioncable: 8.xRoot cause
In
javascript_client/src/subscriptions/ActionCableLink.ts#L37:The random component is uniformly distributed over ~100,001 values. For two
subscriptions opened within the same millisecond, the collision probability is
~1/100,000. With 10–20 subscriptions opening at page boot, the probability
that at least one pair collides is roughly 0.05–0.2% per page load.
When a collision occurs:
identifier (
return if subscriptions.key?(id_key)inaction_cable/connection/subscriptions.rb).Subscriptionobjects — ActionCable JS does notdeduplicate.
receivedon all local subscriptions whose identifier matches(
subscriptions.filter(s => s.identifier === identifier)).other subscription's payload, which has the wrong shape.
Symptoms
Apollo logs (production minified error #13):
…and downstream
onDatahandlers crash:Steps to reproduce
ActionCableLinkfor ActionCable transport.useSubscriptioncalls simultaneously on a single page (e.g. anavigation badge subscription + multiple form/detail subscriptions, ~10+).
channelIdand one of them starts receiving the other's payloads.
Expected behavior
Each subscription receives only its own payload.
Proposed fix
Use
crypto.randomUUID()to guarantee uniqueness:crypto.randomUUID()is available in all modern browsers (Chrome 92+, Firefox95+, Safari 15.4+) and Node 14.17+. If wider compatibility is required, a
high-entropy random fallback (e.g.
crypto.getRandomValuesover a 128-bitbuffer) would also work.
I'm happy to submit a PR if this approach is acceptable.