<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged websocket</title>
<link>https://coderlegion.com/tag/websocket</link>
<description>Powered by Question2Answer</description>
<item>
<title>Building the Realtime Foundation of the MyZubster Metaverse</title>
<link>https://coderlegion.com/26533/building-the-realtime-foundation-of-the-myzubster-metaverse</link>
<description>&lt;h2 id=&quot;building-the-realtime-foundation-of-the-myzubster-metaverse&quot;&gt;Building the Realtime Foundation of the MyZubster Metaverse&lt;/h2&gt;
&lt;p&gt;We are upgrading the MyZubster Metaverse from a REST-synchronized experience into an authenticated realtime multiplayer environment.&lt;/p&gt;
&lt;p&gt;Before continuing with the next development phase, here is a transparent update on what is already working and what we are implementing next.&lt;/p&gt;
&lt;h3 id=&quot;from-polling-to-realtime-communication&quot;&gt;From polling to realtime communication&lt;/h3&gt;
&lt;p&gt;The first version of the Metaverse uses REST endpoints to manage:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;player sessions;&lt;/li&gt;
&lt;li&gt;character movement;&lt;/li&gt;
&lt;li&gt;shared presence;&lt;/li&gt;
&lt;li&gt;chat messages;&lt;/li&gt;
&lt;li&gt;emotes;&lt;/li&gt;
&lt;li&gt;verified identities;&lt;/li&gt;
&lt;li&gt;world synchronization.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This gave us a reliable starting point, but periodic polling is not the final architecture we want for a persistent shared world.&lt;/p&gt;
&lt;p&gt;We have now introduced a Socket.IO gateway mounted at:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;/realtime
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It supports WebSocket communication with HTTP polling available as a compatibility fallback.&lt;/p&gt;
&lt;h3 id=&quot;the-preview-runtime-is-online&quot;&gt;The preview runtime is online&lt;/h3&gt;
&lt;p&gt;The realtime infrastructure has been deployed to an isolated Vercel preview environment.&lt;/p&gt;
&lt;p&gt;We verified it using the public health endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;GET /api/realtime/health
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The current response is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;success&quot;: true,
  &quot;status&quot;: &quot;ok&quot;,
  &quot;transport&quot;: &quot;socket.io&quot;,
  &quot;socketPath&quot;: &quot;/realtime&quot;,
  &quot;presence&quot;: &quot;local-fallback&quot;,
  &quot;privacy&quot;: &quot;aggregate-only&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This confirms that the application is running, Socket.IO is attached correctly, the preview database is reachable, and observability is configured to avoid exposing private user data.&lt;/p&gt;
