← Back to Blog
Next.js2026-05-14Β·74 min read

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

By Forrest Miller

Architecting Resilient Real-Time Sessions: Connection Management and Secure Authentication in Next.js

Current Situation Analysis

Modern component-driven frameworks abstract away network lifecycle management, which creates a dangerous illusion for real-time features. Developers frequently treat WebSocket clients like stateless utilities, instantiating them inside React hooks, custom composables, or component render cycles. This pattern works flawlessly in local development but collapses under production load due to connection sprawl.

The core issue is architectural blindness. When a multiplayer interface requires game state synchronization, chat messaging, and presence tracking, a naive implementation spawns a separate WebSocket handshake for each feature. In a 20-user session, this multiplies to 60+ concurrent connections per room. Real-time platforms bill per active connection, not per user. The cost scales linearly with component count, not actual concurrency. Beyond billing, connection sprawl exhausts browser limits, increases memory pressure, and fragments event routing, making debugging nearly impossible.

Security compounds the problem. Many quick-start tutorials demonstrate embedding publishable API keys directly in client-side bundles. This violates zero-trust principles. Once a key is exposed in browser DevTools, any actor can forge messages, spam channels, or exhaust rate limits. The platform's own documentation often labels this as "acceptable for prototyping," but production multiplayer systems require cryptographic boundary enforcement.

The industry overlooks this because framework abstractions hide the underlying TCP/WebSocket lifecycle. Developers assume the SDK manages pooling automatically. It does not. Without explicit architectural constraints, every render cycle becomes a potential connection leak.

WOW Moment: Key Findings

Shifting from per-component instantiation to a module-level singleton with token-based authentication fundamentally changes the cost, security, and reliability profile of real-time applications. The following comparison isolates the architectural impact:

Approach Connections Per User Initial Latency Security Posture Monthly Cost (Relative) Connection Stability
Per-Component Instantiation 3–5 ~120ms Exposed API Key 3.0x baseline Fragile (leaks on unmount)
Module Singleton + API Key 1 ~120ms Exposed API Key 1.0x baseline Stable
Module Singleton + Token Auth 1 ~170ms Zero-Trust 1.0x baseline Highly Stable

The singleton pattern eliminates redundant handshakes, reducing infrastructure costs by approximately 66% for multi-feature interfaces. Token authentication introduces a single additional round-trip during initialization (~50ms overhead), but completely removes credential exposure. The SDK handles token renewal transparently, adding zero latency to subsequent message delivery. This combination transforms real-time from a fragile feature into a managed, auditable resource.

Core Solution

Building a production-ready real-time layer requires separating client-side subscription management from server-side publishing, enforcing strict authentication boundaries, and tuning transport behavior for edge environments.

1. Module-Level Connection Factory

Browser tabs operate within a single JavaScript execution context. A module-scoped factory guarantees one WebSocket instance per tab, regardless of how many components request it.

// lib/realtime/connection-factory.ts
import { Realtime } from 'ably/promises';
import type { ClientOptions } from 'ably';

let activeConnection: Realtime | null = null;

export function initializeRealtimeConnection(config: ClientOptions): Realtime {
  if (typeof window === 'undefined') {
    throw new Error('Realtime connections must be initialized in a browser environment.');
  }

  if (!activeConnection) {
    activeConnection = new Realtime({
      ...config,
      closeOnUnload: false,
      transports: ['web_socket', 'xhr_polling'],
      logLevel: 0,
    });

    activeConnection.connection.on('failed', (error) => {
      console.error('[Realtime] Connection failed:', error.message);
    });
  }

  return activeConnection;
}

export function getActiveConnection(): Realtime {
  if (!activeConnection) {
    throw new Error('Connection not initialized. Call initializeRealtimeConnection first.');
  }
  return activeConnection;
}

Architecture Rationale: Module scope persists across component mounts/unmounts. The factory pattern prevents accidental re-instantiation. Explicit error listeners catch network degradation early. closeOnUnload: false prevents mobile browsers from terminating the socket during app switches, allowing the platform to buffer messages and replay them upon return.

2. Secure Token Exchange Endpoint

