DEV Community

Forrest Miller
Forrest Miller

Posted on • Originally published at bingwow.com

Why Our Multiplayer Bingo Game Uses a Singleton Ably Client (and Token Auth Instead of API Keys)

Why Our Multiplayer Bingo Game Uses a Singleton Ably Client (and Token Auth Instead of API Keys)

When you're building real-time multiplayer in Next.js, the first architectural question isn't "which WebSocket library." It's "how many connections am I going to accidentally open."

The problem: connection sprawl

BingWow is a free multiplayer bingo platform. Every player in a room subscribes to an Ably channel. When someone taps a cell, claims bingo, or sends a chat message, every other player sees it in real time.

The naive approach — instantiate new Ably.Realtime() in every hook that needs it — creates a connection per hook per render. A single player page has at minimum three hooks subscribing: game subscriptions, chat, and the activity feed. Three connections per player. Twenty players in a room is sixty WebSocket connections for one game. Ably bills per connection.

The singleton pattern

The fix is a module-level singleton. One connection per browser tab, shared across every hook:

// lib/ably.ts
import Ably from 'ably';

let realtimeClient: Ably.Realtime | null = null;

export function getAblyClient(): Ably.Realtime {
  if (typeof window === 'undefined') {
    throw new Error('getAblyClient can only be called on the client side');
  }

  if (!realtimeClient) {
    const clientId = `client-${Date.now()}`;
    realtimeClient = new Ably.Realtime({
      authUrl: '/api/ably/token',
      authMethod: 'GET',
      authParams: { clientId },
      clientId,
      closeOnUnload: false,
      transports: ['web_socket', 'xhr_polling'],
      logLevel: 0,
    });
  }

  return realtimeClient;
}
Enter fullscreen mode Exit fullscreen mode

Every hook calls getAblyClient(). The first call creates the connection. Every subsequent call returns the same instance. When the tab closes, one connection drops.

Why token auth, not the publishable API key

Ably offers two client authentication modes. The quick one — passing key directly — exposes your Ably API key in client-side JavaScript. Anyone who opens DevTools can read it. Ably's docs say this is fine for prototyping. For a production multiplayer game with arbitrary players, it is not fine.

Token auth routes through your server:

// app/api/ably/token/route.ts
import Ably from 'ably';
import { NextResponse } from 'next/server';

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const clientId = searchParams.get('clientId') || `anon-${Date.now()}`;

  const ably = new Ably.Rest({ key: process.env.ABLY_API_KEY! });
  const token = await ably.auth.createTokenRequest({
    clientId,
    capability: { '*': ['subscribe', 'publish'] },
  });

  return NextResponse.json(token);
}
Enter fullscreen mode Exit fullscreen mode

The client's authUrl points at this endpoint. Ably's SDK handles the token lifecycle automatically — requests a new token before the current one expires, reconnects transparently. Zero token-management code on the client.

The server-side split

Client hooks subscribe. Server API routes publish. These are different Ably client types for a reason.

The browser uses Ably.Realtime (WebSocket, persistent connection, subscribe + publish). The server uses Ably.Rest (HTTP, stateless, publish only). Both are singletons at module scope:

// Server-side REST client (publish from API routes)
let restClient: Ably.Rest | null = null;

export function getAblyRestClient(): Ably.Rest {
  if (!restClient) {
    restClient = new Ably.Rest({ key: process.env.ABLY_API_KEY! });
  }
  return restClient;
}
Enter fullscreen mode Exit fullscreen mode

On Vercel, the REST client lives in the function's warm cache. Cold starts create a new one. Warm invocations reuse it. Ably REST calls are stateless HTTP — no connection to manage, no cleanup needed.

The channel-name convention

Every room gets one channel: room:${roomCode}. All event types — claims, bingos, joins, renames, chat — flow through the same channel with different event names:

export function getRoomChannel(roomCode: string) {
  return getAblyClient().channels.get(`room:${roomCode}`);
}
Enter fullscreen mode Exit fullscreen mode

The hooks that subscribe to this channel filter by event name:

// In useGameSubscriptions
channel.subscribe('claim', handleClaim);
channel.subscribe('bingo', handleBingo);

// In useChat
channel.subscribe('chat', handleChat);
Enter fullscreen mode Exit fullscreen mode

One channel per room means one subscription management point. When a player leaves, one channel.detach() cleans everything up.

What closeOnUnload: false does

This was counterintuitive until I saw it in production. Setting closeOnUnload: false tells Ably not to close the WebSocket on beforeunload. Why would you want that?

Mobile browsers. When a phone user switches to another app and comes back, beforeunload fires on the tab switch. With closeOnUnload: true, the connection drops and the player misses events during the app switch. With false, Ably holds the connection for a grace period and the player catches up on return via Ably's automatic message replay.

For a bingo game where rounds last 2-5 minutes and players routinely check their phone mid-game, this is the difference between "the game broke" and "I missed a few taps but caught up."

The transport fallback

transports: ['web_socket', 'xhr_polling'],
Enter fullscreen mode Exit fullscreen mode

WebSocket first, long-polling fallback. School Chromebooks running behind restrictive proxies sometimes block WebSocket upgrades. The xhr_polling fallback ensures the game still works — slower, but functional. We discovered this when three teachers in the same district reported "the game loads but nobody else's taps appear." The Chromebook proxy was stripping the Upgrade: websocket header.

Metrics

Before the singleton: ~3 Ably connections per player per room. After: 1. For a 20-player room, that's 60 → 20 connections. At Ably's pricing tiers, the singleton pattern cut our real-time infrastructure cost by two-thirds with zero behavior change.

The token auth pattern added ~50ms to the initial connection (one extra round-trip to /api/ably/token). After that, token renewal is transparent and adds zero latency to message delivery.


If you're building a game or any real-time feature with Ably and Next.js, start with the singleton + token auth pattern. The alternative — multiple connections with a publishable key — works until it doesn't, and "doesn't" usually means a surprise bill or a security incident.

BingWow is free and open for multiplayer games at bingwow.com. Create a card at bingwow.com/create, share the room code, and play. The Ably architecture described above runs every game session — from classroom activities at bingwow.com/for/teachers to watch parties and team-building events.

Top comments (0)