&lt;h3 id=&quot;secure-realtime-authentication&quot;&gt;Secure realtime authentication&lt;/h3&gt;
&lt;p&gt;Clients cannot connect anonymously to protected realtime features.&lt;/p&gt;
&lt;p&gt;An authenticated user first requests a short-lived token from:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;POST /api/realtime/token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The server creates a dedicated JWT containing only the information required by the realtime gateway.&lt;/p&gt;
&lt;p&gt;The gateway verifies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;signature and expiration;&lt;/li&gt;
&lt;li&gt;issuer and audience;&lt;/li&gt;
&lt;li&gt;realtime-specific purpose;&lt;/li&gt;
&lt;li&gt;user identity;&lt;/li&gt;
&lt;li&gt;user role.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Authentication alone does not grant access to every channel. Each subscription is authorized independently by the server.&lt;/p&gt;
&lt;p&gt;Every user also receives a private channel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;user:&amp;lt;userId&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This provides a controlled foundation for private notifications and direct message delivery.&lt;/p&gt;
&lt;h3 id=&quot;presence-messaging-and-reconnects&quot;&gt;Presence, messaging, and reconnects&lt;/h3&gt;
&lt;p&gt;The new gateway already supports the core operations required by a multiplayer environment:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;presence.join
presence.heartbeat
presence.leave
channel.subscribe
channel.unsubscribe
chat.send
realtime.resume
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a client reconnects, it can request restoration of its previous subscriptions. The server verifies every channel again before restoring access.&lt;/p&gt;
&lt;p&gt;Messages are validated, persisted, deduplicated, and delivered only to authorized recipients.&lt;/p&gt;
&lt;p&gt;We also added backpressure controls so the service can reject excess work safely during temporary overload.&lt;/p&gt;
&lt;h3 id=&quot;preview-database-isolation&quot;&gt;Preview database isolation&lt;/h3&gt;
&lt;p&gt;The preview deployment does not reuse unrestricted production credentials.&lt;/p&gt;
&lt;p&gt;We created:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a dedicated MongoDB preview user;&lt;/li&gt;
&lt;li&gt;a custom least-privilege role;&lt;/li&gt;
&lt;li&gt;access limited to the preview database;&lt;/li&gt;
&lt;li&gt;cluster-level restrictions;&lt;/li&gt;
&lt;li&gt;encrypted Vercel environment variables;&lt;/li&gt;
&lt;li&gt;branch-specific configuration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The MongoDB URI and realtime signing secret are never committed to GitHub or exposed to frontend code.&lt;/p&gt;
&lt;h3 id=&quot;privacy-aware-observability&quot;&gt;Privacy-aware observability&lt;/h3&gt;
&lt;p&gt;Realtime systems need monitoring, but operational visibility should not become user surveillance.&lt;/p&gt;
&lt;p&gt;Our observability layer collects aggregate technical information such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;connection attempts;&lt;/li&gt;
&lt;li&gt;successful and rejected connections;&lt;/li&gt;
&lt;li&gt;active connections;&lt;/li&gt;
&lt;li&gt;disconnects;&lt;/li&gt;
&lt;li&gt;message failures;&lt;/li&gt;
&lt;li&gt;duplicate messages;&lt;/li&gt;
&lt;li&gt;resume operations;&lt;/li&gt;
&lt;li&gt;processing duration;&lt;/li&gt;
&lt;li&gt;Redis failures.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Private conversations and raw personal identifiers are not included in public metrics.&lt;/p&gt;
&lt;p&gt;The detailed metrics endpoint is restricted to authenticated administrators.&lt;/p&gt;
&lt;h3 id=&quot;why-presence-is-still-using-a-fallback&quot;&gt;Why presence is still using a fallback&lt;/h3&gt;
&lt;p&gt;The health response currently reports:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;presence: local-fallback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means presence works inside the active application instance, but it is not yet coordinated across multiple Vercel instances.&lt;/p&gt;
&lt;p&gt;It is an important distinction: the realtime runtime is operational, but distributed presence is still the next step.&lt;/p&gt;
&lt;p&gt;Redis will provide:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;shared presence state;&lt;/li&gt;
&lt;li&gt;cross-instance Socket.IO delivery;&lt;/li&gt;
&lt;li&gt;instance coordination;&lt;/li&gt;
&lt;li&gt;presence expiration;&lt;/li&gt;
&lt;li&gt;better horizontal scaling.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-we-are-working-on-next&quot;&gt;What we are working on next&lt;/h3&gt;
&lt;p&gt;Our next development phase is focused on:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;connecting the Metaverse frontend to Socket.IO;&lt;/li&gt;
&lt;li&gt;keeping REST synchronization as a temporary fallback;&lt;/li&gt;
&lt;li&gt;introducing the Socket.IO Redis adapter;&lt;/li&gt;
&lt;li&gt;configuring an isolated preview Redis instance;&lt;/li&gt;
&lt;li&gt;testing multiple simultaneous clients;&lt;/li&gt;
&lt;li&gt;verifying disconnect and resume behavior;&lt;/li&gt;
&lt;li&gt;validating privacy-safe metrics;&lt;/li&gt;
&lt;li&gt;completing the production review and merge.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;current-project-status&quot;&gt;Current project status&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Realtime server: &lt;strong&gt;implemented&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Vercel preview: &lt;strong&gt;online&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;MongoDB connection: &lt;strong&gt;verified&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Health endpoint: &lt;strong&gt;verified&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Socket authentication: &lt;strong&gt;implemented&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Channel authorization: &lt;strong&gt;implemented&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Presence lifecycle: &lt;strong&gt;implemented&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Reconnect support: &lt;strong&gt;implemented&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Aggregate observability: &lt;strong&gt;implemented&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Frontend Socket.IO connection: &lt;strong&gt;in progress&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Distributed Redis coordination: &lt;strong&gt;next&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Production release: &lt;strong&gt;pending validation&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;building-a-world-not-just-a-page&quot;&gt;Building a world, not just a page&lt;/h3&gt;
&lt;p&gt;The MyZubster Metaverse is intended to become a shared digital space connected to identities, creators, projects, collaboration, and community experiences.&lt;/p&gt;
&lt;p&gt;Making that possible requires more than adding a WebSocket. It requires strong security boundaries, failure handling, database isolation, reconnect logic, privacy-aware monitoring, and distributed state management.&lt;/p&gt;
&lt;p&gt;The server-side foundation is now online.&lt;/p&gt;
&lt;p&gt;The next step is connecting the world to it.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;MyZubster: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.myzubster.com&quot;&gt;https://www.myzubster.com&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MyZubster-Ecosystem&quot;&gt;https://github.com/MyZubster-Ecosystem&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26533/building-the-realtime-foundation-of-the-myzubster-metaverse</guid>
<pubDate>Wed, 09 Sep 2026 00:12:26 +0000</pubDate>
</item>
<item>
<title>How I Moved Real-Time Out of Django (and Made Everything Simpler)</title>
<link>https://coderlegion.com/22825/how-i-moved-real-time-out-of-django-and-made-everything-simpler</link>
<description>&lt;p&gt;Your users want live updates: an order flips to &lt;em&gt;paid&lt;/em&gt;, a notification pops, a dashboard number ticks up without a refresh. The obvious answer in Django is &lt;a rel=&quot;nofollow&quot; href=&quot;https://channels.readthedocs.io/&quot;&gt;Channels&lt;/a&gt;. I reached for it first too. But as our scale grew, the costs of keeping connections in-process pushed me to rethink the architecture. I let Django stop handling WebSockets entirely, and almost everything got simpler. Here's the pattern that replaced it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The Problem&lt;/h3&gt;
&lt;p&gt;Django is a request/response framework. It processes a request, returns a response, and moves on. Real-time notifications — a dashboard ticking up, an order status flipping live — need a server that &lt;em&gt;holds a connection open&lt;/em&gt; for every connected client. That is a fundamentally different job.&lt;/p&gt;
&lt;p&gt;The standard answer in the Django ecosystem is &lt;a rel=&quot;nofollow&quot; href=&quot;https://channels.readthedocs.io/&quot;&gt;Channels&lt;/a&gt;. It moves you to ASGI, gives you consumers, and works well, especially when your real-time needs are deep and bidirectional.&lt;/p&gt;
&lt;p&gt;Ours weren't. We needed server→client push: notify a user when an order completed, or when a payment landed. The connections were mostly idle. Channels gave us that, but it came with costs that started hurting at scale.&lt;/p&gt;
&lt;p&gt;The concrete problem was this: every open WebSocket connection consumed memory and a small slice of CPU time on our app servers. A thousand connected dashboards was fine. Ten thousand, across dozens of tenants, each holding a persistent socket. Suddenly, our workers were doing two very different jobs at once. Serving fast API responses &lt;em&gt;and&lt;/em&gt; babysitting long-lived, mostly-quiet connections. Both suffered.&lt;/p&gt;
&lt;p&gt;What made it brittle was the reconnect storm. Deploy a new build, restart a server, or hit a brief network partition, and every connected client disconnects and &lt;em&gt;immediately reconnects&lt;/em&gt;. All at once. That thundering herd of TLS handshakes, authentication, and subscription requests would spike CPU to 100% on every app server as it tried to spin up thousands of ASGI consumer instances simultaneously, occasionally knocking them over entirely before the retry backoff kicked in.&lt;/p&gt;
&lt;p&gt;There's also the smaller but telling friction of auth. Authenticating the WebSocket handshake means reaching your existing auth (DRF/JWT) from inside an ASGI scope. I ended up writing middleware that reconstructed a DRF &lt;code&gt;Request&lt;/code&gt; out of the raw scope just to reuse the auth I already had. It worked, but it was a sign: I was bending the framework to do something it wasn't built for.&lt;/p&gt;
&lt;p&gt;The turning point was asking: &lt;em&gt;why is my web framework in the business of holding connections at all?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;For a fire-and-forget server→client push, it shouldn't be. There are servers built specifically to hold millions of open connections and do nothing else. The only two things your backend needs to do are &lt;strong&gt;say who's allowed to listen&lt;/strong&gt;, and &lt;strong&gt;publish events&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-solution&quot;&gt;The Solution&lt;/h3&gt;
&lt;p&gt;Put a dedicated pub/sub gateway in front of your app. I used &lt;a rel=&quot;nofollow&quot; href=&quot;https://centrifugal.dev/&quot;&gt;Centrifugo&lt;/a&gt;, a standalone real-time server. Clients connect to &lt;em&gt;it&lt;/em&gt;, not to Django. Django goes back to being a plain, boring WSGI/sync app and plays two small roles:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Issue a short-lived connection token&lt;/strong&gt; that embeds &lt;em&gt;which channels this user may subscribe to&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Publish events&lt;/strong&gt; to the gateway over its HTTP API.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The gateway holds the connections. Django holds nothing.&lt;/p&gt;
&lt;h4 id=&quot;step-1-issue-a-token-that-carries-its-own-permissions&quot;&gt;Step 1: Issue a token that carries its own permissions&lt;/h4&gt;
&lt;p&gt;When a client wants to connect, it asks your backend (over a normal authenticated request) for a connection token. The token is a JWT whose claims list the exact channels the user may subscribe to. The gateway verifies the signature and authorizes subscriptions &lt;strong&gt;without a single database lookup&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# realtime/auth.py
import time
import jwt
from django.conf import settings