Client-side authentication must never expose long-lived credentials. A dedicated API route generates short-lived tokens with scoped capabilities.

// app/api/realtime/auth/route.ts
import { Rest } from 'ably';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const requestedClientId = searchParams.get('clientId') ?? `guest-${Date.now()}`;

  const serverClient = new Rest({ key: process.env.ABLY_API_KEY! });

  const tokenRequest = await serverClient.auth.createTokenRequest({
    clientId: requestedClientId,
    capability: {
      'session:*': ['subscribe', 'publish'],
      'presence:*': ['subscribe'],
    },
    ttl: 3600000, // 1 hour
  });

  return NextResponse.json(tokenRequest);
}

Architecture Rationale: Capabilities are restricted to session and presence channels. The ttl limits token validity, reducing blast radius if intercepted. The client SDK automatically requests renewal before expiration, eliminating manual refresh logic. Server-side token generation enforces identity verification before granting network access.

3. Server-Side Publishing via REST

Real-time subscriptions require persistent WebSocket connections. Server-side publishing does not. Using the REST client for API routes eliminates connection overhead and aligns with stateless function execution models.

// lib/realtime/server-publisher.ts
import { Rest } from 'ably';

let restInstance: Rest | null = null;

export function getServerPublisher(): Rest {
  if (!restInstance) {
    restInstance = new Rest({ key: process.env.ABLY_API_KEY! });
  }
  return restInstance;
}

export async function broadcastToSession(sessionId: string, eventName: string, payload: unknown): Promise<void> {
  const publisher = getServerPublisher();
  const channel = publisher.channels.get(`session:${sessionId}`);
  await channel.publish(eventName, payload);
}

Architecture Rationale: Rest clients use HTTP, not WebSockets. They are stateless, require no cleanup, and perform optimally in serverless environments where cold starts are unavoidable. Separating Realtime (client) and Rest (server) prevents accidental cross-environment usage and simplifies connection tracking.

4. Channel Routing and Event Filtering

A single channel per session reduces subscription overhead. Event names act as routing keys, allowing multiple features to share one connection.

// lib/realtime/channel-router.ts
import { getActiveConnection } from './connection-factory';
import { RealtimeChannel } from 'ably';

export function resolveSessionChannel(sessionId: string): RealtimeChannel {
  const connection = getActiveConnection();
  return connection.channels.get(`session:${sessionId}`);
}

export function attachChannelListeners(
  channel: RealtimeChannel,
  handlers: Record<string, (payload: any) => void>
): () => void {
  const subscriptions: Array<{ event: string; handler: (payload: any) => void }> = [];

  Object.entries(handlers).forEach(([event, handler]) => {
    channel.subscribe(event, handler);
    subscriptions.push({ event, handler });
  });

  return () => {
    subscriptions.forEach(({ event, handler }) => {
      channel.unsubscribe(event, handler);
    });
  };
}

Architecture Rationale: Centralized channel resolution prevents duplicate channels.get() calls. The cleanup function returned by attachChannelListeners integrates cleanly with React's useEffect teardown, guaranteeing subscription removal on unmount.

Pitfall Guide

1. Hook-Level Client Instantiation

Explanation: Creating a new client inside useEffect or custom hooks spawns a connection on every render cycle. React's concurrent mode and strict mode exacerbate this, creating duplicate connections that silently leak. Fix: Enforce module-level singletons. Validate instantiation count in development with a counter or warning logger.

2. Hardcoded Publishable Keys in Client Bundles

Explanation: Embedding key: process.env.ABLY_API_KEY in client code exposes full platform access. Attackers can reverse-engineer the bundle, extract the key, and publish arbitrary messages or exhaust rate limits. Fix: Route all client authentication through a token endpoint. Scope capabilities to minimum required permissions. Rotate keys regularly.

3. Ignoring Token Renewal Failures

Explanation: While the SDK handles automatic renewal, network interruptions during the renewal window can drop the connection. Developers often assume renewal is guaranteed. Fix: Listen for connectionstate changes. Implement exponential backoff for manual reconnection if the SDK's internal retry exhausts. Log renewal failures for monitoring.

