tags

Types of Realtime GraphQL Subscriptions

19 August, 2022 | 14 min read

GraphQL has a particular type of operation, called subscription, that enables you to add real-time or reactive features to your applications.

How does a GraphQL Subscription work? The client subscribes to a particular event on the server, and whenever that event occurs, the server pushes the update to the client.

An event can represent data creation, modification, or deletion. For instance, let’s say you have a voting application. You can set up a subscription to notify you when someone votes. Here is a subscription example:

subscription votes {
  vote {
    created_at
    option {
      poll {
        question
      }
      text
    }
  }
}

The subscription returns the updated results whenever a new vote is inserted into the database.

How are GraphQL Subscriptions implemented? They use the WebSocket protocol to keep a persistent connection between the client and the server. The connection stays open until either party terminates it.

Types of Subscriptions

Hasura comes with two types of subscriptions - live queries and streaming subscriptions.

Live Queries

Live queries represent one way of retrieving real-time data and are a pattern of GraphQL subscriptions.

You can see an example of a live query below:

query @live {
  vote {
    created_at
    option {
      poll {
        question
      }
      text
    }
  }
}

The structure looks similar to a regular query, but it uses the @live directive and works differently under the hood.

Rather than observing specific events, a live query observes data. Live queries react to changes in the latest query result. The server sends the updated result to the client whenever the result changes.

Streaming Subscriptions

Hasura also supports streaming over GraphQL subscriptions, known as Streaming Subscriptions.

With a streaming subscription, the client opens a new connection and specifies an offset (or cursor value). The subscription then sends ordered data from that offset. The offset acts as a starting point.

It’s recommended for the offset (or cursor value) to represent unique or sortable values. The server keeps track of each subscription offset, and after streaming the data, it updates the cursor. This way, the client receives each piece of data exactly once. In other words, it avoids duplicate data. It also acts as some sort of state that ensures data does not get lost from the stream.

In other words, streaming subscriptions uses WebSockets and GraphQL subscriptions to stream data from a starting point specified by the client.

Streaming Subscriptions vs. Live Queries

Subscriptions react to specific events, whereas live queries observe data. A subscription reacts to an event like a record insertion, whereas the live query reacts to data changes.

Their syntax differs as well. The live queries use the @live directive, which is not present in the subscription syntax. It’s important to note that live queries in Hasura do not use the @live or any other directive. In Hasura, live queries are just a pattern in which you fetch the entire result of a given query every single time, mimicking the liveness of it.

Lastly, the streaming subscriptions have some sort of state, whereas the live queries do not. The offset in streaming subscriptions allows the client to store the state of where they are reading from. With live queries, the client subscribes and gets the latest values.

@defer and @stream

The @defer and @stream directives represent another way for GraphQL servers to stream data to the client. They enable GraphQL servers to split the requested data into multiple priority levels. This way, the GraphQL server can stream the critical data first and resolve the rest in separate response payloads.

Consider the following example:

query AuthorQuery($id: String) {
    author(id: "8j32hugjijgreij") {
        name
        bio
        avatar
        ...SocialMediaLinks @defer(label: "socialMediaLinksFragment")
    }
}

fragment SocialMediaLinks on Author {
    socialMediaLinks {
        twitter
        instagram
        youtube
    }
}

The use of the @defer directive on the SocialMediaLinks fragment indicates that it has a lower priority than the other fields. As a result, the fragment is excluded from the initial response and sent in the subsequent response payloads.

With the @defer directive, you can specify the query parts that are not essential and can be delivered later. It’s important to mention that the @defer directive can only be used on fragments.

The @stream directive works similarly, but it can be applied only to fields of type List.

Consider the following example:

query AuthorQuery($id: String) {
    author(id: "8j32hugjijgreij") {
        name
        bio
        avatar
        articles @stream(initialCount: 1, label: "articlesStream") {
            title
            url
            brief
        }
    }
}

The use of the @stream directive on the articles field means that it only returns one article in the initial response payload. The initialCount argument specifies how many records to return in the first payload.