class RealtimeTokenService:
    &quot;&quot;&quot;Issues short-lived JWTs for connecting to the realtime gateway.&quot;&quot;&quot;

    def generate_connection_token(self, user) -&amp;gt; str:
        tenant_id = user.tenant_id

        # Channel grants are baked into the token, so the gateway authorizes
        # subscriptions with zero database lookups on connect.
        channels = [
            f&quot;user_{user.id}&quot;,
            f&quot;critical:user_{user.id}&quot;,
            f&quot;tenant_{tenant_id}&quot;,
            f&quot;critical:tenant_{tenant_id}&quot;,
        ]

        claims = {
            &quot;sub&quot;: str(user.id),
            &quot;exp&quot;: int(time.time()) + settings.REALTIME_TOKEN_TTL_SECONDS,
            &quot;info&quot;: {&quot;name&quot;: user.get_full_name(), &quot;email&quot;: user.email},
            &quot;channels&quot;: channels,
        }
        return jwt.encode(claims, settings.REALTIME_HMAC_SECRET, algorithm=&quot;HS256&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the key idea: &lt;strong&gt;token-as-permission&lt;/strong&gt;. The channel names encode your authorization model. A user gets their own &lt;code&gt;user_{id}&lt;/code&gt; channel and their tenant's &lt;code&gt;tenant_{id}&lt;/code&gt; channel, and nothing else. Multi-tenant isolation falls out of the channel naming: a user simply has no grant for another tenant's channel, so the gateway will refuse the subscription.&lt;/p&gt;
&lt;h4 id=&quot;step-2-hand-the-token-out-behind-your-normal-auth&quot;&gt;Step 2: Hand the token out behind your normal auth&lt;/h4&gt;
&lt;p&gt;The endpoint that mints the token is an ordinary authenticated view. Your existing auth stack protects it; no ASGI gymnastics:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# realtime/views.py
class RealtimeTokenView(APIView):
    permission_classes = [IsAuthenticated]

    def post(self, request):
        token = RealtimeTokenService().generate_connection_token(request.user)
        return Response(
            {&quot;token&quot;: token, &quot;socket_url&quot;: settings.REALTIME_SOCKET_URL}
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client takes &lt;code&gt;{token, socket_url}&lt;/code&gt;, opens a WebSocket to the gateway, and subscribes to its channels. Django is now out of the connection entirely.&lt;/p&gt;
&lt;h4 id=&quot;step-3-publish-events-over-http&quot;&gt;Step 3: Publish events over HTTP&lt;/h4&gt;
&lt;p&gt;To push an update, your backend POSTs to the gateway's publish API. No connection state, no sockets: just an HTTP call:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# realtime/publisher.py
import requests
from django.conf import settings


def push_event(channel, event_type, payload, *, recoverable=False, timeout=10.0):
    # The &quot;critical:&quot; namespace is configured on the gateway with message
    # history + recovery, so clients can catch up after a reconnect.
    final_channel = f&quot;critical:{channel}&quot; if recoverable else channel

    resp = requests.post(
        f&quot;{settings.REALTIME_API_URL}/publish&quot;,
        json={
            &quot;channel&quot;: final_channel,
            &quot;data&quot;: {&quot;type&quot;: event_type, &quot;payload&quot;: payload},
        },
        headers={&quot;X-API-Key&quot;: settings.REALTIME_API_KEY},
        timeout=timeout,
    )
    resp.raise_for_status()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the &lt;code&gt;critical:&lt;/code&gt; channel prefix. On the gateway, that namespace is configured to keep a short history so a client that briefly drops can recover missed messages on reconnect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;namespaces&quot;: [
    { &quot;name&quot;: &quot;critical&quot;, &quot;history_size&quot;: 50, &quot;history_ttl&quot;: &quot;120s&quot;, &quot;force_recovery&quot;: true }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ephemeral events (a &quot;user is typing&quot; blip) publish to the bare channel; events you can't afford to lose (a completed order) publish to &lt;code&gt;critical:&lt;/code&gt; and survive a reconnect.&lt;/p&gt;
&lt;h4 id=&quot;step-4-make-publishing-reliable-and-don-t-block-the-request&quot;&gt;Step 4: Make publishing reliable, and don't block the request&lt;/h4&gt;
&lt;p&gt;Publishing is a network call to a separate service. You do &lt;strong&gt;not&lt;/strong&gt; want it on the request's critical path, and you want it to survive a transient blip. Wrap it in a Celery task with retries and backoff:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# realtime/tasks.py
import logging
from requests.exceptions import RequestException, HTTPError
from celery import shared_task
from .publisher import push_event

logger = logging.getLogger(__name__)


@shared_task(bind=True, max_retries=3, default_retry_delay=5, retry_backoff=True)
def publish_realtime_event(self, channel, event_type, payload, recoverable=False):
    try:
        push_event(channel, event_type, payload, recoverable=recoverable)
    except HTTPError as exc:
        # A 4xx error (bad API key, malformed payload) is a deterministic
        # failure that retries can't fix. Log it and drop it.
        # A 5xx error might be transient, so retry it.
        if 400 &amp;lt;= exc.response.status_code &amp;lt; 500:
            logger.error(&quot;Deterministic 4xx error on %s, dropping.&quot;, channel)
            return
        raise self.retry(exc=exc)
    except RequestException as exc:
        # Pure network timeouts or connection errors — retry with backoff.
        raise self.retry(exc=exc)
    except Exception:
        # A serialization bug won't fix itself on a retry. Log and drop it,
        # rather than burning three attempts on a guaranteed failure.
        # By not re-raising, Celery marks the task as successful and removes
        # it from the queue — no poison pill looping forever.
        logger.exception(&quot;Dropping unrecoverable realtime event on %s&quot;, channel)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;except&lt;/code&gt; split matters: &lt;strong&gt;retry the transient, drop the deterministic.&lt;/strong&gt; A flaky network deserves another try; a payload that can't be JSON-encoded will fail identically three times in a row, so retrying it is just noise.&lt;/p&gt;
&lt;p&gt;Firing an event from anywhere in your app is now a one-liner:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;publish_realtime_event.delay(
    channel=f&quot;tenant_{order.tenant_id}&quot;,
    event_type=&quot;order.completed&quot;,
    payload={&quot;order_id&quot;: order.id, &quot;total&quot;: str(order.total)},
    recoverable=True,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-this-works&quot;&gt;Why This Works&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Your web tier goes back to being stateless.&lt;/strong&gt; Django stays on WSGI. No ASGI migration, no async server, no sockets held in your workers. You can scale, restart, and deploy the web tier like any other stateless service. The open connections live on the gateway and aren't disturbed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Connections scale independently.&lt;/strong&gt; Holding a lot of idle connections is a specialized job. A purpose-built gateway does it on its own box, tuned for that, while your app servers stay sized for request throughput.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Auth has zero connect-time cost.&lt;/strong&gt; Because the token carries its channel grants, the gateway never calls back into your database to authorize a subscription. Ten thousand clients reconnecting after a blip is ten thousand signature checks, not ten thousand DB queries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Multi-tenancy is just naming.&lt;/strong&gt; Tenant isolation isn't a separate access-control layer. It's the channel names in the token. There's no code path by which a user can subscribe to a channel they weren't granted.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Delivery is fire-and-forget but durable.&lt;/strong&gt; Publishing happens off the request path via Celery, retries on transient failure, and the &lt;code&gt;critical:&lt;/code&gt; namespace lets clients recover what they missed across a reconnect.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;design-decision-the-token-is-a-cache-so-it-can-go-stale&quot;&gt;Design Decision: The Token Is a Cache, So It Can Go Stale&lt;/h3&gt;
&lt;p&gt;Baking permissions into the token is what buys you DB-free connects. But a token is a snapshot. If a user's access changes (you remove them from a tenant, say) and their token still has 24 hours to live, the gateway will keep honoring the &lt;em&gt;old&lt;/em&gt; grants until it expires. You've traded &lt;strong&gt;revocation freshness&lt;/strong&gt; for &lt;strong&gt;connect-time performance&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That's usually the right trade for a notification stream, but be deliberate about it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Keep the TTL short&lt;/strong&gt; and have the client transparently re-fetch a token. Shorter tokens = smaller stale window.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For true revocation&lt;/strong&gt;, the gateway can be told to force-disconnect a user or refuse channels server-side. Reach for that only when a stream is sensitive enough to need it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One more operational gotcha: a single gateway is tempting to share across environments, but &lt;code&gt;tenant_42&lt;/code&gt; in staging and &lt;code&gt;tenant_42&lt;/code&gt; in production are the same channel name: cross-talk waiting to happen. If you must share one broker, &lt;strong&gt;prefix channels with the environment&lt;/strong&gt; (&lt;code&gt;prod_tenant_42&lt;/code&gt;, &lt;code&gt;staging_tenant_42&lt;/code&gt;) so the namespaces can't collide.&lt;/p&gt;
&lt;p&gt;And to be honest about the path here: I shipped the Channels version first. It worked. But the friction of running an async server and bridging my existing auth into the ASGI scope is exactly what made the gateway approach worth it. If your real-time needs are deep and bidirectional (collaborative editing, presence), in-process Channels may genuinely fit better. For &quot;push updates to subscribed clients,&quot; letting something else hold the sockets is the simpler system.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-result&quot;&gt;The Result&lt;/h3&gt;
&lt;p&gt;Adding a live update anywhere in the app is now one &lt;code&gt;.delay()&lt;/code&gt; call. The web tier never learned what a WebSocket is. Connections, fan-out, history, and recovery are the gateway's problem; authorization is a signed list of channel names; delivery is a retrying background task. Django went back to doing the thing it's good at. And the real-time problem moved to a server built for it.&lt;/p&gt;
&lt;p&gt;How are you doing real-time in your Django apps — in-process with Channels, or have you pushed connections out to a gateway? I'd love to hear what's worked for you in the comments.&lt;/p&gt;
&lt;p&gt;¡Hasta luego!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22825/how-i-moved-real-time-out-of-django-and-made-everything-simpler</guid>
<pubDate>Thu, 16 Jul 2026 14:21:03 +0000</pubDate>
</item>
<item>
<title>How I Architected a Real-Time Gaming Backend Supporting 3,000+ Concurrent Users with WebSockets</title>
<link>https://coderlegion.com/14146/how-architected-real-time-gaming-backend-supporting-000-concurrent-users-with-websockets</link>
<description>&lt;p&gt;Real-time systems are unforgiving. In a standard web application, a 500-millisecond delay is an inconvenience. In a real-time multiplayer gaming platform, 500 milliseconds is the difference between a fair outcome and a furious user demanding a refund.&lt;/p&gt;
&lt;p&gt;Between October 2019 and December 2021, I served as Senior Software Engineer at Lordwin Group, where I led a team of five backend developers building dice.ng — a real-time gaming platform where thousands of users placed wagers, watched outcomes resolve live, and expected the entire experience to feel instantaneous. The platform also encompassed an investment management system and hotel booking service, but the gaming backend was the most technically demanding piece of infrastructure I have ever designed.&lt;/p&gt;
&lt;p&gt;This article is a technical deep dive into how I architected that real-time gaming backend from the ground up — the WebSocket infrastructure, the event-driven architecture, the scaling strategy, and the hard lessons learned from operating a system where latency directly impacts revenue.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem-sub-100ms-latency-for-thousands-of-simultaneous-players&quot;&gt;The Problem: Sub-100ms Latency for Thousands of Simultaneous Players&lt;/h3&gt;
&lt;p&gt;The requirements were aggressive from day one. Lordwin Group needed a gaming platform that could:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Support &lt;strong&gt;3,000+ concurrent WebSocket connections&lt;/strong&gt; during peak hours&lt;/li&gt;
&lt;li&gt;Deliver game events (dice rolls, bet confirmations, payout calculations) with &lt;strong&gt;sub-100ms latency&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Maintain &lt;strong&gt;absolute consistency&lt;/strong&gt; in game state — in a wagering platform, a race condition is not a bug, it is a financial liability&lt;/li&gt;
&lt;li&gt;Handle &lt;strong&gt;burst traffic patterns&lt;/strong&gt; — user activity would spike dramatically around evening hours and weekends, sometimes tripling within minutes&lt;/li&gt;
&lt;li&gt;Achieve &lt;strong&gt;99.9% uptime&lt;/strong&gt; — every minute of downtime was measurable lost revenue&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The initial prototype used HTTP polling. Clients hit an API endpoint every two seconds to check for game state updates. At 500 concurrent users, this generated over 15,000 HTTP requests per minute, the database was drowning, and the &quot;real-time&quot; experience felt sluggish and broken. It was immediately clear that polling could not scale. We needed a fundamentally different architecture.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;my-role-technical-lead-and-system-architect&quot;&gt;My Role: Technical Lead and System Architect&lt;/h3&gt;
&lt;p&gt;As the senior engineer leading a team of five, my responsibilities went beyond writing code. I made the core architecture decisions, designed the WebSocket infrastructure, established the event-driven messaging patterns, defined the horizontal scaling strategy, and mentored junior engineers on real-time system design.&lt;/p&gt;
&lt;p&gt;Olamilekan Lamidi was not just a contributor on this project — I owned the technical direction. Every critical design decision described in this article was one I either made directly or guided the team toward after evaluating alternatives.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;technical-deep-dive-building-the-real-time-infrastructure&quot;&gt;Technical Deep Dive: Building the Real-Time Infrastructure&lt;/h3&gt;
&lt;h4 id=&quot;1-websocket-server-architecture-with-node-js&quot;&gt;1. WebSocket Server Architecture with Node.js&lt;/h4&gt;
&lt;p&gt;I chose Node.js as the runtime for the WebSocket server. Its event-driven, non-blocking I/O model is purpose-built for maintaining thousands of persistent connections with minimal resource overhead. PHP (our backend for the investment and hotel systems) was poorly suited for long-lived connections, so I designed the gaming layer as a separate Node.js service communicating with the main platform through Redis message channels.&lt;/p&gt;
&lt;p&gt;The core WebSocket server was built on the &lt;code&gt;ws&lt;/code&gt; library rather than Socket.IO. While Socket.IO offers convenience features like automatic reconnection and room management, it adds protocol overhead and abstractions that reduce control. For a latency-sensitive gaming platform, I needed raw WebSocket performance with custom protocol handling.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const WebSocket = require('ws');
const Redis = require('ioredis');
const { v4: uuidv4 } = require('uuid');

const wss = new WebSocket.Server({
  port: 8080,
  maxPayload: 1024 * 16,
  perMessageDeflate: false,
  clientTracking: true,
});

const connections = new Map();

wss.on('connection', (ws, req) =&amp;gt; {
  const connectionId = uuidv4();
  const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;

  connections.set(connectionId, {
    ws,
    userId: null,
    rooms: new Set(),
    connectedAt: Date.now(),
    lastHeartbeat: Date.now(),
  });

  ws.on('message', (raw) =&amp;gt; {
    try {
      const message = JSON.parse(raw);
      routeMessage(connectionId, message);
    } catch (err) {
      ws.send(JSON.stringify({ type: 'error', code: 'INVALID_PAYLOAD' }));
    }
  });

  ws.on('close', () =&amp;gt; {
    cleanupConnection(connectionId);
  });

  ws.on('pong', () =&amp;gt; {
    const conn = connections.get(connectionId);
    if (conn) conn.lastHeartbeat = Date.now();
  });

  ws.send(JSON.stringify({
    type: 'connected',
    connectionId,
    serverTime: Date.now(),
  }));
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I disabled &lt;code&gt;perMessageDeflate&lt;/code&gt; deliberately. Compression adds CPU overhead per message, and since our payloads were small (typically under 500 bytes for game events), the bandwidth savings were negligible compared to the latency cost. This single configuration change reduced median message delivery time by 8ms across our benchmark tests.&lt;/p&gt;
&lt;h4 id=&quot;2-connection-management-and-authentication&quot;&gt;2. Connection Management and Authentication&lt;/h4&gt;
&lt;p&gt;In a gaming platform handling real money, every WebSocket connection must be authenticated. I implemented a token-based authentication flow where clients first obtain a short-lived JWT via the REST API, then present it during WebSocket handshake.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const jwt = require('jsonwebtoken');

function routeMessage(connectionId, message) {
  const conn = connections.get(connectionId);
  if (!conn) return;

  switch (message.type) {
    case 'authenticate':
      handleAuthentication(connectionId, message.token);
      break;
    case 'join_game':
      if (!conn.userId) return sendError(conn.ws, 'NOT_AUTHENTICATED');
      handleJoinGame(connectionId, message.gameId);
      break;
    case 'place_bet':
      if (!conn.userId) return sendError(conn.ws, 'NOT_AUTHENTICATED');
      handlePlaceBet(connectionId, message);
      break;
    case 'ping':
      conn.ws.send(JSON.stringify({ type: 'pong', serverTime: Date.now() }));
      break;
    default:
      sendError(conn.ws, 'UNKNOWN_MESSAGE_TYPE');
  }
}

function handleAuthentication(connectionId, token) {
  const conn = connections.get(connectionId);
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'],
      maxAge: '5m',
    });
    conn.userId = payload.userId;
    conn.ws.send(JSON.stringify({ type: 'authenticated', userId: payload.userId }));
    userConnectionIndex.set(payload.userId, connectionId);
  } catch (err) {
    conn.ws.send(JSON.stringify({ type: 'auth_failed', reason: 'INVALID_TOKEN' }));
    conn.ws.close(4001, 'Authentication failed');
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The JWT had a deliberately short expiry of five minutes. Since it was only used for the WebSocket handshake, there was no reason for it to live longer. Once authenticated, the connection was maintained through heartbeat pings rather than token refresh cycles.&lt;/p&gt;
&lt;h4 id=&quot;3-room-based-game-state-management&quot;&gt;3. Room-Based Game State Management&lt;/h4&gt;
&lt;p&gt;Each active game session functioned as a &quot;room&quot; — a logical grouping of WebSocket connections that should receive the same events. I implemented a lightweight room system without relying on external libraries:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const rooms = new Map();

function handleJoinGame(connectionId, gameId) {
  const conn = connections.get(connectionId);
  if (!conn) return;

  if (!rooms.has(gameId)) {
    rooms.set(gameId, new Set());
  }

  rooms.get(gameId).add(connectionId);
  conn.rooms.add(gameId);

  conn.ws.send(JSON.stringify({
    type: 'game_joined',
    gameId,
    players: rooms.get(gameId).size,
  }));

  broadcastToRoom(gameId, {
    type: 'player_joined',
    userId: conn.userId,
    playerCount: rooms.get(gameId).size,
  }, connectionId);
}

function broadcastToRoom(gameId, payload, excludeConnectionId = null) {
  const room = rooms.get(gameId);
  if (!room) return;

  const message = JSON.stringify(payload);
  for (const connId of room) {
    if (connId === excludeConnectionId) continue;
    const conn = connections.get(connId);
    if (conn &amp;amp;&amp;amp; conn.ws.readyState === WebSocket.OPEN) {
      conn.ws.send(message);
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One critical optimisation: I serialised the message payload once with &lt;code&gt;JSON.stringify&lt;/code&gt; outside the loop, then sent the same string buffer to every client. For a room of 500 players, this avoided 499 redundant serialisation operations per broadcast — a saving that compounds rapidly under load.&lt;/p&gt;
&lt;h4 id=&quot;4-event-driven-game-engine-with-redis-pub-sub&quot;&gt;4. Event-Driven Game Engine with Redis Pub/Sub&lt;/h4&gt;
&lt;p&gt;The game logic itself — dice rolling, bet resolution, payout calculation — ran in a separate process from the WebSocket server. This was a deliberate architectural decision. The WebSocket server's only responsibility was managing connections and delivering messages. Game logic, financial calculations, and database writes happened in dedicated worker processes.&lt;/p&gt;
&lt;p&gt;Redis Pub/Sub was the communication backbone:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const publisher = new Redis(process.env.REDIS_URL);
const subscriber = new Redis(process.env.REDIS_URL);

subscriber.subscribe('game:events', 'game:results', 'system:announcements');

subscriber.on('message', (channel, message) =&amp;gt; {
  const event = JSON.parse(message);

  switch (channel) {
    case 'game:events':
      broadcastToRoom(event.gameId, {
        type: 'game_event',
        event: event.eventType,
        data: event.payload,
        timestamp: event.timestamp,
      });
      break;

    case 'game:results':
      handleGameResult(event);
      break;

    case 'system:announcements':
      broadcastToAll({
        type: 'system_announcement',
        message: event.message,
      });
      break;
  }
});

async function handlePlaceBet(connectionId, message) {
  const conn = connections.get(connectionId);

  const betEvent = {
    gameId: message.gameId,
    userId: conn.userId,
    amount: message.amount,
    selection: message.selection,
    timestamp: Date.now(),
    idempotencyKey: message.idempotencyKey,
  };

  await publisher.publish('bets:incoming', JSON.stringify(betEvent));

  conn.ws.send(JSON.stringify({
    type: 'bet_acknowledged',
    idempotencyKey: message.idempotencyKey,
    status: 'processing',
  }));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This decoupling had three critical benefits:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Fault isolation:&lt;/strong&gt; If the game engine crashed, WebSocket connections remained alive. Users saw a brief pause rather than a disconnection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Independent scaling:&lt;/strong&gt; I could run multiple game engine workers to process bets in parallel without changing the WebSocket layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auditability:&lt;/strong&gt; Every game event flowed through Redis, creating a natural event stream that I logged for regulatory compliance and dispute resolution.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;5-horizontal-scaling-across-multiple-websocket-servers&quot;&gt;5. Horizontal Scaling Across Multiple WebSocket Servers&lt;/h4&gt;
&lt;p&gt;A single Node.js process can handle approximately 10,000 concurrent WebSocket connections before memory and CPU become constraints. But scaling WebSockets horizontally introduces a problem that REST APIs do not have: connection affinity. If Player A is connected to Server 1 and Player B to Server 2, a room broadcast must reach both servers.&lt;/p&gt;
&lt;p&gt;I solved this with Redis as the cross-server message bus:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const serverId = process.env.SERVER_ID || uuidv4();

subscriber.subscribe('ws:broadcast');

subscriber.on('message', (channel, raw) =&amp;gt; {
  if (channel !== 'ws:broadcast') return;
  const message = JSON.parse(raw);

  if (message.originServer === serverId) return;

  if (message.targetRoom) {
    broadcastToRoom(message.targetRoom, message.payload);
  } else if (message.targetUser) {
    sendToUser(message.targetUser, message.payload);
  }
});

function clusterBroadcastToRoom(gameId, payload) {
  broadcastToRoom(gameId, payload);

  publisher.publish('ws:broadcast', JSON.stringify({
    originServer: serverId,
    targetRoom: gameId,
    payload,
  }));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each WebSocket server instance subscribed to a shared Redis channel. When a game event needed to reach all players in a room, the originating server broadcast to its local connections and simultaneously published to Redis, where other servers picked up the message and relayed it to their local connections.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;originServer&lt;/code&gt; check prevented message loops — without it, a server would re-broadcast messages it had already delivered locally.&lt;/p&gt;
&lt;h4 id=&quot;6-heartbeat-monitoring-and-connection-cleanup&quot;&gt;6. Heartbeat Monitoring and Connection Cleanup&lt;/h4&gt;
&lt;p&gt;Stale connections are a silent performance killer in WebSocket systems. Users close browser tabs, lose internet connectivity, or let their phones go to sleep. Without proactive cleanup, the server accumulates zombie connections that consume memory and distort room player counts.&lt;/p&gt;
&lt;p&gt;I implemented a heartbeat interval that pinged every client every 30 seconds:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const HEARTBEAT_INTERVAL = 30000;
const HEARTBEAT_TIMEOUT = 45000;

setInterval(() =&amp;gt; {
  const now = Date.now();
  for (const [connectionId, conn] of connections) {
    if (now - conn.lastHeartbeat &amp;gt; HEARTBEAT_TIMEOUT) {
      conn.ws.terminate();
      cleanupConnection(connectionId);
      continue;
    }
    if (conn.ws.readyState === WebSocket.OPEN) {
      conn.ws.ping();
    }
  }
}, HEARTBEAT_INTERVAL);

function cleanupConnection(connectionId) {
  const conn = connections.get(connectionId);
  if (!conn) return;

  for (const gameId of conn.rooms) {
    const room = rooms.get(gameId);
    if (room) {
      room.delete(connectionId);
      if (room.size === 0) {
        rooms.delete(gameId);
      } else {
        broadcastToRoom(gameId, {
          type: 'player_left',
          userId: conn.userId,
          playerCount: room.size,
        });
      }
    }
  }

  if (conn.userId) {
    userConnectionIndex.delete(conn.userId);
  }

  connections.delete(connectionId);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This heartbeat mechanism maintained accurate player counts and ensured we never wasted resources on dead connections — critical when operating at 3,000+ concurrent users where even small inefficiencies compound.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;ensuring-game-integrity-the-financial-safety-layer&quot;&gt;Ensuring Game Integrity: The Financial Safety Layer&lt;/h3&gt;
&lt;p&gt;In a platform where real money is at stake, game outcome integrity is non-negotiable. I designed several safeguards:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Idempotent Bet Processing:&lt;/strong&gt; Every bet carried a client-generated idempotency key. If a network hiccup caused a duplicate submission, the game engine would recognise the duplicate key and return the original response rather than processing the bet twice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Atomic Balance Operations:&lt;/strong&gt; All balance changes used Redis &lt;code&gt;WATCH&lt;/code&gt;/&lt;code&gt;MULTI&lt;/code&gt;/&lt;code&gt;EXEC&lt;/code&gt; transactions for in-memory operations and database-level row locking for persistence, ensuring no user could ever bet more than their balance — even under concurrent requests.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;async function processBalanceDeduction(userId, amount, idempotencyKey) {
  const lockKey = `lock:balance:${userId}`;
  const lock = await acquireLock(lockKey, 5000);

  try {
    const processed = await redis.get(`idempotency:${idempotencyKey}`);
    if (processed) return JSON.parse(processed);

    const balance = parseFloat(await redis.get(`balance:${userId}`));
    if (balance &amp;lt; amount) {
      return { success: false, reason: 'INSUFFICIENT_BALANCE' };
    }

    const newBalance = balance - amount;
    await redis.set(`balance:${userId}`, newBalance.toString());

    const result = { success: true, newBalance, deducted: amount };
    await redis.setex(`idempotency:${idempotencyKey}`, 3600, JSON.stringify(result));

    return result;
  } finally {
    await releaseLock(lock);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Provably Fair Outcomes:&lt;/strong&gt; Game results were generated using a combination of server seed and client seed, hashed together. Users could verify after each round that the outcome was not manipulated. This was both a regulatory requirement and a trust-building feature.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;monitoring-and-observability-in-production&quot;&gt;Monitoring and Observability in Production&lt;/h3&gt;
&lt;p&gt;Operating a real-time gaming system requires comprehensive observability. I built custom monitoring dashboards tracking:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Active connections per server instance&lt;/strong&gt; — to trigger auto-scaling&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Message throughput&lt;/strong&gt; — messages sent per second across all rooms&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Event latency&lt;/strong&gt; — time from game engine event emission to client delivery&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redis pub/sub lag&lt;/strong&gt; — early warning for message bus saturation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Game round completion times&lt;/strong&gt; — anomaly detection for stuck game sessions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I instrumented the WebSocket server with Prometheus metrics:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const client = require('prom-client');

const activeConnections = new client.Gauge({
  name: 'ws_active_connections',
  help: 'Number of active WebSocket connections',
});

const messageLatency = new client.Histogram({
  name: 'ws_message_latency_ms',
  help: 'Message delivery latency in milliseconds',
  buckets: [5, 10, 25, 50, 100, 250, 500],
});

const messagesPerSecond = new client.Counter({
  name: 'ws_messages_total',
  help: 'Total WebSocket messages sent',
  labelNames: ['type'],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These metrics fed into Grafana dashboards that gave me real-time visibility into system health. On several occasions, a spike in message latency gave us a 10-minute early warning before a Redis memory issue would have caused visible user impact.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-results-production-metrics&quot;&gt;The Results: Production Metrics&lt;/h3&gt;
&lt;p&gt;After three months of iterative development and load testing, Olamilekan Lamidi and the engineering team shipped the gaming backend into production. The measurable outcomes exceeded our original targets:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Metric &lt;/th&gt;&lt;th&gt;Target &lt;/th&gt;&lt;th&gt;Achieved&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Concurrent WebSocket connections &lt;/td&gt;&lt;td&gt;3,000 &lt;/td&gt;&lt;td&gt;3,200+ sustained peak&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Event delivery latency (p50) &lt;/td&gt;&lt;td&gt;&amp;lt;100ms &lt;/td&gt;&lt;td&gt;32ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Event delivery latency (p99) &lt;/td&gt;&lt;td&gt;&amp;lt;200ms &lt;/td&gt;&lt;td&gt;87ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;System uptime (monthly) &lt;/td&gt;&lt;td&gt;99.5% &lt;/td&gt;&lt;td&gt;99.9%&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Bet processing throughput &lt;/td&gt;&lt;td&gt;500/min &lt;/td&gt;&lt;td&gt;850+/min&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Connection recovery after deploy &lt;/td&gt;&lt;td&gt;&amp;lt;5s &lt;/td&gt;&lt;td&gt;~2.5s average&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The sub-50ms median latency was particularly significant. Users experienced game events as truly instantaneous — dice rolls resolved, payouts appeared, and leaderboards updated in what felt like real time. This directly correlated with user engagement metrics: average session duration increased by 35% compared to the earlier HTTP polling prototype.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;lessons-learned&quot;&gt;Lessons Learned&lt;/h3&gt;
&lt;p&gt;Building this system taught me principles that I have carried into every project since:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Separate connection management from business logic.&lt;/strong&gt; The WebSocket server should be a dumb pipe. All intelligence belongs in backend workers communicating through message queues.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design for graceful degradation.&lt;/strong&gt; When the game engine was temporarily slow, the WebSocket layer continued serving heartbeats and acknowledged messages. Users experienced a delay, not a crash.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Serialise once, send many.&lt;/strong&gt; In broadcast-heavy systems, the cost of JSON serialisation dwarfs the cost of network transmission. Serialise the payload once and reuse the buffer.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Monitor latency percentiles, not averages.&lt;/strong&gt; A 30ms average means nothing if 5% of your users experience 500ms delays. The p99 is what your angriest users feel.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test with realistic load patterns.&lt;/strong&gt; Our load tests simulated bursty evening traffic with sudden spikes, not steady-state throughput. Production traffic is never smooth.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Architecting a real-time gaming backend was the most technically challenging and rewarding project of my career up to that point. It demanded a deep understanding of network protocols, distributed systems, concurrent programming, and financial transaction safety — all operating under latency constraints that left no room for architectural shortcuts.&lt;/p&gt;
&lt;p&gt;The patterns I developed at Lordwin Group — event-driven architecture, Redis-backed horizontal scaling, idempotent financial operations — became foundational to how I approach every real-time system I have built since. At VacancySoft, I applied similar event-driven patterns to handle 50,000+ daily API requests. At 2am Tech, the idempotent transaction processing pattern directly informed the financial workflows I built for the Addio platform.&lt;/p&gt;
&lt;p&gt;Real-time systems are hard. But the engineering discipline they dem&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/14146/how-architected-real-time-gaming-backend-supporting-000-concurrent-users-with-websockets</guid>
<pubDate>Tue, 07 Apr 2026 11:30:41 +0000</pubDate>
</item>
</channel>
</rss>