GraphQL Subscriptions in Ruby

Lesson Overview

Welcome to this lesson on setting up subscriptions for real-time data. In this lesson, we will discuss real-time data and subscriptions to events in GraphQL using graphql-ruby and Sinatra.

GraphQL subscriptions enable clients to listen for real-time updates from the server. When an event that matches the subscription's criteria occurs, the server sends the updated data to the client automatically.

This is how subscriptions differ from Queries and Mutations:

  • Queries: Request data from the server.
  • Mutations: Modify data on the server.
  • Subscriptions: Receive updates whenever data is changed as specified.

Setting Up Subscriptions with graphql-ruby

Let's begin by setting up our server to handle subscriptions using graphql-ruby and Sinatra. We'll need to install the necessary gems first:

Shell
gem install graphql sinatra sinatra-contrib puma faye-websocket

We'll start by creating the basic structure for our subscription system. In graphql-ruby, subscriptions use a publish-subscribe pattern, where events are published to subscribers through triggers.

Ruby
require 'sinatra'
require 'sinatra/json'
require 'graphql'
require 'faye/websocket'
require 'json'
require 'securerandom'

# In-memory storage for books
# We use global variables (prefixed with $) here so that
# the data is accessible across all classes and routes in
# our application. In a production app, you would use a
# database instead — global variables are only suitable
# for simple examples like this because they are mutable
# from anywhere, which can make code harder to reason
# about and is not thread-safe.
$books = [
  { id: '1', title: 'The Hobbit', author: 'J.R.R. Tolkien' },
  { id: '2', title: 'Harry Potter', author: 'J.K. Rowling' }
]

# In-memory subscription storage
$subscriptions = {}

A Quick Note on GraphQL Variables

Before diving into the schema, let's introduce GraphQL variables — a concept we'll use later in our client code.

So far in this course, we've been passing argument values directly inside query strings (inline). GraphQL also supports variables, which let you define parameters separately from the query. This keeps queries reusable and avoids messy string interpolation.

Here's the syntax. The query declares the variables and their types with a $ prefix, and the actual values are passed in a separate variables object:

Ruby
# The query declares variables with $ and their types
query = <<~GRAPHQL
  mutation($title: String!, $author: String!) {
    addBook(title: $title, author: $author) {
      id
      title
      author
    }
  }
GRAPHQL

# The values are passed separately
variables = { title: "1984", author: "George Orwell" }

# Both are sent together in the request body
request.body = JSON.generate({ query: query, variables: variables })

The ! after the type (e.g., String!) means the variable is required — the server will reject the request if it's missing. We'll use this pattern in the client code later in this lesson.

Defining Schema with Subscriptions

In graphql-ruby, we define types using Ruby classes. Let's create our Book type, Query type, Mutation type, and, importantly, our Subscription type.

Ruby
# Define the Book type
class BookType < GraphQL::Schema::Object
  field :id, ID, null: false
  field :title, String, null: false
  field :author, String, null: false
end

# Define the Query type
class QueryType < GraphQL::Schema::Object
  field :books, [BookType], null: false

  def books
    $books
  end
end

# Define the Mutation type
class MutationType < GraphQL::Schema::Object
  field :add_book, BookType, null: false do
    argument :title, String, required: true
    argument :author, String, required: true
  end

  def add_book(title:, author:)
    new_book = {
      id: SecureRandom.uuid,
      title: title,
      author: author
    }
    $books << new_book
    
    # Trigger the subscription — this notifies all clients
    # currently subscribed to 'book_added' that a new book
    # has been created. We'll define MySchema shortly in the
    # next section; Ruby allows forward references like this
    # because the call is inside a method body, which isn't
    # evaluated until the method is actually called at runtime.
    MySchema.subscriptions.trigger('book_added', {}, new_book)
    
    new_book
  end
end

# Define the Subscription type
class SubscriptionType < GraphQL::Schema::Object
  field :book_added, BookType, null: false

  def book_added
    object
  end
end

The Subscription type defines a book_added field, which is of type Book. When a new book is added, all clients subscribed to book_added will receive the update.

Configuring the Schema and Subscription Backend

Now we need to create our schema and configure it to handle subscriptions. In graphql-ruby, subscriptions require a backend that manages which clients are subscribed to which events. When a mutation triggers a subscription event, this backend is responsible for delivering the update to every subscribed client.

For this example, we'll build a simple in-memory subscription backend. The GraphQL::Subscriptions base class requires us to implement several methods, each with a specific role:

Ruby
# Simple in-memory subscription implementation
class InMemorySubscriptions < GraphQL::Subscriptions
  def initialize(**rest)
    super
    @subscriptions = {}
    @mutex = Mutex.new
  end

  # Called when a client subscribes. Stores the subscription
  # query so it can be re-executed when an event is triggered.
  def write_subscription(query, events)
    @mutex.synchronize do
      events.each do |event|
        @subscriptions[event.topic] ||= []
        @subscriptions[event.topic] << {
          query: query,
          variables: event.arguments
        }
      end
    end
  end

  # Called when an event is triggered (e.g., a book is added).
  # Iterates over all subscribers for that event so each one
  # can receive the update.
  def each_subscription_id(event)
    @mutex.synchronize do
      subscribers = @subscriptions[event.topic] || []
      subscribers.each_with_index do |subscription, index|
        yield index.to_s
      end
    end
  end

  # Retrieves a stored subscription by its ID so the query
  # can be re-executed with the new data.
  def read_subscription(subscription_id)
    topic = @subscriptions.keys.first
    @subscriptions[topic][subscription_id.to_i] if topic
  end

  # Removes a subscription when a client unsubscribes.
  def delete_subscription(subscription_id)
    @mutex.synchronize do
      @subscriptions.each do |topic, subscribers|
        subscribers.delete_at(subscription_id.to_i)
      end
    end
  end

  # Delivers the result of a triggered event to the client.
  # In this simple implementation, we store the result for
  # later retrieval. A production system would push the
  # result directly to the client over a WebSocket.
  def deliver(subscription_id, result)
    @mutex.synchronize do
      @subscriptions[:results] ||= {}
      @subscriptions[:results][subscription_id] = result
    end
  end
end

Now define the schema, tying together our query, mutation, and subscription types, and configuring it to use our in-memory subscription backend:

Ruby
class MySchema < GraphQL::Schema
  query QueryType
  mutation MutationType
  subscription SubscriptionType
  
  use InMemorySubscriptions
end

Here, when a book is added using the add_book mutation, the new book data is sent to all clients subscribing to the book_added subscription through the MySchema.subscriptions.trigger method.

Integrating WebSocket for Real-Time Updates

WebSockets provide a way for a server and a client to communicate in real time over a single, long-lived connection. Unlike regular HTTP requests (which follow a request-response pattern), a WebSocket connection stays open, allowing the server to push data to the client at any time. This is crucial for handling subscriptions.

We'll integrate WebSockets into our Sinatra application to handle subscriptions. The server and client communicate using a simple message protocol with three message types:

  • 'start' — sent by the client to begin a subscription, including the GraphQL query as its payload.
  • 'data' — sent by the server to deliver results (both initial responses and real-time updates).
  • 'stop' — sent by the client to unsubscribe and stop receiving updates.
Ruby
set :server, 'puma'
set :sockets, []

# HTTP endpoint for queries and mutations
post '/graphql' do
  request_payload = JSON.parse(request.body.read)
  query = request_payload['query']
  variables = request_payload['variables'] || {}
  
  result = MySchema.execute(
    query,
    variables: variables,
    context: {}
  )
  
  json result
end

# WebSocket endpoint for subscriptions
get '/graphql' do
  if Faye::WebSocket.websocket?(request.env)
    ws = Faye::WebSocket.new(request.env)
    
    ws.on :open do |event|
      settings.sockets << ws
    end
    
    ws.on :message do |event|
      data = JSON.parse(event.data)
      
      if data['type'] == 'start'
        # Client wants to start a subscription (or run a query).
        query = data['payload']['query']
        variables = data['payload']['variables'] || {}
        
        result = MySchema.execute(
          query,
          variables: variables,
          context: { socket: ws },
          # operation_name identifies which operation to run when
          # a query string contains multiple named operations. For
          # single-operation queries (like ours), this can be nil.
          operation_name: data['payload']['operationName']
        )
        
        # result.subscription? returns true if the executed query
        # was a subscription. In that case, the client is now
        # registered and will receive future updates via the
        # deliver method. We still send an initial acknowledgment.
        if result.subscription?
          ws.send(JSON.generate({
            type: 'data',
            id: data['id'],
            payload: { data: result.to_h['data'] }
          }))
        else
          ws.send(JSON.generate({
            type: 'data',
            id: data['id'],
            payload: result.to_h
          }))
        end
      elsif data['type'] == 'stop'
        # Client wants to unsubscribe
      end
    end
    
    ws.on :close do |event|
      settings.sockets.delete(ws)
      ws = nil
    end
    
    ws.rack_response
  else
    status 400
    body 'WebSocket connection required'
  end
end

When you run this code with ruby server.rb, your server should be ready to handle real-time subscriptions at ws://localhost:4000/graphql.

Requesting Subscriptions after Setting Up the Server

After setting up the server to handle subscriptions, it's essential to know how to request and subscribe to real-time data updates. Below, we will provide step-by-step instructions for setting up a client to request subscriptions.

First, install the required gems for the client:

Shell
gem install faye-websocket eventmachine

Create a client file to test subscriptions:

Ruby
require 'faye/websocket'
require 'eventmachine'
require 'json'
require 'net/http'
require 'uri'

# Define the GraphQL endpoint
GRAPHQL_ENDPOINT = 'http://localhost:4000/graphql'
WEBSOCKET_ENDPOINT = 'ws://localhost:4000/graphql'

GraphQL Queries and Mutations

Define the queries and mutations that will be used in the client application. Note that ADD_BOOK_MUTATION uses GraphQL variables ($title and $author) as introduced earlier in this lesson, keeping the query reusable:

Ruby
GET_BOOKS_QUERY = <<~GRAPHQL
  query {
    books {
      id
      title
      author
    }
  }
GRAPHQL

ADD_BOOK_MUTATION = <<~GRAPHQL
  mutation($title: String!, $author: String!) {
    addBook(title: $title, author: $author) {
      id
      title
      author
    }
  }
GRAPHQL

BOOK_ADDED_SUBSCRIPTION = <<~GRAPHQL
  subscription {
    bookAdded {
      id
      title
      author
    }
  }
GRAPHQL

Helper Function for Sending GraphQL Requests

Create a function to facilitate sending GraphQL requests using net/http:

Ruby
def fetch_graphql(query, variables = {})
  uri = URI.parse(GRAPHQL_ENDPOINT)
  http = Net::HTTP.new(uri.host, uri.port)
  
  request = Net::HTTP::Post.new(uri.path)
  request['Content-Type'] = 'application/json'
  request.body = JSON.generate({
    query: query,
    variables: variables
  })
  
  response = http.request(request)
  result = JSON.parse(response.body)
  
  if result['errors']
    raise "GraphQL error: #{result['errors'].map { |e| e['message'] }.join(', ')}"
  end
  
  result['data']
end

Setting Up WebSocket for Subscriptions

Initialize the WebSocket client and set up the subscription for real-time updates. We use EventMachine (EM), a Ruby library that provides an event-driven I/O loop. WebSocket connections are long-lived and asynchronous — the client needs to stay running and react to incoming messages at any time. EventMachine provides this event loop, letting our code register callbacks (like on :open and on :message) that fire when events occur, rather than blocking in a linear flow:

Ruby
def setup_subscription
  # EM.run starts the EventMachine event loop. All WebSocket
  # communication happens inside this block. The loop keeps
  # running until we call EM.stop.
  EM.run do
    ws = Faye::WebSocket::Client.new(WEBSOCKET_ENDPOINT)
    subscription_id = '1'
    
    ws.on :open do |event|
      puts 'WebSocket connection opened'
      
      # Send a 'start' message to subscribe
      ws.send(JSON.generate({
        id: subscription_id,
        type: 'start',
        payload: {
          query: BOOK_ADDED_SUBSCRIPTION,
          variables: {}
        }
      }))
    end
    
    ws.on :message do |event|
      data = JSON.parse(event.data)
      
      # The server sends 'data' messages with subscription updates
      if data['type'] == 'data' && data['payload']['data']
        book = data['payload']['data']['bookAdded']
        puts "Book added: #{book}"
        
        # Send a 'stop' message to unsubscribe
        ws.send(JSON.generate({
          id: subscription_id,
          type: 'stop'
        }))
        
        sleep 1
        ws.close
        EM.stop
      end
    end
    
    ws.on :error do |event|
      puts "WebSocket error: #{event.message}"
      EM.stop
    end
    
    ws.on :close do |event|
      puts 'WebSocket connection closed'
      ws = nil
    end
  end
end

Executing Queries and Mutations

Execute the defined queries and mutations:

Ruby
# Fetch books
begin
  books_data = fetch_graphql(GET_BOOKS_QUERY)
  puts "Books: #{books_data['books']}"
rescue => e
  puts "Error fetching books: #{e.message}"
end

# Add a new book
begin
  add_book_data = fetch_graphql(ADD_BOOK_MUTATION, { title: '1984', author: 'George Orwell' })
  puts "Added book: #{add_book_data['addBook']}"
rescue => e
  puts "Error adding book: #{e.message}"
end

# Set up subscription to listen for new books
setup_subscription

This client code demonstrates how to:

  1. Query existing books.
  2. Add a new book via mutation.
  3. Subscribe to real-time updates when books are added.

Summary

In this lesson, we:

  • Discussed real-time data and its importance.
  • Introduced GraphQL subscriptions and compared them with Queries and Mutations.
  • Introduced GraphQL variables for parameterized queries.
  • Set up graphql-ruby and Sinatra with subscriptions.
  • Defined schema and configured a subscription backend using Ruby classes.
  • Integrated WebSockets with EventMachine for real-time updates.

You're now ready to move on to the practice exercises. These will help you solidify your understanding by applying what you've learned hands-on.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal