ASP.NET Core  

GraphQL with .NET & React | Part 4: Real-Time Data with Web Sockets

Series: GraphQL with .NET & React, From Zero to Production

Welcome to this comprehensive GraphQL tutorial series! By the end of these 5 articles, you'll have a solid understanding of GraphQL and hands-on experience building a full-stack application with React and .NET.

Get your code here: https://github.com/RikamPalkar/graphql-dotnet-react-from-zero-to-production

This is the one series to take you from GraphQL basics to advanced patterns.

Part 1: Foundations & Library Backend
Part 2: Query (Arguments, Aliases & Fragments)
Part 3: Mutations (Create, Update & Delete)
Part 5: Advanced Patterns (Pagination, Errors, JWT & Testing)

Part 4: Real-Time Data with Web Sockets

Welcome to Part 4! We've learned to read data with queries and modify it with mutations. Now comes one of GraphQL's most powerful features: subscriptions for real-time updates.

Where We Left Off

Our Library app can now:

  • Browse and search books

  • View detailed information with nested data

  • Add books, authors, and reviews

But there's a problem: if someone adds a review, other users don't see it until they refresh. That's not a great experience! Let's fix it with subscriptions.

What Are Subscriptions?

Subscriptions are a GraphQL operation type that maintains a persistent connection between client and server. When specific events happen, the server pushes data to all connected clients.

Analogy: Newsletter vs Checking the Website

Traditional (Polling)Subscriptions
Visit website every hour to check for updatesSubscribe to newsletter
You do the workUpdates come to you
Wasteful if nothing changedEfficient - only notified when there's news
Might miss time-sensitive updatesInstant notification

Real-World Use Cases

  • Chat applications - New messages appear instantly

  • Live sports scores - Updates as games progress

  • Stock prices - Real-time market data

  • Notifications - Alerts, mentions, system events

  • Collaborative editing - See others' changes live

  • Our Library app - See new reviews without refreshing

How Subscriptions Work

The Technology: WebSockets

Regular HTTP requests follow a pattern:

  1. Client sends request

  2. Server sends response

  3. Connection closes

Subscriptions need persistent connections - the connection stays open so the server can push data anytime. This is where WebSockets come in.

WebSockets:

  • Open a connection that stays alive

  • Two-way communication (client ↔ server)

  • Server can send data anytime without client asking

  • More efficient than constant polling

The Flow

1. Client opens WebSocket connection to /graphql
2. Client sends subscription query
3. Server acknowledges subscription
4. Connection stays open...
   
   [Later, when something happens:]
5. Server pushes data through the connection
6. Client receives and displays update
7. Repeat steps 5-6 for each event

GraphQL Subscription Protocol

GraphQL subscriptions use the graphql-ws protocol (sometimes called graphql-transport-ws).

Backend Setup

WebSocket Configuration

First, enable WebSockets in your ASP.NET Core app:

// Program.cs
var app = builder.Build();

// IMPORTANT: This must come BEFORE MapGraphQL!
app.UseWebSockets();

app.MapGraphQL();

app.Run();

Configure HotChocolate for Subscriptions

// Program.cs
builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddMutationType<Mutation>()
    .AddSubscriptionType<Subscription>()  // Add subscription type
    .AddInMemorySubscriptions();          // Store subscription state

What is AddInMemorySubscriptions()?

It configures where subscription state is stored. Options:

  • AddInMemorySubscriptions() - Single server, stores in memory (good for development)

  • AddRedisSubscriptions(...) - Multiple servers, stores in Redis (production scale)

For our learning project, in-memory is perfect.

Creating a Subscription Class

// GraphQL/Subscriptions/Subscription.cs
using HotChocolate;
using HotChocolate.Execution;
using HotChocolate.Subscriptions;
using HotChocolate.Types;
using LibraryApi.Models;

namespace LibraryApi.GraphQL.Subscriptions;

public class Subscription
{
    /// <summary>
    /// Subscribe to receive notifications when any new book is added
    /// </summary>
    [Subscribe]                           // Marks this as a subscription resolver
    [Topic(nameof(OnBookAdded))]          // The topic/channel name
    public Book OnBookAdded(
        [EventMessage] Book book)         // Data received from the event
    {
        return book;
    }
}

Breaking it down:

AttributePurpose
[Subscribe]Tells HotChocolate this is a subscription
[Topic]The "channel" name for this subscription
[EventMessage]Injects the data that was published

Publishing Events from Mutations

When a book is added, we publish to the subscription:

// In Mutation.cspublic async Task<AddBookPayload> AddBook(
    LibraryDbContext context,
    [Service] ITopicEventSender eventSender,  // Injected service for publishing
    AddBookInput input)
{
    // ... create book logic ...
    
    context.Books.Add(book);
    await context.SaveChangesAsync();
    
    // Publish event to all subscribers
    // Topic name must match the [Topic] attribute in Subscription class
    await eventSender.SendAsync(nameof(Subscription.OnBookAdded), book);
    
    return new AddBookPayload(book, null);
}

How ITopicEventSender works:

  • SendAsync(topicName, payload) - Sends data to a topic

  • All clients subscribed to that topic receive the payload

  • Think of it like a pub/sub message queue


Dynamic Topics (Book-Specific Subscriptions)

What if you only want updates for a specific book's reviews?

Backend Implementation

[Subscribe]
[Topic("OnReviewAdded_{bookId}")]  // Dynamic topic with parameter
public Review OnReviewAdded(
    int bookId,                    // Subscription argument
    [EventMessage] Review review)
{
    return review;
}

The {bookId} placeholder is replaced with the actual value when subscribing.

Publishing to Dynamic Topic

public async Task<AddReviewPayload> AddReview(
    LibraryDbContext context,
    [Service] ITopicEventSender eventSender,
    AddReviewInput input)
{
    // ... create review logic ...
    
    // Dynamic topic: includes the book ID
    await eventSender.SendAsync($"OnReviewAdded_{input.BookId}", review);
    
    return new AddReviewPayload(review, null);
}

Subscribing to Specific Book

subscription WatchBookReviews($bookId: Int!) {
  onReviewAdded(bookId: $bookId) {
    id
    title
    rating
    reviewerName
  }
}

Client A watching book #1 only gets reviews for book #1. Client B watching book #2 only gets reviews for book #2.

Frontend Setup

Understanding Apollo Link Architecture

Apollo Client uses a link chain to process GraphQL operations. Think of it like middleware:

Query/Mutation → HttpLink → Server
Subscription → WebSocketLink → Server

We need to tell Apollo: "Use WebSocket for subscriptions, HTTP for everything else."

Installing Dependencies

npm install graphql-ws

What is graphql-ws?

It's a library that implements the GraphQL over WebSocket protocol. It handles:

  • Connection management

  • Message serialization

  • Reconnection logic

  • Protocol compliance

Configuring Apollo Client

// src/graphql/client.ts
import { 
  ApolloClient, 
  InMemoryCache, 
  HttpLink,
  split           // For routing queries vs subscriptions
} from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';

// HTTP Link for queries and mutations
const httpLink = new HttpLink({
  uri: 'http://localhost:5000/graphql',
});

// WebSocket Link for subscriptions
const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:5000/graphql',  // Note: 'ws://' not 'http://'
    
    // Optional: Configure reconnection
    retryAttempts: 5,
    
    // Optional: Handle connection events
    on: {
      connected: () => console.log('WebSocket connected'),
      closed: () => console.log('WebSocket closed'),
      error: (err) => console.error('WebSocket error:', err),
    },
  })
);

// Split: Route to correct link based on operation type
const splitLink = split(
  ({ query }) => {
    // Get the operation type from the query
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'  // If subscription → use wsLink
    );
  },
  wsLink,   // If true (subscription) → WebSocket
  httpLink  // If false (query/mutation) → HTTP
);

// Create client with the split link
export const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(),
});

Understanding split():

  • Takes a condition function

  • Returns first link if true, second link if false

  • Essential for using both HTTP and WebSocket

Using Subscriptions in React

The useSubscription Hook

Apollo provides useSubscription for subscribing to events:

import { useSubscription, gql } from '@apollo/client';

const ON_BOOK_ADDED = gql`
  subscription OnBookAdded {
    onBookAdded {
      id
      title
      author {
        name
      }
    }
  }
`;

function BookNotifications() {
  const { data, loading, error } = useSubscription(ON_BOOK_ADDED);

  // loading is true only initially while connecting
  if (loading) return <p>Connecting to notifications...</p>;
  if (error) return <p>Subscription error: {error.message}</p>;

  // data contains the LATEST event (overwrites previous)
  if (data) {
    return (
      <div className="notification">
        New book added: {data.onBookAdded.title}
      </div>
    );
  }

  return <p>Waiting for new books...</p>;
}

Important: data only contains the most recent event, not a history.

Accumulating Events

To keep track of all events, maintain local state:

function AllBookNotifications() {
  const [newBooks, setNewBooks] = useState<Book[]>([]);
  
  const { error } = useSubscription(ON_BOOK_ADDED, {
    // onData is called for EACH event
    onData: ({ data }) => {
      const book = data.data?.onBookAdded;
      if (book) {
        setNewBooks(prev => [book, ...prev]);  // Add to beginning
      }
    }
  });

  return (
    <div>
      <h3>New Books ({newBooks.length})</h3>
      {newBooks.map(book => (
        <div key={book.id}>📚 {book.title}</div>
      ))}
    </div>
  );
}

Subscription with Variables

For book-specific reviews:

const ON_REVIEW_ADDED = gql`
  subscription OnReviewAdded($bookId: Int!) {
    onReviewAdded(bookId: $bookId) {
      id
      title
      rating
      reviewerName
    }
  }
`;

function BookReviewsLive({ bookId }: { bookId: number }) {
  const [reviews, setReviews] = useState<Review[]>([]);
  
  useSubscription(ON_REVIEW_ADDED, {
    variables: { bookId },
    onData: ({ data }) => {
      const review = data.data?.onReviewAdded;
      if (review) {
        setReviews(prev => [...prev, review]);
      }
    }
  });

  return (
    <div>
      <h4>Live Reviews</h4>
      {reviews.map(review => (
        <div key={review.id}>
          ⭐ {review.rating}/5 - {review.title} by {review.reviewerName}
        </div>
      ))}
    </div>
  );
}

Combining Query + Subscription

A common pattern: load initial data with a query, then subscribe for updates:

function BookDetail({ bookId }: { bookId: number }) {
  // Query: Get initial data
  const { data: queryData, loading } = useQuery(GET_BOOK_BY_ID, {
    variables: { id: bookId }
  });
  
  // State: Store all reviews
  const [reviews, setReviews] = useState<Review[]>([]);
  
  // Initialize reviews from query
  useEffect(() => {
    if (queryData?.bookById?.reviews) {
      setReviews(queryData.bookById.reviews);
    }
  }, [queryData]);
  
  // Subscription: Get new reviews in real-time
  useSubscription(ON_REVIEW_ADDED, {
    variables: { bookId },
    onData: ({ data }) => {
      const newReview = data.data?.onReviewAdded;
      if (newReview) {
        // Add to local state (avoids duplicates with ID check)
        setReviews(prev => {
          if (prev.some(r => r.id === newReview.id)) return prev;
          return [...prev, newReview];
        });
      }
    }
  });

  if (loading) return <p>Loading...</p>;

  return (
    <div>
      <h1>{queryData.bookById.title}</h1>
      
      <h3>Reviews ({reviews.length})</h3>
      {reviews.map(review => (
        <ReviewCard key={review.id} review={review} />
      ))}
    </div>
  );
}

Practical Example: Global New Book Banner

Let's add a banner that shows when any new book is added:

// components/NewBookBanner.tsx
import { useState, useEffect } from 'react';
import { useSubscription } from '@apollo/client';
import { ON_BOOK_ADDED } from '../graphql/subscriptions';
import type { Book } from '../types';

