Designing a Live Scoreboard: From Setup to SSE Endpoint

When real-time features are needed in Angular applications, my default choice has typically been SignalR. However, a recent conversation introduced me to Server-Sent Events (SSE)—a concept I hadn't explored before. My curiosity led me to investigate, and I was impressed by both its simplicity and its effectiveness in certain use cases.

SSE enables servers to push updates or continuous data streams to browsers over standard HTTP. It leverages the built-in EventSource JavaScript API, making server-to-client communication straightforward without requiring additional protocols.

The Mechanics Behind SSE

The process begins when the client dispatches an HTTP request to establish a connection. The server replies with specific headers—text/event-stream and transfer-encoding: chunked—signaling that the channel will remain open for ongoing communication. Once established, the server can push events as a textual stream, each containing a data field and optionally an id property.

Consider notifications: SSE makes it remarkably simple to deliver server-generated alerts directly to the browser. With that foundational understanding in place, let's move from theory to practice and build something tangible.

The Use Case

Our goal is to develop a live scoreboard displaying game results as the server broadcasts them. The interface will also include a control to halt incoming updates, creating a realistic scenario for demonstration.

Dashboard

The optimal approach combines Angular for the frontend and NestJS for the backend, orchestrated through an Nx monorepo.

  • Angular handles the client-side dashboard, rendering the scoreboard and managing the real-time connection.

  • NestJS simplifies server-side SSE implementation through its @sse decorator.

  • Nx provides the unified workspace, housing both frameworks within a single repository.

With the plan outlined, it's time to get started.

Initializing the Workspace

We'll begin by creating an empty Nx workspace named thescore. Execute npx create-nx-workspace@latest thescore in your terminal. During the setup prompts, select "none" for the stack option and choose the integrated monorepo layout.

Why skip the preconfigured stack? Since we're working with Nx, I prefer adding the Angular and NestJS schematics and generators manually to maintain full control over the installation.

d

With the workspace configured, let's proceed to set up the API layer.

Setting Up the Server with NestJS

To get started, we'll bring the NestJS generator into our Nx workspace with nx add @nx/nest, then scaffold a new application named score-api by executing nx g @nx/nest:app score-api.

nx add @nx/nest
nx g @nx/nest:app score-api

With the application in place, we'll create a controller dedicated to games. Run nx g @nx/nest:controller src/app/controllers/games to generate it.

nx g @nx/nest:controller src/app/controllers/games
√ Where should the controller be generated? · score-api/src/app/controllers/games.ts
CREATE score-api/src/app/controllers/games.controller.ts
CREATE score-api/src/app/controllers/games.controller.spec.ts
UPDATE score-api/src/app/app.module.ts

Inside games.controller.ts, define a GameScore type that will represent the structure of the data we'll be pushing to clients.

type GameScore = { data: { game: { lakers: number; denver: number } } };

Within the GameController class, we set up a actionSubject and an accompanying action$ Observable. The actionSubject will be used to signal when the stream should terminate. The scores method, decorated with @Sse, is where we define our event stream. It returns an Observable of GameScore.

@Controller('games')
export class GamesController {
  private actionSubject = new Subject<boolean>();
  private action$ = this.actionSubject.asObservable();

  @Sse('scores')
  scores(): Observable<GameScore> {
  }
}

Now, we need to seed the game with an initial state. By leveraging the interval observable, we can emit a new game state every 2 seconds. Inside the stream, the tap operator will update the game object with random values to simulate score changes. We chain this with takeUntil, which listens to the action$ observable to halt the emissions, and finally, the map operator extracts the game state for the subscriber. The complete implementation is as follows:

  @Sse('scores')
  scores(): Observable<GameScore> {
    const game = {
      lakers: 0,
      denver: 0,
    };
    return interval(2000).pipe(
      tap(() => {
        game.lakers += Math.floor(Math.random() * 4) + 1;
        game.denver += Math.floor(Math.random() * 4) + 1;
      }),
      takeUntil(this.action$),
      map(() => ({ data: { game } }))
    );
  }

To provide a way to stop the stream, we create a stopCounter method decorated with @Post. When called, it triggers the actionSubject to emit a value, effectively stopping the SSE stream.

  @Post('stop')
  @HttpCode(HttpStatus.OK)
  stopCounter() {
    this.actionSubject.next(false);
  }

Important: Before launching the API, ensure CORS is enabled. Open main.ts and add the app.enableCors() call inside the bootstrap function.

Start the NestJS API with nx run score-api:serve

