Skip to content

Real-time updates with GraphQL subscriptions

A GraphQL subscription lets the server push a message to every browser that has a page open, for example when another user changes the record on screen. The browser opens a websocket (Action Cable) and the page reacts to each message.

The examples below use a hypothetical MyEntity model in a plugin. For the subscription base class and naming rules, see GraphQL.

Prerequisites

  • Action Cable must work. config/cable.yml uses the async adapter in development and Redis in production.
  • The frontend Apollo client (app/frontend/src/shared/plugins/apolloClient.ts) already sends subscription operations over Action Cable; you do not configure it.

Send a signal, not data

Design the message so it only says that something changed, and let the client refetch with its ordinary query. A broadcast payload is built once and read by every subscriber, so anything in it bypasses per-user authorisation. A refetch also fixes ordering: messages can arrive out of order, and a refetch always converges on the current state.

A payload may carry identifiers and the state needed to re-authorise the subscriber. It must not carry attribute values, rendered text or anything whose visibility depends on who reads it.

Define the subscription

Create the result type, the subscription and the trigger in your plugin's app/api/easy_graphql/.

app/api/easy_graphql/subscriptions/results/my_entity_changed_result.rb
module EasyGraphql
  module Subscriptions
    module Results
      class MyEntityChangedResult < Types::Base
        field :event, String, null: false
        field :actor_id, ID, null: true

      end
    end
  end
end

Return a result type instead of naming fields on the subscription. It is easier to extend and reuse.

Re-authorise on every event in update, not only in subscribe: a connection outlives the permissions it was opened with. Return NO_UPDATE to stay silent.

app/api/easy_graphql/subscriptions/my_entity_changed.rb
module EasyGraphql
  module Subscriptions
    class MyEntityChanged < ::EasyGraphql::Subscriptions::Base
      description "Signals a change of a MyEntity"

      argument :my_entity_id, ID, required: true

      field :result, Results::MyEntityChangedResult, null: true

      def update(my_entity_id:)
        return NO_UPDATE unless subscriber
        return NO_UPDATE unless ::MyEntity.visible.exists?(id: my_entity_id)

        object
      end

      private

      # Re-read the user: the cached one may have lost permissions since the connection opened.
      def subscriber
        return @subscriber if defined?(@subscriber)

        @subscriber = ::User.current.reload
      rescue ActiveRecord::RecordNotFound
        @subscriber = nil
      end

    end
  end
end

When access is lost, the subscription goes quiet; there is no "you can no longer see this" event.

Register the subscription

Add a field to the root Subscription type from a file your plugin requires in an initialiser (see Where the code goes):

lib/my_plugin/easy_graphql.rb
1
2
3
EasyGraphql.register_patch("EasyGraphql::Types::Subscription") do
  field :my_entity_changed, subscription: EasyGraphql::Subscriptions::MyEntityChanged
end

Trigger the subscription

Call EasyGraphql::AppSchema.subscriptions.trigger after the change is committed, typically from an after_commit callback or at the end of a mutation:

app/models/my_entity.rb
1
2
3
4
5
6
7
8
9
after_commit :broadcast_changed, on: :update

def broadcast_changed
  EasyGraphql::AppSchema.subscriptions.trigger("my_entity_changed",
                                               { my_entity_id: id },
                                               { result: { event: "updated", actor_id: User.current.id } })
rescue StandardError => e
  Rails.logger.error("my_entity_changed broadcast failed for #{id}: #{e.class}: #{e.message}")
end
  • The first argument is the subscription field name, the second the arguments the client subscribed with (all must match exactly), the third the object passed to update; its structure must match the result type.
  • Always rescue and log. An error raised in after_commit propagates into the request that saved the record, so a hiccup in the cable transport must not fail the save.
  • Never broadcast an empty change. Compute what changed first and return early when nothing did, otherwise every save wakes every subscriber.

Subscribe from the frontend

Define the subscription in a graphql/subscriptions/ file and generate its types (see GraphQL services and definitions):

graphql/subscriptions/myEntityChanged.ts
import { gql } from "@apollo/client";

export const myEntityChangedSubscription = gql`
  subscription myEntityChanged($myEntityId: ID!) {
    myEntityChanged(myEntityId: $myEntityId) {
      result {
        event
        actorId
      }
    }
  }
`;

Use the useLiveUpdates composable (app/frontend/src/shared/composables/useLiveUpdates.ts). It turns each message into one debounced refetch and defers the refetch while the tab is hidden:

composables/useMyEntityLiveUpdates.ts
import { toValue, type MaybeRefOrGetter } from "vue";
import { myEntityChangedSubscription } from "../graphql/subscriptions/myEntityChanged";
import type {
  MyEntityChangedSubscription,
  MyEntityChangedSubscriptionVariables,
} from "../graphql/subscriptions/myEntityChanged.generated";
import { useLiveUpdates } from "@/src/shared/composables/useLiveUpdates";

export const useMyEntityLiveUpdates = (myEntityId: MaybeRefOrGetter<number>, refresh: () => unknown) => {
  useLiveUpdates<MyEntityChangedSubscription, MyEntityChangedSubscriptionVariables>({
    subscription: myEntityChangedSubscription,
    subscriptionVariables: () => ({ myEntityId: String(toValue(myEntityId)) }),
    refreshCallback: refresh,
  });
};
  • Subscribe once per view, from its root component, never inside a component that repeats.
  • The user who made the change refetches too. Do not suppress the echo: the server's answer can differ from what the client expects, and a second tab of the same user would never update.
  • Ignore out-of-order responses in the store you refresh, for example by applying only the response with the newest request counter.
  • Do not reload a field under a user who has its editor open; warn instead.

Test

At minimum, assert that the trigger was called:

subscriptions = EasyGraphql::AppSchema.subscriptions
allow(subscriptions).to receive(:trigger)

my_entity.update!(name: "New name")

expect(subscriptions).to have_received(:trigger).with("my_entity_changed", { my_entity_id: my_entity.id }, anything)