The rest of the articles are resolved asynchronously and returned in the following payloads.

The @stream and defer directives solve the problem where the GraphQL response is sent only after the whole request is completed. With these two directives, you can send part of the data to the client in the initial response and send the rest in subsequent payloads.

GraphQL Application

To put the new learnings into practice, you will build an application that uses GraphQL subscriptions. The application is split into two parts:

  • the GraphQL server - you build a server from scratch
  • the client - you build a client that consumes data from the server

GraphQL Server Implementation

You will build the GraphQL server with Node.js, Express, and Apollo. The server does not use a database to avoid introducing extra complexity to the tutorial. It uses a JSON file to act as a database.

Start by creating the project structure as follows:

mkdir graphql-app

cd graphql-app

mkdir src

touch src/index.js src/schema.js src/resolvers.js data.js

After running the above commands, you will have 4 files inside the src folder.

The next step is initializing the Node.js project and installing the required dependencies. Run the following commands in the graphql-app folder:

npm init -y

npm i express graphql graphql-subscriptions apollo-server-express apollo-server-core graphql-ws ws @graphql-tools/schema

npm i nodemon --save-dev

These dependencies are all required to build and run the GraphQL server. Before going further, open package.json and replace the "scripts" object with the following code:

"scripts": {
    "start": "nodemon src/index.js"
 }

This way, you do not have to stop and restart the server each time you make a change. The server automatically refreshes and shows the new changes.

Server code

Now that the project is set up, it’s time to build the server. Open the file index.js and write the following code:

// GraphQL Server - index.js

const { ApolloServer } = require("apollo-server-express");
const { ApolloServerPluginDrainHttpServer } = require("apollo-server-core");
const { makeExecutableSchema } = require("@graphql-tools/schema")
const { WebSocketServer } = require("ws");
const { useServer } = require("graphql-ws/lib/use/ws");
const express = require("express");
const http = require("http");
const typeDefs = require("./schema");
const resolvers = require("./resolvers");