s

Now, open your browser and navigate to http://localhost:300/api to see the server-sent events in action.

f

With the backend stream operational, we're ready to connect it to the Angular frontend. Let's move on!

Consuming Server Events in Angular

It's time to bring the live data into our Angular application. Follow the same setup procedure, this time adding Angular support. Run nx add @nx/angular and then generate the score-live Angular app using nx g @nx/nest:app score-api.

$ nx add @nx/angular
$ nx g @nx/angular:app score-live

Next, create a service that will handle API interactions. Use the command nx g @nx/angular:service src/app/services/score.

$ nx g @nx/angular:service src/app/services/score
NX  Generating @nx/angular:service
CREATE score-live/src/app/services/score.service.spec.ts
CREATE score-live/src/app/services/score.service.ts

Before we proceed, to make HTTP requests to the server, we need to register the HTTP provider. Add provideHttp to the app.config.

import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
import {provideHttpClient} from "@angular/common/http";

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(appRoutes), provideHttpClient()],
};

Open the score.service.ts file. Here, define the GameScore interface in the service file with the necessary properties.

export type GameScore = {
  lakers: number;
  denver: number;
};

Now, let's structure the ScoreService class with the following core elements:

  • API: the URL endpoint for the backend.

  • ssSource: an EventSource instance for receiving server-sent messages.

  • http: an injected httpClient for making API calls.

  • scoreSubject: a Subject used to push incoming values to subscribers.

  • scores$: an Observable holding the GameScore value, with the startWith operator providing a sensible default.

Here's how the service is structured:


export class ScoreService {
  private API = 'http://localhost:3000/api/games/scores';
  private sseSource = new EventSource(`${this.API}`);
  private http = inject(HttpClient);
  private scoreSubject$ = new Subject<GameScore>();
  public scores$ = this.scoreSubject$.asObservable().pipe(
    startWith({
      lakers: 0,
      denver: 0,
    })
  );
}

Next, implement the getFeed method. This method configures the sseSource by attaching an addEventListener to capture new data emissions, which are then pushed through the scoreSubject. We also wire up the onerror callback to log any connection errors to the console.

  private getFeed(): void {
    this.sseSource.addEventListener('message', (e: MessageEvent) => {
      const { game } = JSON.parse(e.data);

      this.scoreSubject$.next(game);
    });

    this.sseSource.onerror = () => {
      console.error('😭 sse error');
    };
  }

Our component will need methods to start and stop the data flow. The start method will call getFeed() to open the stream, while the stop method will send a POST request to the server's stop endpoint.

  public start(): void {
    this.getFeed();
  }
  public stop(): void {
    this.http.post(`${this.API}/stop`, {});
  }

Now we can integrate everything into the main component. In app.component.ts, inject the ScoreService and create an observable gameScore$. Define the start and stop methods to control the live data flow from the service.

export class AppComponent {
  private scoreService = inject(ScoreService);
  public gameScore$ = this.scoreService.scores$;

  public stop(): void {
    this.scoreService.stop();
  }
  public start(): void {
    this.scoreService.start();
  }
}

Finally, in the component's HTML template, we use the async pipe to subscribe to gameScore$ and render the received data.

<div class="flex justify-center items-center h-screen">
  <div class="bg-gray-800 text-white p-4 rounded-lg text-center">
    <div class="flex justify-between mb-2">
      <span class="flex-grow font-bold text-yellow-500 p-4">Lakers</span>
      <span class="flex-grow font-bold text-blue-500 p-4">Nuggets</span>
    </div>
    <div class="flex justify-between">
      @if (gameScore$ | async; as score) {
      <span class="text-4xl font-bold w-1/2">{{score.lakers}}</span>
      <span class="text-4xl font-bold w-1/2">{{score.denver}}</span>
      }
    </div>
    <div class="mt-4">
      <button class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded-full mr-2"
        (click)="start()">Start</button>
      <button class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-full"
        (click)="stop()">Stop</button>
    </div>
  </div>
</div>

After saving your changes, the scoreboard is live with real-time updates!🎉

s

Checking the network tab in your browser's developer tools will reveal the ongoing data transmission from the server.

f

Wrapping Up

This walkthrough introduced Server-Sent Events, demonstrated a straightforward implementation on the NestJS side, and showed how to subscribe to the stream from an Angular client. SSE lacks the full-duplex communication that SignalR offers, but there are still ways to signal the server to terminate the stream when needed.

Cover image by JC Gellidon on Unsplash