4. Channel Subscription Leaks

Explanation: Subscribing to channels without explicit cleanup leaves dangling listeners. When components unmount, the WebSocket remains open, and stale handlers execute on new messages, causing state corruption. Fix: Always return a cleanup function from useEffect that calls channel.unsubscribe() or channel.detach(). Use the router pattern shown above to centralize teardown logic.

5. Over-Subscribing to Broad Channels

Explanation: Subscribing to * or wildcard channels without filtering forces the client to process irrelevant events. This increases CPU usage, memory consumption, and message parsing latency. Fix: Scope subscriptions to specific event names. Use server-side routing to publish only to relevant channels when possible. Implement client-side message filtering as a secondary safeguard.

6. Misconfiguring Transport Fallback Order

Explanation: Specifying ['xhr_polling', 'web_socket'] forces long-polling first, degrading performance. Some restrictive proxies strip WebSocket upgrade headers, but polling should remain a fallback, not a primary. Fix: Always prioritize web_socket. Include xhr_polling as a secondary option. Test in corporate/school network environments to verify fallback behavior.

7. Mixing Realtime and REST Clients on the Server

Explanation: Using Realtime in serverless functions creates persistent connections that outlive function execution, causing orphaned sockets and platform limit violations. Fix: Strictly use Rest for server-side publishing. Reserve Realtime exclusively for browser environments. Document this boundary in architecture guidelines.

Production Bundle

Action Checklist

  • Initialize a module-level singleton for the client-side Realtime connection
  • Implement a dedicated API route for token generation with scoped capabilities
  • Replace all client-side API key references with authUrl configuration
  • Create a separate REST client instance for server-side publishing
  • Establish a channel naming convention (e.g., session:${id}) and event routing strategy
  • Configure closeOnUnload: false and transport fallback array for mobile/proxy resilience
  • Attach connection state listeners for failure detection and monitoring
  • Implement explicit subscription cleanup in component teardown cycles

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Multi-feature UI (chat, game state, presence) Module Singleton + Single Channel Prevents connection multiplication, simplifies routing Reduces infrastructure cost by ~66%
Public-facing multiplayer application Token Authentication Eliminates credential exposure, enforces identity verification Adds ~50ms initial latency, zero ongoing cost
Serverless API routes publishing events REST Client Stateless HTTP execution, no connection lifecycle management Eliminates orphaned socket risk, optimizes cold start
Corporate/School network deployment WebSocket + XHR Polling Fallback Bypasses proxy header stripping, maintains functionality Slight latency increase during fallback, prevents total failure
High-frequency event broadcasting Server-side routing + Event filtering Reduces client parsing overhead, minimizes bandwidth Lowers client CPU usage, improves perceived responsiveness

Configuration Template

// lib/realtime/config.ts
import type { ClientOptions } from 'ably';

export const realtimeClientConfig: ClientOptions = {
  authUrl: '/api/realtime/auth',
  authMethod: 'GET',
  clientId: `user-${crypto.randomUUID()}`,
  closeOnUnload: false,
  transports: ['web_socket', 'xhr_polling'],
  logLevel: 0,
};

// lib/realtime/index.ts
export { initializeRealtimeConnection, getActiveConnection } from './connection-factory';
export { getServerPublisher, broadcastToSession } from './server-publisher';
export { resolveSessionChannel, attachChannelListeners } from './channel-router';
export { realtimeClientConfig } from './config';

Quick Start Guide

  1. Install Dependencies: Add ably to your project. Ensure your Next.js environment variables include ABLY_API_KEY.
  2. Deploy Token Endpoint: Create the /api/realtime/auth route. Verify it returns a valid token request when accessed directly.
  3. Initialize Client Connection: Call initializeRealtimeConnection(realtimeClientConfig) at the root of your client application (e.g., layout or provider).
  4. Subscribe and Clean Up: Use resolveSessionChannel() and attachChannelListeners() inside components. Return the cleanup function in useEffect.
  5. Validate in Production: Open DevTools, monitor the Network tab for WebSocket handshakes, and confirm only one connection per tab. Test token renewal by waiting for TTL expiration and verifying automatic re-authentication.