function NewBookBanner() {
  const [newBook, setNewBook] = useState<Book | null>(null);
  const [visible, setVisible] = useState(false);

  useSubscription(ON_BOOK_ADDED, {
    onData: ({ data }) => {
      const book = data.data?.onBookAdded;
      if (book) {
        setNewBook(book);
        setVisible(true);
      }
    }
  });

  // Auto-hide after 5 seconds
  useEffect(() => {
    if (visible) {
      const timer = setTimeout(() => setVisible(false), 5000);
      return () => clearTimeout(timer);
    }
  }, [visible, newBook]);

  if (!visible || !newBook) return null;

  return (
    <div className="new-book-banner">
      <span> New book added: </span>
      <strong>{newBook.title}</strong>
      {newBook.author && <span> by {newBook.author.name}</span>}
      <button onClick={() => setVisible(false)}>×</button>
    </div>
  );
}

// CSS for the banner
const styles = `
.new-book-banner {
  position: fixed;
  top: 20px;
  right: 20px;
  background: #4caf50;
  color: white;
  padding: 16px 24px;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  animation: slideIn 0.3s ease;
  z-index: 1000;
}

@keyframes slideIn {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}
`;

Connection Management

Handling Disconnections

WebSocket connections can drop. Handle reconnection gracefully:

// In client.ts
const wsLink = new GraphQLWsLink(
  createClient({
    url: 'ws://localhost:5000/graphql',
    retryAttempts: 10,  // Retry up to 10 times
    retryWait: async (retries) => {
      // Exponential backoff: 1s, 2s, 4s, 8s...
      await new Promise(resolve => 
        setTimeout(resolve, Math.min(1000 * Math.pow(2, retries), 30000))
      );
    },
  })
);

Connection Status Component

Show users when they're disconnected:

function ConnectionStatus() {
  const [connected, setConnected] = useState(true);
  
  // Note: This requires custom setup with graphql-ws client
  // The actual implementation depends on your client configuration
  
  if (connected) return null;
  
  return (
    <div className="connection-warning">
      ⚠️ Connection lost. Trying to reconnect...
    </div>
  );
}

Subscription vs Polling

When to Use Subscriptions

Use subscriptions for:

  • Events that require immediate user action

  • Chat messages, notifications

  • Collaborative features (multiple users editing)

  • Frequently changing data that users are actively watching

When to Use Polling Instead

Sometimes polling (periodic queries) is simpler:

const { data } = useQuery(GET_BOOKS, {
  pollInterval: 10000,  // Refetch every 10 seconds
});

 Use polling for:

  • Dashboards with periodic updates

  • Data that doesn't need instant updates

  • When WebSocket infrastructure is complex

  • As a fallback when subscriptions aren't available

Comparison

AspectSubscriptionsPolling
LatencyInstantUp to poll interval
Server loadLower (event-based)Higher (constant requests)
ComplexityHigher (WebSocket setup)Lower
Offline handlingRequires reconnection logicAutomatic with HTTP

Testing Subscriptions

Manual Testing

  1. Open the app in two browser windows

  2. In window A: Navigate to book list

  3. In window B: Add a new book

  4. Watch window A: Should show notification!

Using Banana Cake Pop

HotChocolate's built-in IDE supports subscriptions:

  1. Go to http://localhost:5000/graphql

  2. Run a subscription query:

    subscription {
      onBookAdded {
        id
        title
      }}
  3. Open another tab and run an addBook mutation

  4. Watch the subscription tab receive the event!

Key Takeaways

  1. Subscriptions - Real-time updates via persistent connections

  2. WebSockets - Technology enabling two-way communication

  3. Topics - Channels that group related events

  4. ITopicEventSender - Backend service for publishing events

  5. [Subscribe] / [Topic] - HotChocolate attributes for subscriptions

  6. useSubscription - Apollo React hook for subscribing

  7. split - Routes subscriptions to WebSocket, others to HTTP

  8. Dynamic topics - OnReviewAdded_{bookId} for filtered subscriptions

  9. Code: graphql-dotnet-react-from-zero-to-production

What's Next?

In Part 5: Advanced Topics, we'll cover:

  • Cursor-based pagination

  • Error handling strategies

  • Authentication and authorization

  • Performance optimization with DataLoaders

  • Testing GraphQL APIs

Exercises

  1. Add a subscription for when a book is deleted

  2. Implement a live "users online" counter

  3. Create a subscription that notifies when book availability changes

  4. Add sound/visual notification when new events arrive

  5. Implement reconnection indicator UI

See you in Part 5!