// from docs
// https://www.apollographql.com/docs/apollo-server/integrations/middleware/#swapping-out-apollo-server
// https://www.apollographql.com/docs/apollo-server/data/subscriptions
async function startServer() {
    const app = express();
    const httpServer = http.createServer(app);
    const wsServer = new WebSocketServer({
        server: httpServer,
        path: '/graphql'
    });

    const schema = makeExecutableSchema({ typeDefs, resolvers });

    const serverCleanup = useServer({ schema }, wsServer);

    const server = new ApolloServer({ 
        schema,
        plugins: [
            ApolloServerPluginDrainHttpServer({ httpServer }),
            {
                async serverWillStart() {
                    return {
                        async drainServer() {
                            await serverCleanup.dispose();
                        }
                    }
                }
            }
        ]
    });

    await server.start();

    server.applyMiddleware({ app });

    httpServer.listen({ port: 4000 }, () => {
        console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`);
    });
}

startServer();

What’s happening in the above code?

Firstly, you import the required packages, schema, and resolvers at the top of the file.

After that, you create an Express app and an httpServer. The httpServer is required to set up the HTTP and WebSocket servers, and it takes the Express app as an argument.

Going further, you create a new WebSocket server using the httpServer created previously.

You also need to build a schema from the type definitions and resolvers. That's what the method makeExecutableSchema allows you to do. The WebSocket server and ApolloServer then use the schema.

After that, you use the userServer method to start the WebSocket server with the schema created previously.

Lastly, you create the Apollo server.

GraphQL Schema

It’s time to define the GraphQL schema for this application. Open the schema.js file and add the following code:

// GraphQL Schema - schema.js

const { gql } = require("apollo-server-express");

const typeDefs = gql`
    type Note {
        id: ID!
        title: String!
        content: String!
        username: String!
    }

    type Query {
        notes: [Note]
        note(id: Int): Note 
    }

    type Mutation {
        addNote(id: ID!, title: String!, content: String!, username: String!): Note
    }

    type Subscription {
        noteCreated: Note
    }
`;

module.exports = typeDefs;

The above code describes the shape of the data. You can see that it describes what a "Note" looks like. It also specifies the available queries and the mutation a client can perform.

In this case, the client can:

  • retrieve all notes
  • retrieve a specific note
  • add a new note
  • get updates when a new note is added

GraphQL Resolvers

Previously, you defined the available operations but did not implement the logic. For example, when someone uses the mutation addNote, how should it behave?

That's what you will do now - you will implement the GraphQL resolvers. In the simplest terms, the GraphQL resolvers implement the logic required to resolve the queries, mutations, and subscriptions.

// GraphQL Resolvers - resolvers.js

const { PubSub } = require("graphql-subscriptions");
const notes = require("./data");

const pubsub = new PubSub();

const resolvers = {
    Query: {
        notes() {
            return notes;
        },
        note: (_, args) => {
            const note = notes.find((note) => note.id === args.id);
            return note;
        }
    },
    Mutation: {
        addNote: (_, args) => {
            const newNote = {
                id: args.id,
                title: args.title,
                content: args.content,
                username: args.username
            }

            notes.push(newNote);
            pubsub.publish("NOTE_CREATED", { noteCreated: args })

            return newNote;
        }
    },
    Subscription: {
        noteCreated: {
            subscribe: () => pubsub.asyncIterator(["NOTE_CREATED"])
        },
    }
}

module.exports = resolvers;

In the above code, you use the PubSub class from the graphql-subscriptions package. The PubSub stands for "publish/subscribe", which is a messaging system. The system allows the publishers to broadcast messages asynchronously to the subscribers interested in a specific event.

In this example, the clients can subscribe to the NOTE_CREATED event and receive updates whenever a new note is added.

It’s important to mention that the PubSub class is intended only for development purposes. It’s not recommended for production. You can read more here.

Dummy data

The last step is to add some dummy data to the data.js file. Open the file and add the following code:

// Dummy data - data.js

let notes = [
    {
        "id": 1,
        "title": "Learn React Hooks",
        "content": "This is a reminder to continue learning about React hooks",
        "username": "John Doe"
    },
    {
        "id": 2,
        "title": "Learn Hasura",
        "content": "Learn how to build a backend with Hasura",
        "username": "Kris Noel"
    }
];

module.exports = notes;

Now you have some initial data for the application to work with.

Test the application

Start the application by running npm start in the "graphql-app" directory. After it starts, you can access the server at http://localhost:4000/graphql.

You can use the Apollo studio to query and mutate the data. You can also subscribe to new updates.

Apollo Studio showing the application

The above image shows an example of querying all the notes. Play around and try out the other queries, mutations, and subscriptions.

GraphQL Client

The last step involves building the front-end client with React. Run the following commands inside the graphql-app folder:

npx create-react-app client

npm install @apollo/client graphql graphql-ws

The first command creates a React project and the second command installs the necessary packages. After the installation finishes, modify the project to look as follows:

📂 graphql-app
 └ 📂 client
   └ 📁 node_modules
     📁 package-lock.json
     📁 package.json
     📁 public
     📁 README.md
     └ .env
     📂 src
      └ App.css
      └ App.js
      └ apolloClient.js
      └ index.css
      └ index.js
      └ 📂 components
        └ InitialNotes.jsx
        └ LatestNotes.jsx
        └ Note.jsx

Now that you have the project structure in place, you can start by configuring the Apollo client. Open the apolloClient.js file and add the following code:

// Apollo client configuration - apolloClient.js

import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';

const httpLink = new HttpLink({
    uri: 'http://localhost:4000/graphql'
  });

const wsLink = new GraphQLWsLink(createClient({
    url: 'ws://localhost:4000/graphql'
}));

const splitLink = split(
    ({ query }) => {
      const definition = getMainDefinition(query);
      return (
        definition.kind === 'OperationDefinition' &&
        definition.operation === 'subscription'
      );
    },
    wsLink,
    httpLink,
);

const client = new ApolloClient({
    link: splitLink,
    cache: new InMemoryCache()
});

export default client;

Since the application supports subscriptions, you must set up a WebSocket connection. Queries and mutations use the HTTP protocol, but the subscriptions use the WebSocket protocol.

You also have a split function that checks the operation type to determine what transport protocol to use. If it’s a query or mutation, it uses the HTTP link or the WebSocket link if it’s a subscription.

Lastly, you create the Apollo client and export it so you can use it in other places.

Note component

You need to create the component that renders one note. Open the src/Note.jsx file and add the following code:

// Note component - note.jsx

import "../App.css";

function Note({ note }) {
    return (
        <div className="card">
            <h2>{note.title}</h2>
            <p>{note.content}</p>
            <p className="author">by {note.username}</p>
        </div>
    )
}

export default Note;

You will use this component in the InitialNotes.jsx and LatestNotes.jsx files.

Initial notes

When you built the GraphQL server, you added some dummy data - 2 example notes. The InitialNotes.jsx component is the one that renders those notes.

// Initial notes - InitialNotes.jsx

import { gql, useQuery } from "@apollo/client";
import Note from "./Note";

const ALL_NOTES = gql`
    query {
        notes {
            id
            title
            content
            username
        }
    }
`;

function InitialNotes() {
    const { data, loading, error } = useQuery(ALL_NOTES);

    if (loading) return <div>Loading...</div>

    if (error) return <div>Error occured...</div>

    const notes = data.notes;

    return (
        <div>
            { notes.map((note) => (
                <Note key={note.id} note={note} />
            )) }
        </div>
    )
}

export default InitialNotes;

The component uses the useQuery hook from the Apollo client to execute the ALL_NOTES query. It then renders each note on the page.

Latest notes

You also need a component to display the latest notes added to the database. That's the purpose of the LatestNotes.jsx component.

The component uses the useSubscription hook to execute the LATEST_NOTE subscription. Whenever the hook receives data, it updates the allNotes state with the new data. Then it renders the latest notes on the page.

// Latest notes - LatestNotes.jsx

import { gql, useSubscription } from "@apollo/client";
import { useState } from "react";
import Note from "./Note";
import '../App.css';

const LATEST_NOTE = gql`
  subscription {
    noteCreated {
      id
      title
      content
      username
    }
  }
`;

function LatestNotes() {
  const [ allNotes, setAllNotesState ] = useState([]);

  const { data, error, loading } = useSubscription(LATEST_NOTE, {
    onSubscriptionData: ({ subscriptionData: { data } }) => {
      setAllNotesState((state) => [...state, data.noteCreated])
    }
  });

  if (error) return <div>Error occured...</div>

  return (
    <div>
      { allNotes.map((note) => (
        <Note key={note.id} note={note} />
      )) }
    </div>
  )
}

export default LatestNotes;

Each time a new note is added, the LatestNotes.jsx component renders it on the page.

Update index.js and .env

Before running the application, replace the content of index.js with the following code:

import { ApolloProvider } from "@apollo/client";
import client from "./apolloClient";
import LatestNotes from "./components/LatestNote";
import InitialNotes from "./components/InitialNotes";
import './App.css';

function App() {
  return (
    <ApolloProvider client={ client }>
      <div className="App">
        <h1 className="title">Notes</h1>
        <div>
          <InitialNotes/>
          <LatestNotes/>
        </div>
      </div>
    </ApolloProvider>
  );
}

export default App;

Now open the .env file and add the following line:

PORT=1234

Save the files, and the client code is done!

Test the application

Start the client by running npm start in the client directory. Also, make sure the GraphQL server is running.

Once the app starts, you can navigate to http://localhost:1234/ to test the application.

The application page

As you can see, it renders the initial notes on the page. You can test the subscription by going to the GraphQL server and adding a new note, as shown in the image below.

Adding a new note with a mutation in Apollo Studio

You can copy the mutation and variables from this gist.

You should see the new note displayed if you visit the client after performing the mutation. The image below shows the webpage with the new note.

The application showing the new note

To see the subscriptions in action, open the server and client in separate browser tabs and put them side by side. Then add new notes, and you will see them displayed on the page in real-time.

You are done, congratulations! You learned about the different types of subscriptions and built an application that shows them in action.

Custom GraphQL Server vs. Hasura

In this article, you built a GraphQL server manually. Even if the server is relatively basic, you had to use eight packages and write quite a bit of code. Imagine when the server gets more complex.

So what if I told you there is another way to build a GraphQL API? You can use Hasura, a GraphQL Engine that turns your database into an instant, real-time GraphQL API.

In simpler terms, Hasura connects to the provided database, and it automatically generates an API based on the tables and views. It gives you GraphQL schema and resolvers out of the box without writing any backend code.

To see the difference between a custom-built GraphQL API and Hasura, you will implement the same API with Hasura.

Build the GraphQL API with Hasura

The fastest and easiest way to use Hasura is via the Hasura Cloud. Click the button to sign-up:

Deploy to Hasura button

After that, create a new Hasura project and launch the console. The figure below illustrates what you should see.

Hasura project console

The next step involves connecting the application to a database. You can connect your app to an existing database or a new one.

In this example, the application uses a new database.

Connect Hasura to database

Click the button saying "Create Database" to add a new database.

After adding the database, navigate to "Data -> Create table" to create a new table. Create the table note with the following columns:

  • id of type UUID (Primary Key)
  • title of type Text
  • content of type Text
  • username of type Text

Create a new table in Hasura

Save the table and you are done! You have a GraphQL API, which you can use to query and mutate data. It also comes with GraphQL subscriptions by default.

If you need help with getting started on the cloud, adding a database, or creating a table, check the documentation.

Test the API

Let's test the API by adding, reading, and subscribing to notes. Go to the "API" tab and perform the following mutation:

mutation add_note {
  insert_note_one(object: {
    title: "Learn Hasura", 
    content: "Learn how to build a GraphQL API with Hasura", 
    username: "John Doe"
  }) {
    id
  }
}

The image below illustrates the mutation in action. Looking at the "Explorer" tab, you can see all the available mutations. You can add, update and delete notes.

GraphQL mutation in Hasura

Now let's query the database to see the note added previously. Run the following query:

query get_notes {
  note {
    id
    title
    content
    username
  }
}

The image illustrates the query in action. You can see it returns the note you added previously.

GraphQL query in Hasura

Lastly, you can test the subscriptions. Run this subscription in GraphiQL:

subscription notes_subscription {
  note {
    id
    title
    content
    username
  }
}

Now open the project console in another tab and perform a mutation. If you check the subscription, you should see the new note.

GraphQL subscriptions in Hasura

At this point, you tried all the GraphQL operations: queries, mutations, and subscriptions. You have all those operations out of the box without writing a single line of code.

Comparison

The first noticeable difference between a custom-built GraphQL API and Hasura is that you do not need to write any code with Hasura. You have an API up and running in just a few clicks. You do not have to create things like the GraphQL schema and resolvers yourself.

Hasura also comes with real-time capabilities, such as GraphQL subscriptions and live queries by default. You do not need extra configuration.

In case you need custom business logic or you need to plug in your existing GraphQL/REST APIs, you can do it through Remote Schemas and Actions.

Moreover, Hasura has other features such as authorization, caching, rate limiting, monitoring, and analytics. You can see the complete list of features here.

Comparing the two, we can conclude that building a GraphQL API with Hasura is faster, and it also comes with many features out of the box. It speeds up the development process considerably and lets you focus on more important things.

Webinar: Instant streaming APIs on Postgres, with built-in auth
View Recording
Hasura Con
The Hasura User Conference | June 28 - 30, 2022
New Product Announcements
View Recordings


Subscribe IlluSubscribe Illu

Monthly product updates in your inbox. No spam.

Loading...