Enterprise architecture is full of technology debates that are really ownership debates.
Do we buy a managed capability? Reuse something that already exists (open source or internal)? Or build the missing piece ourselves?
Treated as slogans, those words start culture wars. Treated as an EA decision framework, they become a repeatable way to protect cost, time, and differentiation.
This article is that framework when to choose build, when to choose buy, when to choose reuse.
I will ground it with one example an offline segmentation engine that followed our Dynamic Real-Time QueryEngine work so the guidance is not abstract. That example is illustrative, not the whole story. The same questions apply whether you are deciding on compute, messaging, identity, CI, observability, or a data platform.
The example is evidence. The decision pattern is the article.
The EA triangle, without the mythology
Choice What it means in EA terms
Buy Pay a vendor for a managed capability (product, platform, or cloud service). You buy outcomes and operating leverage and you buy their constraints and bill shape.
Reuse Adopt capability that already exists: open source, an internal platform, a shared service. You do not reinvent the commodity.
Build Create what you cannot get (or cannot afford / cannot customize) from buy or reuse alone — ideally the thinnest layer that unlocks fit.
Mature architecture almost never picks only one forever. The skilled move is composition .
Reuse the commodity engine.
Buy when the managed premium is rational.
Build only the control plane, integration, or scaling policy that makes the commodity fit your enterprise.
That composition is what good EA should sound like in an architecture board on Spark, or on anything else.
When to choose REUSE
Choose reuse when the capability is commodity, proven, and not where you win in the market.
Reuse when:
The problem is already solved well by open source or an internal shared platform
Differentiating on a from-scratch rewrite would be vanity, not strategy
Standards, community, and hiring markets already exist around the tool
You can accept the core abstraction (APIs, execution model) and invest above it
Do not confuse reuse with do nothing. Reuse still needs governance versions, security, support model, upgrade ownership.
One example (segmentation) Apache Spark was reuse. Offline segmentation needed distributed batch compute. Spark already owned that problem. Building a new engine would have been enterprise malpractice.
EA rule: Reuse what is commodity. Put architecture energy above it, not underneath it.
When to choose BUY
Choose buy when a vendor’s managed service is the fastest, safest path and the total cost of ownership (money + risk + headcount) beats building.
When to choose BUILD
Choose build when reuse gives you the core, buy is too expensive or too rigid, and a small, justified build creates enterprise fit.
Build when
You need customization the managed product will fight (discovery, scheduling policy, tenancy, warm-up, per-job limits).
Build time is short relative to the ongoing buy premium.
Build cost is defensible in payback (engineering spike vs years of platform fees).
The build surface is thin runtime, scaler, adapter, control plane not a rewrite of the commodity.
The capability sits on a critical path where vendor constraints become business constraints.
Do not build when
You are bored and want to re-implement Spark / Kafka / Kubernetes.
The only argument is “we are smart enough”.
Nobody will own upgrades, security, and 2 a.m. failures.
Buy is only slightly expensive and your team is already overloaded.
One example (segmentation):We built the runtime around reused Spark not Spark itself.
Spark master on one EC2, IP published to consumers.
Slaves as autoscaling container pods, joining via master IP / props on spin-up.
Baseline around 2 workers, scale to N with demand.
A custom scaler warm capacity ~5 minutes before job start min/max nodes per job.
Delivery in about 10 days
Ongoing cost far below the EMR path (10× difference for us)
EA rule :Build the thinnest layer that turns commodity reuse into enterprise fit and only when payback and ownership are real.
An enterprise decision checklist (use this in architecture reviews)
Before the board debates tools, answer these in order:
1. Is this differentiating or commodity?
Commodity → prefer reuse (or buy a managed wrapper). Differentiating → may justify build.
2. What exactly are we buying / reusing / building?
Force precision. The engine, the managed platform, and your scaler/adapter are three different decisions.
3. What is the 12–36 month bill for buy at our utilization?
Include idle, minimum footprint, support, and growth. Compare to reuse + thin build.
4. Can the managed option scale?
Assume yes until proven otherwise then judge cost and control, not myths.
5. What customization do we actually need?
List behaviors buy cannot give cleanly.
6. What is the smallest useful build, and how long will it take?
If the answer is months with unclear owners, buy may win. If the answer is days with clear ownership, build can win.
7. Who operates it after launch?
No owner → do not build. Buy or reuse through a platform team that already exists.
8. What is the exit / change cost?
Buy lock-in, reuse upgrade debt, build maintenance pick the debt you can service.
If you cannot answer these, you are not ready to choose. You are ready to argue.
This is one application of the framework useful because it shows reuse, buy evaluation, and build in the same decision. Your next board topic might be messaging, identity, or CI the questions stay the same.
How we got here real-time first, then offline
The story does not start with Spark. It starts with real-time.
We had already built a Dynamic Real-Time Query Engine a platform so CRM and marketing could understand player behavior the instant an event happened and act while the player was still on screen. Milliseconds, not nightly batch. Live segments, live treatments, a shared real-time data and query layer instead of bolting another third-party point tool onto CRM.
I wrote that journey separately the Architecture Board decision to build a platform, the stack, the trade-offs. The short version for this article we built real-time segmentation capability in-house because existing vendor tools could not do what we needed at that latency and because buying our way out of every CRM gap was the wrong enterprise pattern.
Then management asked the next question.
Real-time covers the moment the player is on screen. The business also needed offline segmentation richer, heavier, historical, warehouse-scale segment computation that does not have to finish in milliseconds, but still has to be ours to operate and affordable to run. Campaigns, deeper behavioral cohorts, reconciliation-style and batch analytics workloads extend the segmentation story beyond the hot path.
The pressure behind that ask was familiar to any EA review avoid huge cost from third-party vendors for yet another segmentation CDP-style capability we would rent forever. We had already proven we could own the real-time side. Extending to offline was the natural platform move if we chose build / buy / reuse correctly for batch compute, not by copying the real-time design blindly.
So the sequence was deliberate:
1. Build real-time(Dynamic Real-Time Query Engine) act in milliseconds stop depending on tools that were never designed for that window.
2. Extend to offline management wants an offline segment engine for the work that is batch by nature.
3. Apply build vs buy vs reuse on the offline runtime so we do not replace one vendor bill with another (or with EMR-scale spend) after we just escaped third-party lock-in on the real-time path.
Offline was not a random Spark project. It was the batch half of a segmentation platform we were already committing to own.
Context: what offline needed
We needed an offline segmentation engine bursty batch work over historical and large-scale data, not live gameplay milliseconds. Spark was the right compute model for that half of the problem.
Reuse
Open-source Spark the engine. Commodity. Do not reinvent.
Buy (evaluated)
Amazon EMR managed Spark path. It can scale. For our usage, the billing model was ~10× a lean self-run option. That failed the EA cost test.
Build (chosen for the thin layer)
Built piece Why it was build, not buy
Containerized Spark workers on a container service Control images, join. behavior, unit economics
Master on EC2 with published IP(domain) Consumer discovery the way our enterprise clients needed
Job-aware scaler |Warm ~5 mins before start; min/max nodes per job
10-day delivery Build time low enough to justify vs ongoing EMR premium
Outcome in EA language
Decision
Choice
Rationale
Compute engine
Reuse
Spark is commodity
Managed Spark platform
Do not buy (this case)
Bill ~10× despite scaling capability
Autoscaling runtime + scaler
Build
Customization + short build + cost payback
How that built runtime worked
1. Master on EC2 — stable control plane IP(domain) published to job submitters / consumers.
2. Workers as containers— pods scale out on spin-up they receive master IP. Spark props and associate with the master.
3. Scaler — warm workers 5 minutes before scheduled jobs each job declares min and max nodes.
4. Economics — sit near a small floor (often 2 workers), rise to N when the job needs it.
Build was scoped: runtime and policy, not a science project.
The same triangle elsewhere (same rules, different topics)
Once the framework is clear, you can map it onto other enterprise decisions without changing the logic:
Domain
Typical reuse
Typical buy question
Typical thin build
Messaging
RabbitMQ / Kafka (OSS)
Managed broker / streaming
Routing policies, CDC bridges, consumer platforms
Data / analytics
Spark, Flink, warehouse engines
EMR, serverless Spark, SaaS BI
Job-aware scalers, governance adapters
Identity
OIDC / standard protocols
IdP / IAM suites
Enterprise-specific policy and integration
CI / delivery
Jenkins / GitHub Actions runners patterns
Fully managed CI
Internal pipelines and quality gates
Observability
Prometheus / OpenTelemetry
Vendor APM suites
Cardinality controls, standard labels, routing
You will not always land on “reuse + thin build.” Sometimes buy wins cleanly. Sometimes pure reuse on an internal platform is enough. The point of EA is to decide deliberately, using one consistent test not to copy the Spark outcome onto every domain.
Anti-patterns enterprise architecture should stop
Buy everything— hides a bad bill behind a brand logo
Build everything — confuses engineering pride with strategy
Reuse means free — ignores upgrade, CVE, and ownership cost
It can’t scale” as a buy dismissal — often false check the invoice instead
Building the engine instead of the adapter — rewriting Spark/Kafka/K8s is rarely EA
No payback math — if you cannot compare a short build to a long managed premium, you are guessing
One example = one dogma a good Spark decision is not a mandate to avoid managed services forever
Closing: the EA stance
Reuse when it is commodity.
Buy when the managed operating model and the bill fit.
Build when a short, owned, thin layer unlocks customization and cost the vendor will not give you.
One example made that tangible for us: after we built a real-time query / segmentation engine in-house, management wanted to extend to offline to avoid another wave of third-party vendor cost. For that offline segment engine we reused Spark, skipped EMR on a 10× bill (even though EMR can scale), and built a job-aware autoscaling fabric in 10 days with warm-up and per-job min/max.
Use the example to understand the pattern. Use the checklist on the next decision whatever the domain.
That is enterprise architecture doing its job: fit over fashion, composition over slogans, framework over single-story dogma.
If you sit on an architecture board what question do you ask first differentiating vs commodity, or which logo is trending? Drop a comment.
As a TOGAF practitioner, I hear the same complaint often TOGAF is too theoretical.
That is only half true.
TOGAF looks heavy when you study it as a framework. When you apply it the right way, especially through the Architecture Development Method (ADM), it becomes surprisingly simple and practical. Whenever a complex business problem lands on your desk, try following ADM. It does not give you magic answers it gives you a clear picture what matters, who owns it, what to build, what to govern, and what to change next.
In this article, I map a real-time fraud engine we built for high-scale gaming and payments onto ADM not as a certification exercise, but as a working method.
TOGAF ADM applied to a real-time fraud engine Preliminary through Phase H with requirements at the center signal → detect → decide → case → learn contrast of without ADM vs with ADM.
Why a real-time fraud engine is a good ADM proving ground
High-scale gaming and payments are a bad place for vague architecture.
Business risk is immediate (loss, chargebacks, regulatory heat, player trust).
Decisions sit on hot paths game and wallet flows cannot wait for a leisurely batch job.
Data is everywhere (events, payments, device, behavior, cases) and late or wrong data becomes wrong blocks.
Buy vs build vs reuse shows up hard (vendor fraud suites vs in-house rules/ML vs shared event platforms).
Change never stops (new abuse patterns weekly). Phase H is not optional poetry.
If ADM only works on calm greenfield HR portals, it is not Enterprise Architecture. A real-time fraud engine is the stress test.
ADM in one sentence (before we use it)
ADM is an iterative method to move from “why are we changing?” to “what must be true in business, data, applications, and technology?” to “how do we deliver and govern without lying to ourselves?”
You do not need every artifact. You need every phase question answered well enough to decide.
Preliminary Phase — get permission to architect, not just to code
ADM question: Are we allowed to run architecture as a controlled change, with principles and scope?
For a real-time fraud engine, Preliminary is where enterprises usually skip and later regret.
What we fixed here:
Architecture principles the engine must obey (examples prefer explainable decisions on player-facing blocks no silent irreversible wallet actions without audit reuse enterprise event sources before inventing parallel telemetryprotect PII in case files protect hot-path latency budgets).
Org touchpoints:Risk, Payments, Customer Support, Legal/Compliance, Game/Product, Data Platform, Security.
Repository / decision log: fraud architecture decisions are recorded, not trapped in Slack.
Scope fence: this cycle is “real-time fraud decisioning and case workflow for online play and payments” — not “boil the ocean AML + KYC + every analytics dashboard.”
Without Preliminary, every squad invents private fraud truth. ADM starts by making the fraud engine an enterprise concern.
Phase A — Architecture Vision name the pain and the target state in business language
ADM question: What problem are we solving, for whom, and what does success look like?
Fraud conversations love tools. Phase A refuses tools first.
Drivers we made explicit
Abuse and payment fraud leaking into loss and support load
Real-time game and wallet paths needing decisions in tight latency budgets
Investigators needing a case trail, not only a model score
High-scale concurrency peak play and payment bursts, not demo traffic
Management wanting to avoid unbounded third-party fraud-platform cost where an in-house + selective-buy mix is defensible
Vision statement (shape we aligned on)
A real-time fraud engine that consumes trusted enterprise events, decides with rules and models under clear SLAs on gaming and payment hot paths, opens explainable cases, and improves continuously without a second shadow data estate.
Stakeholders and concerns
Stakeholder
Concern
Risk / Fraud ops
Catch rate, case quality, tooling
Product / Game
False positives, player friction
Payments
Auth/capture risk, chargebacks
Support
Clear reasons, override path
Platform / EA
Reuse events, avoid duplicate stacks
Finance
Loss vs platform TCO
Phase A outputs a vision stakeholders can argue with. If they only argue about vendor logos, you are still in sales mode, not ADM.
Phase B — Business Architecture how fraud work actually runs
ADM question:What business capabilities, value streams, and processes must change?
For a real-time fraud engine, Business Architecture is the difference between a model demo and an operating model.
Capabilities we mapped
Signal intake- player, session, payment, device, bonus, gameplay events
Detection — rules, velocity, graph/collusion signals, ML scores
Decisioning — allow / challenge / hold / block / step-up (sync on hot paths)
Case management — queue, investigate, evidence, disposition
Feedback — confirmed fraud / false positive back into rules and training
Reporting — loss, funnel, SLA, auditor views
Value streams (simplified)
1. Live play / payment → real-time risk check → decision → continue or friction
Decision and reason codes as first-class data (not log line archaeology)
Case and evidence store with retention and access control
Label / outcome data for model and rule learning
ADM forces a hard line: the fraud engine does not get a private parallel universe of “almost the same” player events. Reuse enterprise data products build fraud-specific semantics on top.
Application Architecture
Application Architecture building blocks: Decision API, detection workers, rules and policy, model scoring, case management, admin/simulation, and integration adapters.
Application building block
Responsibility
Fraud Gateway / Decision API
Real-time sync decisions on hot paths
Detection workers / stream jobs
Async scoring and pattern detection
Rules & policy service
Versioned, testable rules
Model scoring service
ML inference with fallback
Case Management app
Investigator UX and workflow
Admin / simulation
Shadow rules, backtests
Integration adapters
Game, payments, wallet, support, notifications
Baseline often shows rules hard-coded inside payment or game services. Target separates decisioning from channel apps so high-scale gaming and payments call one fraud engine instead of each inventing one.
Phase D — Technology Architecture make non-functionals boringly explicit
ADM question: What technology standards and patterns meet the SLAs?
For a real-time fraud engine, NFRs are the architecture:
Latency — hot-path decision budget on game and payment calls
Availability— fail-open vs fail-closed policy by transaction type (a conscious business choice)
Throughput — peak concurrent games and payment bursts
Auditability— every material decision replayable
Security— least privilege on cases; encryption; secrets
Technology patterns that fit this class of engine:
Event backbone already in the enterprise
Low-latency decision service horizontally scaled
Stream/nearline enrichment without blocking the sync path
Datastores fit to access patterns (profiles vs cases vs features)
Isolation of model runtime from rule runtime so one failure mode does not blind both
Phase D is where buy/reuse/build returns vendor case tools might be buy event bus reuse decision service build. ADM does not mandate build. It mandates fit.
Phase E — Opportunities & Solutions packages, not wishlists
ADM question: What solution building blocks and work packages close the gap?
We grouped work so delivery could breathe:
1. Foundation — decision API, reason codes, audit log, adapters (payment + one game path)
5. Decommission— retire duplicate checks and overlapping vendor modules
Each package has dependencies and a business outcome. “Big bang fraud rewrite” is not a Phase E output. It is a resignation letter.
Phase F — Migration Planning sequence risk reduction
ADM question:In what order do we move, with what transitional architectures?
Real-time fraud migration is dual-run friendly if you design it
Shadow mode: new engine scores beside old checks compare
Dark launch: decide but do not enforce on selected cohorts
Enforce on lower-blast-radius payment types first
Expand to hotter game paths when false-positive SLAs hold
Keep break-glass override with support/risk RACI
Roadmap is calendar + risk, not only story points. ADM Phase F makes that respectable in front of finance and risk leadership.
Phase G — Implementation Governance architecture is a gate, not a spectator
ADM question: Are delivery teams implementing the architecture we agreed?
Governance checks that mattered for our engine
New channel cannot embed local fraud ifs — must call the Decision API
Reason codes required for player-facing friction
Fail-open/closed matches policy matrix
Hot-path latency budgets are measured, not assumed
PII in cases classified and retained correctly
Model/rule changes go through simulation evidence
Without G, ADM becomes a kickoff deck. With G, ADM becomes how the enterprise stays honest.
Phase H — Architecture Change Management: fraud never sits still
ADM question: How do we respond when the enemy changes tactics?
Fraud is adversarial. Phase H is continuous:
Monitoring abuse pattern shifts and model drift
Intake for new typologies from investigators
Architecture runway for new signals (new payment rail, new game type)
Re-entry to earlier ADM phases when the vision or capability map breaks
If your TOGAF practice has no Phase H operating rhythm, you certified a museum.
Requirements Management — the center that a fraud engine will flood
Every phase dumps requirements into a managed set latency, explainability, retention, jurisdictions, payment-scheme rules, player-experience limits, audit.
ADM’s center is not bureaucracy. It is how you stop a single loud incident from silently rewriting enterprise principles.
What applying ADM changed
What applying ADM changed: without ADM versus with ADM on the real-time fraud engine.
Without ADM
With ADM on the real-time fraud engine
Vendor demo drives scope
Vision and capabilities drive scope
Engineers argue tools first
Business architecture names the work
Shadow data copies appear
Data architecture reuses enterprise events
Hot path latency is a surprise
NFRs are Phase D contracts
Big-bang cutover fantasy
Migration with shadow and dark launch
Architecture finishes at design review
Governance and change management continue
TOGAF did not give us the fraud engine. ADM gave us a way to decide and sequence one for high-scale gaming and payments.
How to steal this for your next Architecture Board
1. Pick a real problem with enemies, regulators, or revenue on the line.
2. Walk A→H as questions, not as mandatory 40-deliverable cosplay.
3. Write principles in Preliminary before tool shortlists.
4. Force Business Architecture before ML heroics.
5. Make fail-open/closed latency, and explainability explicit in Technology Architecture.
6. Package E/F so risk shrinks every release.
7. Keep G/H alive or admit you only did waterfall with extra shapes.
Whenever a complex business problem lands on your desk, try following ADM. It will not hand you magic answers. It will hand you a clear picture.
Wrapping-Up
TOGAF looks heavy when you study it as a framework. It becomes simple when you apply ADM to something that can hurt the business.
For us, that something was a real-time fraud engine for high-scale gaming and payments. Stakeholders, capabilities, data, applications, technology, migration, governance, and change under adversarial load.
Theory becomes useful the day you stop redrawing the ADM crop circle and start answering its questions against a working method.
We did not pick one cache — we mapped each workload to CAP
On a live gaming platform, someone always asks: “Hazelcast or Redis?”
As if one logo must win the whole cluster.
We did not choose that way. After modularizing game engines — lobby, game, transactions on their own paths — we needed recoverable game state when a node dies, coordination across JVM services, and high-volume leaderboard reads that could tolerate a little lag. Hazelcast and Redis each fit a different CAP shape.
This article is Hazelcast and Redis — not vs. How we use a remote Hazelcast distributed cluster for replicated table state and failover, and Redis for leaderboards. CAP is the lens; live tables, pinned game nodes, and money kept off the tick are the examples.
Architecture, to us, is not picking logos. It is design, prototypes, and naming tradeoffs out loud. Choose C or A because the business can live with the downside — CAP is a lens, not a bible.
CAP in one minute
Letter
Plain meaning
C — Consistency
Every read sees the latest write (or you get an error)
A — Availability
Non-failing nodes keep answering
P — Partition tolerance
System keeps running when the network splits
P is not optional at scale. Under partition you pick C (refuse stale / fence / error) or A (keep serving, maybe stale).
If the business says…
Lean toward
Gaming example
“Wrong money or double owner is unacceptable”
C (refuse / fence / error)
Table ownership, routing on failover
“Empty screen or long outage hurts more than slightly stale data”
A (keep serving)
Leaderboard top 100, presence hints
Workload
CAP lean
Tool
Live table state + failover
C when JVM or grid member fails
Hazelcast IMap + CP
Leaderboard top 100
A — empty board worse than stale rank
Redis ZSET
Wallet / payout
Durable C
DB + queue
Same company, same platform — different maps, different clusters, different backup settings.
Our model: pinned node + IMap replica
We pin one game to one game node. That node is the active writer — pot, seats, turn, timers — single owner, local memory, fast tick.
Every state-changing action (fold, call, raise, timer) also replicates a snapshot to Hazelcast `IMap` on a remote cluster with sync backup. If the pinned node dies, a new node loads that snapshot and resumes — same pot, same turn.
Layer
Role
Pinned game node (local)
Active writer during play — every fold/call/raise mutates local state first (fast tick)
Hazelcast IMap (sync backup)
Recovery copy — replicate snapshot on each state change; new node loads table if pinned node dies
CP lock + fencing
Only one game node may resume the table after failover
Table → node routing (IMap)
Where clients reconnect after failover
Queue + DB
Buy-in / payout — durable money truth, not per-action IMap
Normal action: local mutate → IMap.set(tableId, snapshot) → broadcast
Game node dies: CP lock → new node IMap.get → hydrate → clients reconnect
oney still flows queue + DB — not per-action cache ledger.
Dedicated six-member grid. Game / lobby / transaction services are clients only.
Model
What it means
What we did
Embedded
Each game JVM is also a Hazelcast member
No — different scale lifecycles
Client → remote cluster
Game services are HZ clients; grid is dedicated
Yes — our model
[ Game node A — active writer ] ──client──┐
[ Game node B — failover target ] ──client──┼──► [ Hazelcast cluster ]
[ Lobby service ] ──client──┘ game state IMap (sync backup)
routing IMap, CP locks
Why remote cluster: scale game fleet independently; blast radius; tune sync backup on grid nodes; game JVM churn does not reshape partitions.
Jobs we gave Hazelcast
Job
Why Hazelcast (remote cluster)
Replicated game state (IMap)
Every state-changing action → snapshot to IMap with sync backup; new game node loads table if pinned node dies
Table → node routing
Central IMap; all services know where each table lives
Ownership on failover
CP subsystem + fencing — only one node may resume the table
Soft metadata
Presence, session hints — rebuildable; async OK
CAP justification: losing a live money table mid-hand, or two nodes serving different pots, is unacceptable → sync backup on game-state `IMap`, CP + fencing on failover.
What we did not do: two writers without fencing wallet ledger in HZ async-only zero-backup on game-state maps.
Redis: leaderboards (AP-leaning)
Job
Why Redis
Global / seasonal leaderboards
ZSET — score-ordered ranks, top-N in one structure
High read volume
Simple, fast, operationally familiar
Ranking display
Product tolerates seconds of staleness across replicas
Leaderboards are AP-friendly: show rank #6 when true rank just became #5 beats an empty board. Redis ZSET, high reads, replica lag OK for display.
What we did not do: Redis as sole payout source; linearizable global #1 on every read full match simulation in Redis.
Workload
CAP preference
Choice
Why
Live table / pot / turn
Strong C per match
Game node + HZ IMap
Local active writer; replicate snapshot to sync-backed IMap for failover
Table → node routing
C on failover
Hazelcast
Sync backup on dedicated HZ members; game nodes are clients
Ownership after node death
CP (no split-brain)
Hazelcast CP
One successor; fence old owner
Buy-in / payout handoff
Durable, ordered
Queue + DB
Not cache-as-ledger; gameplay stays off money path
Leaderboard / ranking
A over strict C
Redis
ZSET top-N; brief staleness OK; high read QPS
Presence / soft hints
Eventual OK
HZ async or TTL
Rebuildable; not money truth
When things fail
Game node goes down
1. Pinned node dies mid-match
2. One successor acquires CP lock (fences old node)
3. ‘IMap.get(tableId)` → rebuild local state
4. Routing updated → players reconnect
5. Resume or void by product rules
CAP: C on failover — we paid sync replication during play.
Hazelcast grid member goes down
1. HZ member loss → backup promotes → short rebalance
2. Game clients retry `get`/`set`
3. Replicated game state survives on promoted copy
Avoid: state living only in one JVM with no recoverable `IMap` copy.
Anti-patterns
One cache for everything — CAP differs by workload
Redis Redlock for table ownership — we used Hazelcast CP + pinned node
Hazelcast for every leaderboard — Redis ZSET won for AP serving
Async / zero-backup on game-state `IMap`
Strong consistency everywhere — unnecessary for presence and ranks
Wrapping-Up
Hazelcast and Redis — not vs.
Hazelcast — remote cluster: replicated game state in `IMap`(sync backup), routing, CP on failover. Active writer on pinned node; recoverable copy on the grid.
Redis — leaderboards: available top-N, briefly stale OK.
CAP: pick C or A per workload. Architecture: design, prototype, name tradeoffs. Sync backup costs latency — we paid it where failure consistency mattered. Replica lag costs perfect ranks — we paid it where availability mattered.
Draw the workload on CAP first. Then place Hazelcast, Redis, and the game server.
if your platform debates “Hazelcast or Redis” as one winner — what workload would you map first?
What we keep missing in the Architecture Review Board — and the drawbacks of each pattern
When a new requirement lands, we open the Architecture Review Board (ARB).
The room fills quickly. Mid-level engineers come prepared — and passionate. Someone says: “We’ll do gRPC.” Someone else: “GraphQL is better for the UI.” Another: “Just expose a REST API — everyone knows it.”Occasionally WebSockets enter the chat because “we need real-time.”
I understand the concern. Everyone is trying to move fast and pick a modern, credible tool.
But sometimes we miss the fundamentals between all of them.
gRPC, GraphQL, REST, and WebSocket are not competing logos. They are different communication patterns. They answer different questions about how long the conversation lasts, who is calling, how much of the data the client needs, and what fails when the network gets ugly.
In this article I explain when to choose which communication pattern, and — just as important — what the drawbacks of each are. The examples come from high-scale gaming and payments platforms we built: matchmaking, live tables, real-time query APIs, and service-to-service paths behind the client.
Same ARB energy. Clearer criteria.
What the ARB should ask before naming a protocol
Before anyone says “gRPC” or “GraphQL,” force these questions:
1. Is this a short request/response, or a living session?
2. Is the caller a browser/app, a partner, or an internal service we control?
3. Does the client need a fixed contract, a flexible read shape, or a stream of events?
4. Are we optimizing for universality and debuggability, binary efficiency, or push latency?
5. Who owns versioning when the contract changes?
If you cannot answer those, you are not choosing a pattern. You are choosing a buzzword.
REST — when to choose it
Choose REST when the interaction is “ask and get an answer”: short-lived, resource- or command-oriented, and best served by ordinary HTTP (methods, status codes, gateways, caches).
Matchmaking was a scalable REST service. The client called it first. The service read live tables/seats from cache, applied rules, and returned game table info (later including a sticky cookie). Only then did the client know where to play.
The Dynamic Real-Time Query Engine exposed REST APIs so CRM, ML, and targeting could send a behavioral query and get a decision. Request/response. Wide consumer set. Spring Boot + HTTP was the interoperable door.
Why ARB likes REST (for good reasons)
Every platform can call it
API gateways already know how to auth, rate-limit, and route it
Easy to debug (`curl`, logs, status codes)
Natural for onboarding, config, “place me,” “get segment,” partner integrations
Drawbacks / backdrops
Chatty UIs — one screen may need many round trips
Over-fetching / under-fetching — one DTO rarely fits every client
Poor fit as the primary game loop — polling REST for table state is a smell
Versioning sprawl — `/v1` `/v2` and bloated payloads if governance is weak.
ARB line: If the conversation ends when the response returns, REST is the default until proven otherwise.
WebSocket — when to choose it
Choose WebSocket when you need a persistent, bidirectional channel: server push, client push, session as long as the user is inside an experience.
Where it fit for us
Gameplay was never REST polls. Early on, clients held a persistent socket for the life of the session — input in, broadcast out.
Later we moved to WebSockets behind an API gateway (auth, security, rate limits, routing).
Flow:
1. REST matchmaking → table info + sticky cookie
2. WebSocket through the gateway → live game path
3. Stay connected for the session; server fans out state
Historically we also separated lobby vs game connections so browsing spikes did not punish a live table.
Why ARB likes WebSocket
True real-time without fake polling
Efficient for many small messages after handshake
Matches how games, live ops, and collaborative UIs actually work
Gateway and LB behavior become architecture — not an afterthought
Horizontal scale is non-trivial — affinity, failover, “who owns this gameId?”
Abuse risk — open sockets are a DoS surface if rate limits and auth are weak
Wrong tool for one-shot CRUD — login and “get config” do not need a socket
ARB line: Use REST (or similar) to enter the room. Use WebSocket to live in the room.
gRPC — when to choose it
Choose gRPC when callers are services inside your trust boundary, you want a strict contract (Protobuf), and you care about efficiency, deadlines, and codegen. Where it fits in stacks like ours
Unary or streaming RPCs without inventing a private framing protocol
A fraud Decision API or internal RTQE neighbor might speak gRPC internally while broader consumers still see REST.
Why ARB likes gRPC
Compact binary payloads; strong performance story
Contract-first with breaking-change discipline
Deadlines, status codes, streaming built in
Excellent for polyglot microservices you own
Drawbacks / backdrops
Browsers are awkward — need grpc-web or a proxy; not “just fetch”
Ops complexity — HTTP/2 load balancing, observability, and client libraries must be mature
Less human-debuggable than JSON REST in a pinch
Overkill for simple public or partner APIs
False comfort — a `.proto` does not replace product thinking about failure modes
ARB line: gRPC for internal contracts. Do not force it to the client because it feels advanced.
GraphQL — when to choose it
Choose GraphQL when many clients need different shapes of the same domain, and REST over/under-fetching is slowing product teams — and you are willing to govern a schema.
Where it fits
BFF / app / admin / CRM consoles: screens that would otherwise need five REST calls or one monstrous DTO.
Where I push back hard
Hot game loops — use WebSocket state sync, not GraphQL as the table protocol
Simple commands — REST is clearer
Blind “GraphQL everywhere” — schema sprawl is an enterprise debt
Why ARB likes GraphQL
Clients ask for exactly the fields they need
One endpoint can serve many UI variants
Strong story for mobile and parallel frontends
Drawbacks / backdrops
N+1 and resolver cost — easy to create accidental database storms
Caching is harder than REST resource URLs
Authz must be field-aware — coarse gateway auth is not enough
Schema governance — without owners, GraphQL becomes a junk drawer
Subscriptions ≠ free real-time architecture — still need backplane thinking
ARB line: Choose GraphQL for read-shape flexibility. Do not choose it to avoid designing APIs.
Side-by-side: pattern vs backdrop
Pattern
Choose when
Main drawbacks
REST
Short request/response; broad clients; gateways
Chatty UIs; over/under-fetch; weak as live game pipe
WebSocket
Long-lived bidirectional session; server push
Sticky/reconnect complexity; harder to scale; easy to misuse for CRUD
gRPC
Internal service RPC; strict contracts; efficiency
Browser friction; ops maturity required; overkill for public CRUD
GraphQL
Many clients, many read shapes
N+1; cache/authz hardness; schema sprawl
How we composed them (what “good” looked like)
Conversation
Pattern
Matchmaking / “where do I sit?”
REST
Live gameplay / presence
WebSocket
CRM / ML segment query
REST (+ JSON body)
Service-to-service, high QPS, strict
gRPC (internal)
Diverse admin / app reads
GraphQL (when UI diversity demands it)
Durable async side effects (e.g. money path)
Message broker — different layer, not a fifth “API style”
What I say in the ARB when the suggestions fly
When someone says “we’ll do gRPC / GraphQL / REST,” I translate:
REST — “We need a door everyone can knock on.”
WebSocket — “We need a room that stays open and pushes.”
gRPC — “Two services we own need a tight, fast contract.”
GraphQL — “Many UIs need many shapes of one graph — and we will govern the schema.”
If the sentence is only “it’s modern,” that is not an architecture decision.
Wrapping-Up
In the ARB, mid-level energy is valuable. Protocol fashion is not.
Sometimes we miss the funda: REST, gRPC, GraphQL, and WebSocket solve different communication problems, and each brings drawbacks you must budget for — chattiness, connection ops, browser proxies, schema and resolver risk.
When a new requirement opens the board, don’t start with the acronym. Start with the conversation type. Then choose the pattern. Then name the backdrops out loud so nobody is surprised in production.
Enter with REST when you need a door.
Stay with WebSocket when you need a room.
Speak gRPC when services need a tight contract.
Offer GraphQL when many UIs need many shapes — and you will own the schema.
Use each where it fits — utilize the features, don’t crown a winner
In 2016, we redesigned our game engines.
We were moving from a classic monolith to a modular monolith — lobby, game, transactions, and related concerns split by responsibility so each piece could scale and fail on its own. Once those modules stopped living in one process, they needed a way to talk without blocking each other on synchronous calls.
That seam was a message broker. We chose RabbitMQ.
This article is not RabbitMQ vs Kafka. It is RabbitMQ and Kafka — how we used each where it fit, utilized the features each one is actually good at, and stopped treating messaging as a single-tool religion.
The problem the redesign created
In the monolith, “send this to transactions” was often just a function call in the same process. After the split, a buy-in or a payout could not sit on the critical path of live gameplay. The game server had to publish and keep moving. The transaction server had to consume and act when it was ready.
We needed:
1. Point-to-point communication — some messages had a clear owner. One producer, one consumer path. Work had to land with the right module, not spray across the system by accident.
2. Broadcast when every consumer must act — some messages were not “pick one worker.” Every interested consumer had to see the message and do its own work.
3. Work distribution under load — for other flows, we needed many consumers sharing the load in round-robin style so no single worker became the bottleneck.
4. Survival under failure — if a consumer crashed mid-flight, the message could not vanish. It had to stay at the broker until something healthy consumed it.
5. High availability at the broker layer — if one broker node went down, the platform still had to serve. Gaming traffic does not wait for a maintenance window.
6. Serious throughput — we were designing for the order of 1 lakh (100,000) concurrent messages being consumed. This was not a toy queue for nightly jobs.
RabbitMQ mapped cleanly onto all of that.
Why RabbitMQ fit that architecture
Point-to-point where ownership was clear
For flows like game server → transaction server, we needed point-to-point messaging. Publish a command or event meant for one consumer path. Decouple the producer from the consumer’s speed. Keep gameplay off the money path.
RabbitMQ made that natural: publish to an exchange, bind a queue to the module that owns the work, consume with acknowledgements. The producer does not care whether the consumer is momentarily slow. The broker buffers.
Topic when every consumer must act
Not every message was “give this job to one place.”
Some messages needed every interested consumer to consume and act. For those, we used topic exchanges. Each consumer (or each consumer type) bound its own queue with the routing pattern it cared about. One publish, many independent reactions.
That gave us fan-out of intent without hard-coding a list of callers inside the producer. New consumers could subscribe by binding — the publisher stayed dumb about who was listening.
Fanout for queue round-robin under load
Where the job was pure work distribution — many workers, same kind of work, share the load — we used fanout into queues that consumers competed on in round-robin fashion.
One message → one worker. Add more consumers, get more parallelism. That pattern is RabbitMQ at its most boring and most useful: a durable work queue in front of a pool of processors.
Between topic (everyone who cares acts) and fanout / competing consumers (one worker acts), we covered both “notify the system” and “do the job once.”
Durable queues: messages stay until consumed
This was non-negotiable in a gaming stack that touched money and live state.
We used durable queues so that:
Queues survived broker restarts.
Messages were not treated as ephemeral fire-and-forget.
If a consumer broke, the message stayed at the broker until a healthy consumer took it and acknowledged it.
That durability model matched how we thought about reliability: the broker is the safety net between modules. A crash in one modular service must not erase in-flight work for another.
In practice that meant designing for at-least-once delivery. Consumers had to be idempotent. Duplicates can happen when a consumer dies after doing the work but before the ack. Durable queues protect you from loss. They do not invent exactly-once magic. We accepted that trade-off consciously.
Dead letter queues for transactional outages
Durable queues keep messages alive. Dead letter queues (DLQs) gave us a place to put work that could not complete cleanly.
When a transaction path hit an outage — consumer errors, rejected messages, retries exhausted, or a downstream money path temporarily unhealthy — those messages did not disappear into a black hole. They landed on a dead letter queue, where we could inspect them, retry them, or run a controlled recovery once the outage cleared.
For gaming, that mattered as much as the happy path. Buy-ins and payouts cannot be “lost because the consumer threw.” DLQs turned transactional failure into something operable: quarantine, diagnose, execute again when the system was ready.
So the reliability story had two layers:
Durable queues — message stays until a healthy consumer acknowledges it.
Dead letter queues — failed or unprocessable transactional work stays available for recovery after outages.
Absolute match: act in milliseconds, not replay history
For our game engines, RabbitMQ was not “good enough.” It was an absolute match for what a broker had to do.
Gameplay and money-adjacent flows had to act within milliseconds. The job of the broker was to get the right message to the right consumer fast — point-to-point, topic, or round-robin — and keep that path reliable under failure. We did not need to save a long history of every message so someone could replay last week. Once the work was done (or parked on a DLQ for recovery), the broker had finished its job.
That distinction is easy to miss when you evaluate messaging tools in the abstract. Game engines live in the present tense. Publish, route, consume, act. RabbitMQ is built around that shape of work, and in production it worked fine for us at the load and availability bar we needed.
High availability: keep serving when a node dies
Durability at the queue level was only half the story. The other half was broker high availability.
We ran RabbitMQ as a distributed cluster. The requirement was simple and absolute: if one node went down, the system should keep serving. Producers and consumers had to continue — buy-ins, payouts, and cross-module work could not freeze because a single broker machine failed.
That is where RabbitMQ’s distributed mechanisms mattered for us:
Queues and messages were set up so work survived node loss, not only process crashes on the consumer side.
Clients could reconnect and resume against healthy nodes.
The cluster absorbed a node failure without turning the modular game engines back into a tightly coupled outage.
Distributed brokers also force you to think about network partitions and split-brain — the ugly cases where nodes cannot agree on who is in charge. For our gaming requirements, we operated the cluster so those distributed behaviors stayed within what the platform could tolerate: failover worked, serving continued, and we did not see the broker layer become the weak link when a node disappeared.
In a live gaming environment, “high availability” is not a slide. It is whether tables keep moving and money paths keep accepting work at peak. RabbitMQ’s HA model met that bar for us.
Scale: 1 lakh concurrent messages consumed
People sometimes dismiss RabbitMQ as “fine for small systems.” That was not our experience.
Configured and operated carefully — durable queues, dead letter queues, HA across nodes, sensible prefetch, enough consumers, monitoring on queue depth and consumer lag — RabbitMQ handled the load we needed: on the order of 100,000 concurrent messages being consumed across the paths that mattered for the modular game engines.
We did not pick it because a blog said it was trendy. We picked it because our patterns were route and act in milliseconds, with availability under node failure and **DLQs for transactional outages — and the broker could take the concurrency we were aiming at.
Then the business grew: analytics and BI
As the business grew, analytics and BI arrived with a new class of requirements.
They needed game events and transactional events stored in the BI system — routinely — so the business could analyze what players did and how money moved. Data lakes and warehouses entered the conversation. Snowflake and similar systems became the place where transformed events were supposed to live for reporting and analysis.
Our first instinct was natural, and a little dangerous: we already had RabbitMQ in the middle of the game engines. So we suggested RabbitMQ again.
The plan was simple on paper:
1. BI / analytics consumers bind and consume from the relevant exchanges.
2. They transform the messages.
3. They store the results into the BI system / data lake (for example Snowflake).
For some days, that also worked fine. Events flowed. Transforms ran. Rows landed in the warehouse. It looked like we had extended the same broker to a new audience without inventing new infrastructure.
That comfort did not last.
The day risk analysis changed the question
One fine day, the risk analysis team came with a harder ask.
They did not only want a live feed into BI. They wanted to transform events in ways the BI team could modify — evolving logic, re-shaping pipelines, changing how raw game and transactional events became analytical facts. And they needed the underlying messages available for reconciliation the next week.
That sentence is where the architecture cracked.
RabbitMQ had been perfect when the job was: deliver this work now, act in milliseconds, then you are done.It is a weak fit when the job becomes: keep the events around, let multiple teams re-transform them, and let risk come back next week to reconcile against what actually happened.
We had stretched a work broker into an event history problem.
Once a message is consumed from a classic queue path, it is not sitting there as a week-long source of truth for reconciliation. DLQs help with failed processing. They are not a BI-grade archive of every game and transactional event for next week’s risk checks. Asking every new team to “just consume from the exchange” also couples analytical evolution to the same live routing fabric the game engines depend on — and it does not give risk a clean way to re-read last week’s facts after BI changes a transform.
That is when it became clear: for this case, we had chosen the wrong broker.
Not wrong for game engines. Wrong for analytics + BI + risk reconciliation over time.
The requirements had shifted from act now to retain, re-transform, and reconcile later. Same events. Different job. Different tool.
Introducing Kafka for BI and analytics
So we introduced Kafka for the BI / analytics path.
Not as a replacement for RabbitMQ on the game engines. As the right backbone for a different job: game events and transactional events that must be stored, analyzed as routine, re-transformed when BI logic changes, and still available when risk comes back next week for reconciliation.
Kafka for BI and analytics: game and transactional events plus Cassandra CDC feed a commit-log cluster; transform consumers reshape events in transit; BI, risk reconciliation, and other analytics read independently into the data lake.
What Kafka gave us that RabbitMQ was not designed to be for this case:
A commit log, not a disposable queue
Kafka is built as a distributed commit log. Events are appended to topics and retained by policy. Consumers track an offset — a position in history — instead of “take this message and it is gone from the shared truth.”
That is the mental model analytics needed. The stream is a durable record of what happened. Risk can return to last week’s events. BI can change a transform and re-read. The log stays.
History you can replay
For reconciliation and evolving analytics, history is the feature.
With Kafka, game and transactional events remain available for the retention window we set. A consumer that was wrong on Monday can be fixed on Thursday and replay. A risk job that needs last week’s facts does not depend on someone having saved a side copy “just in case.” The platform keeps the events.
That is exactly what broke when we tried to stretch RabbitMQ: once the live consume path had moved on, next week’s reconciliation had no first-class history to stand on.
Transformation in transit
BI and risk did not only need raw events parked forever. They needed message transformation in transit — shape the event on the way into lakes and warehouses (Snowflake and similar), and keep the ability to change that shaping as analytical definitions evolved.
Kafka fits that pipeline shape:
1. Producers (or bridges from the game / transaction world) publish canonical events to topics.
2. Stream / transform consumers read the log, apply BI-owned logic, and write curated results downstream.
3. When the transform changes, teams can redeploy the transform and, where needed, replay from history instead of begging for a one-off re-extract.
4. Risk can consume the same underlying topics independently for reconciliation — without stealing messages from the BI consumer or coupling to the game-engine RabbitMQ fabric.
The important architectural point: **transformation becomes a consumer concern on top of a retained log**, not a one-shot side effect of emptying a work queue.
CDC from Cassandra into Kafka
Kafka also fit another reality of our stack: we already had important state in Cassandra.
For analytics and BI, it was not enough to stream only the messages the game engines happened to publish. We needed changes from Cassandra tables themselves — inserts and updates that represented durable game and transactional facts available as an event feed.
So we integrated Cassandra → Kafka as CDC (change data capture). Table changes were captured and published into Kafka topics. From there, the same commit-log strengths applied: retain history, transform in transit, land curated data in the BI lake, and let risk reconcile later by re-reading the stream.
That pattern worked well. CDC turns the database into a producer of facts without forcing every service to remember to emit a perfect analytics event on every write. Kafka is a natural sink for that feed because it is built to hold an ordered, replayable log of changes — not to empty a work queue and forget.
Use each where it fits
After Kafka landed for BI and analytics, we were not declaring a winner. We were **using each technology where it fit** and utilizing the features that matched the job:
We did not “upgrade from RabbitMQ to Kafka.” We utilized RabbitMQ for work routing and Kafka for the commit-log / history path. Both stayed. Both earned their place.
Where it fit
What we utilized
Game engines: route work, act in milliseconds
RabbitMQ — exchanges, durable queues, DLQs, HA
BI / analytics / risk: history, transform, reconcile later
Kafka — commit log, replay, transform in transit
Cassandra table changes into the analytical stream
Kafka CDC
What I’d tell a team building the same platform
Do not start with “which technology is better.” Start with the job, then utilize the features that fit:
1. Point-to-point? You need clear ownership of work — RabbitMQ exchanges and queues fit well.
2. Every consumer must act? Topic-style fan-out to many queues.
3. Round-robin workers? Competing consumers on shared work queues.
4. Consumer can die? Durable queues and ack-based consumption so messages stay until processed.
5. Transaction path can fail?Dead letter queues so outages become recoverable work, not silent loss.
6. Broker node can die? A real HA cluster — including partition / split-brain behavior, not only the happy path.
7. Act in milliseconds, no long history? A classic broker like RabbitMQ is often the cleaner fit — that was our game-engine case.
8. Analytics, BI, or risk need events next week — to re-transform or reconcile? Utilize a commit log with history — for us, that was Kafka, including CDC from Cassandra.
We asked the first set of questions in 2016. RabbitMQ fit. We utilized it.
We asked the later questions when the business grew. Kafka fit. We utilized it.
That is the whole lesson.
Wrapping up
Do not compare technologies to pick a champion. Use them wherever they fit. Utilize their features.
RabbitMQ fit our modular game engines: point-to-point, topic broadcast, fanout with round-robin, durable queues, dead letter queues, HA, and roughly 1 lakh concurrent messages consumed— act within milliseconds.
Kafka fit BI, analytics, and risk: a commit log, history for next-week reconciliation, transformation in transit into lakes like Snowflake, and CDC from Cassandra tables.
That is why this article is named RabbitMQ and Kafka — not RabbitMQ vs Kafka.
Same company. Same events. Different jobs. Both tools, used on purpose.
A live game table explains the difference — and why “just add more threads” is not the answer
Sometimes we can go back to basics.
In interviews, design reviews, and late-night debugging, the same words get mixed up: concurrency, parallelism, multithreading. Someone says we need concurrency and the next sentence is “so we’ll make it multithreaded.” Someone else hears “concurrent users” and assumes the server must be full of threads. Another adds “virtual threads” because the JDK version made the slides.
Those are not the same idea.
Concurrency is about how you structure work that overlaps in time. Multithreading is one implementation tool. You can be highly concurrent with a single thread. You can run dozens of threads and still get races, starvation, and no real throughput win.
In this article I separate the terms with a live game table example from high-scale gaming platforms we built — then bridge to the day thread starvation hit us at 100K requests/second on the Dynamic Real-Time Query Engine and why virtual threads fixed a concurrency cost problem without magically fixing every other one.
Core concepts. Clear vocabulary. A game you can picture.
Three words people treat as one.
Term
Plain meaning
Not the same as
Concurrency
Many tasks in progress — interleaved or overlapping in time
The same as multithreading
Parallelism
Work truly running at the same time — e.g. multiple tables in the system
Always being “concurrent by design”
Multithreading
Multiple threads inside one process as a way to run work
A guarantee of correctness or speed
One line to keep: Concurrency is the problem shape. Parallelism is a performance mode. Multithreading is a mechanism. Don’t say one when you mean another.
A useful mental model:
Concurrency = one kitchen has many orders open at once (timers, players, wallets, heartbeats on a single table).
Parallelism = many kitchens running at once — multiple tables in the system progressing at the same time.
Multithreading = hiring more cooks for one kitchen — which only helps if they don’t fight over the same knife (shared pot / seats).
Picture a live game table (or room): several players seated, a round in progress, money on the line, clients connected over a persistent channel.
One live game table = concurrency — overlapping player inputs, timers, broadcasts, side effects, and heartbeats around shared table state; Design A thread-per-player vs Design B single-owner
At any moment the server is dealing with overlapping work:
1. Player inputs — fold, call, raise, buy-in, emoji, reconnect
2. Timers — turn clock, sit-out, reconnect grace
3. Broadcasts — seat state, pot, winners to everyone at the table
4. Side effects — wallet / transaction messages that must not block the game loop forever
5. Heartbeats / presence — who is still here
That list is concurrency. The table must make progress on many concerns that are “in flight” together. Whether you use one thread or twenty is a separate design choice.
What goes wrong if you confuse the words
Confused sentence
What actually happens
“We need concurrency → add threads per player”
Shared table state gets races; seats desync; money bugs
A well-designed game loop is concurrent work, serialized safely
“Virtual threads will fix our race conditions”
They won’t — they change cost of blocking, not shared-memory safety
So: use threads where the work is embarrassingly parallel or I/O-bound and isolated. Don’t recruit them as a substitute for a clear concurrency model on shared state.
When “more classic threads” stopped scaling.
On the Dynamic Real-Time Query Engine, a behavioral query often touched multiple models. We mapped the work and ran a path per table with `CompletableFuture` so Cassandra reads overlapped — pay roughly the slowest read, not the sum.
That is concurrency (many reads in flight) implemented with multithreading / async tasks.
It worked until traffic climbed toward ~100K requests/second. Latency rose — not because Cassandra was dead, but because of thread starvation. Classic platform threads are scarce. Each concurrent table read wanted an OS thread from a bounded pool. The pool became the bottleneck.
Java 21 virtual threads changed the cost model of that concurrency: many blocking reads could be in flight without tying up a scarce platform thread each. Starvation eased. Memory and GC pressure rose — we tuned with ZGC. Different problem, still real.
What that night taught us:
1. We already had concurrency.
2. Multithreading (classic) was the implementation that hit a wall.
3. Virtual threads were a better implementation of the same concurrent fan-out — not a synonym for “we finally added concurrency.”
4. Measuring told us the bottleneck was the threading model, not “buy more database.”
Cheat sheet
If you hear…
Ask…
Often choose…
“We need concurrency”
What overlaps? What must stay ordered?
Event loop / actor / queues or threads — by domain
“Make it multithreaded”
What shared state? Who owns writes?
Isolate state; parallelize only independent work
“Bigger thread pool”
Are we CPU-bound, I/O-bound, or lock-bound?
Fix contention / ownership first; then size pools
“Virtual threads”
Are we blocked on I/O with huge fan-out?
Yes → strong candidate; races → still your problem
“Single-threaded is slow”
Slow where — one core saturated, or waiting on I/O?
Measure; don’t assume
Anti-patterns
Thread per player mutating the same pot without a clear ownership model
synchronized everywhere as architecture
Equating concurrent users (product metric) with multithreading (implementation)
Assuming more threads ⇒ lower latency under shared locks
Treating virtual threads as a free pass on backpressure and memory
Wrapping-Up
Back to basics:
Concurrency means many things are in progress — for a live table: inputs, timers, broadcasts, money side-effects.
Parallelism means work truly runs at the same time — for example, running multiple tables in the system across cores or workers.
Multithreading is one way to chase either — powerful when work is independent, dangerous when everyone writes the same seat map.
Design the ownership of state first. Then pick the mechanism: single-threaded game loop, thread pool, actors, virtual threads, or a broker handoff. Name the drawbacks — races, starvation, GC, stalled table loops — so nobody is surprised in production.
Don’t start with “we’ll multithread it.”
Start with what must overlap, what must stay ordered, and who owns the write.
Every gaming company will tell you the same thing – the difference between a player who stays and a player who leaves is often decided in a matter of seconds. In that window, the right message, the right offer, or the right nudge can change the entire trajectory of a relationship. Miss it, and the moment is gone.
For us, that window wasn’t seconds. It was milliseconds.
In 2025, I led the architecture for a system we came to call the Dynamic Real-Time Query Real – Time Query Engine — a platform that lets our CRM and marketing teams understand a player’s behavior the instant an event happens and act on it while the player is still on screen. No nightly batch jobs. No “we’ll reach them tomorrow.” Just live behavior, computed and acted upon in real time.
It started, as many good systems do, with a problem nobody could solve. Our CRM team had spent six months trying to make existing tools do something they were never designed for. When they hit a wall, the problem landed on the desk of the Architecture Board and that’s where my part of the story begins.
This article is about how we got from “this is impossible with what we have” to a production system answering behavioral queries in milliseconds. I’ll walk through the problem, the architecture we designed, the trade-offs we wrestled with, and the lessons I took away from leading the effort — both the technical ones and the human ones this — the problem ones and the human ones.
If you build real-time systems, work in data infrastructure, or care about CRM and marketing technology, I hope you’ll find something useful here.
The Decision: Build a Platform, Not a Patch
When my solution architects, the VP of Engineering, the CTO, and I sat down to brainstorm, the easy path would have been to bolt yet another point solution onto the CRM stack. We chose not to.
The key realization in that room was this — the problem the CRM team brought us wasn’t really a CRM problem — it was a data problem. The CRM tools couldn’t act in milliseconds because nothing in our stack could answer questions about a player’s live behavior fast enough. Solve that, and we wouldn’t just unblock CRM – we’d unlock a whole class of real-time use cases.
So we decided to build the Dynamic Real-Time Query Engine as a foundational building block – a piece of platform infrastructure, not a feature. The same engine that resolved the CRM problem could serve several consumers at once:
The CRM / targeting engine- segment players in real time and trigger on-screen treatments within milliseconds of an event.
The ML Service- act as a real-time data provider, feeding live behavioral features into in-built ML models that predict what a player is likely to do next.
Future consumers - any team that needs to ask fast questions about live player behavior, without building their own pipeline.
This reframing changed everything. Instead of designing a narrow tool for one team, we were designing a shared real-time data and query layer that the whole business could build on. It raised the stakes and the scope – but it was the right call.
The Tech Stack (and Why We Chose It)
Architecture is ultimately a series of trade-offs, and the technology choices are where those trade-offs become concrete. Here’s what we picked and the reasoning behind each decision.
Java 21 - the core language
We built the engine on Java, running on Java 21. For a system that has to process a high volume of events concurrently while keeping latency low, Java was a natural fit:
– It’s a mature, battle-tested language for large-scale backend systems, with a rich ecosystem and tooling.
– Its multithreading and concurrency support is first-class - exactly what we needed to fan out work and squeeze every millisecond out of the hardware.
Spring Boot - the application framework
On top of Java, we used Spring Boot to build the service layer. It gave us:
A fast path to production-grade REST APIs, so consumers (CRM, the ML Service, the targeting engine) could integrate over a clean, well-understood interface.
Built-in support for the operational concerns that matter in production - configuration, dependency injection, metrics, health checks without reinventing the wheel.
Cassandra - the storage engine
For storage we chose Apache Cassandra, a distributed NoSQL database. Given our requirements, this was one of the most important decisions we made:
Horizontal scalability. Cassandra scales out by simply adding nodes, with no single point of failure – essential for a system expected to grow with player volume.
Petabyte scale capacity. It’s designed to store and serve enormous datasets, so we wouldn’t hit a ceiling as event volume exploded.
Write and read-friendly at scale. Its architecture suits a high-ingest, high-query workload like ours, where events stream in constantly and consumers query live behavior just as constantly.
A traditional relational database would have struggled with this combination of write throughput, data volume, and the need for predictable performance under load. NoSQL – specifically Cassandra was the right tool for the job.
RabbitMQ - the message broker
To move events from producers into the engine, we used RabbitMQ as our message broker, following a point-to-point communication model:
A producer emits an event (a player action) onto a queue.
The query engine consumes that event, processes it, and persists the result into Cassandra.
This decoupling was important. The producer doesn’t need to know anything about how the engine works, how busy it is, or whether it’s momentarily slow – it just publishes. RabbitMQ buffers the events and hands them to the engine to consume at its own pace, which keeps the pipeline resilient under bursty load and gives us a clean seam between event production and event processing.
The Data Model
If the architecture is the skeleton, the data model is the heart of the engine. In Cassandra, your data model is your performance -you model around the queries you need to answer, not around some abstract notion of “clean” relational design. This is where we spent a disproportionate amount of our thinking, and it paid off.
One table per event type, modeled as a time series
We made two deliberate decisions:
1. One Cassandra table per event type. Each kind of player event gets its own table, rather than cramming every event into a single generic table. This keeps each table’s schema tight, its partitions predictable, and its queries fast.
2. Model every event table as a time series. Player behavior is inherently a sequence of events over time, so we leaned into Cassandra’s strength: time-series data keyed by entity.
This time-series design plays a crucial role in how the engine performs. It lets us answer the question that matters most - what has this player been doing recently?” - by reading a single, contiguous slice of one partition.
The key design
For each event table, the primary key is structured as:
Partition key: `player_id` - all of a player’s events of a given type live together on the same node, so reading one player’s recent activity is a single-partition lookup (the fastest thing Cassandra can do).
Clustering key: event timestamp- events are physically ordered by time within the partition, so “the last N events” or “events in the last X milliseconds/minutes/hours/days” is a cheap, sorted range scan.
Remaining columns: the event’s attributes- whatever payload that event type carries.
A representative table looks like this:
CREATE TABLE game_result_events (
player_id text,
event_time timestamp,
game_id text,
rake double,
winning_amt double,
is_winner text,
PRIMARY KEY ((player_id), event_time)
) WITH CLUSTERING ORDER BY (event_time DESC);
A deliberate constraint: a small, fixed set of data types
We made one more rule that surprised people: across the entire engine, we restricted attribute data types to a small, fixed set`double`, `text`, and `timestamp`.No nested collections, no exotic types - just the primitives we actually needed (whole numbers, monetary/decimal values, strings, and time).
This was a conscious trade-off in favor of speed, simplicity, and predictability:
A simpler, uniform schema is far easier to validate, serialize, and query consistently across hundreds of event tables.
Predictable storage and parsing- a handful of primitive types means no surprises in how data is stored, indexed, or deserialized on the hot path.
Fewer foot-guns- restricting types kept producers honest and prevented a long tail of edge cases that would have slowed the engine and complicated the code.
Constraints like this are easy to undervalue, but in a system optimizing for millisecond latency, every bit of uniformity you can buy is latency you don’t have to fight for later.
The Query Language: A JSON DSL for Behavior
Here’s where the “query engine” really earns its name. Rather than forcing the CRM and marketing teams to write SQL – or worse, to file engineering tickets every time they wanted a new segment we built our own JSON-based query language.
The goal was simple: let non-engineers (and other services) express rich behavioral questions as data, not code. A query is just JSON, so it can be created in a UI, stored, versioned, sent over an API, and evaluated by the engine in real time.
What the language supports
The DSL is small but expressive. It covers the operations that behavioral targeting actually needs:
Aggregate functions:`sum`, `count`, `avg` (average), `max`, `min`, `uniqueCount`, and an in-a-row (consecutive streak) aggregate.
Logical / arithmetic operators: `and`, `or`, `nor`, and a `compare` operator for relating one computed value to another.
A query is built from two parts:
1. queryModels - one or more named sub-queries (`q1`, `q2`, …). Each model computes a single aggregate over a single event table, optionally filtered by a time window and `WHERE`- style conditions.
2. expression- an optional layer that combines the results of those models with conditions and logical operators, turning several aggregates into one true/false segment decision.
In plain English, `q1` asks: “Over the last 600 days, for this player’s `player_games` events where `game_type` and `server_type` are ‘Stake’ (case-insensitive) and `bet >= 10`, is the sum of rake` greater than 0”
Notice how much is packed into one declarative block: the table, the aggregate, the time window (which maps directly onto our time-series clustering key), and the row filters. The engine translates this into an efficient, single-partition, time-bounded scan over Cassandra.
Composing models into a segment
The real power shows up when you combine multiple models with an `expression`. Here’s a segment built from five sub-queries, all over a 7-day window:
Each model produces a number; the `expression` block then thresholds and combines them. Read together, this query targets a specific kind of high-value player in the last 7 days:
Here the `value` is an array, and `compare` lets the engine evaluate one quantity against another – comparing `q1` against a `wallet_balance` (and `500`), and then `q2` against the result of `q1`. This is what makes the language dynamic: expressions can reference other columns and even other query results, not just hard-coded numbers.
Why build our own language?
It’s a fair question - why not just expose SQL? A few reasons drove the decision:
Safety and control. A constrained DSL can’t issue an unbounded or accidentally catastrophic query. Every model maps to a bounded, single-partition, time-windowed read by construction.
It speaks the domain. “Sum of rake over 600 days where game_type is Stake” is closer to how the CRM team thinks than raw SQL joins.
It’s portable data. Because a query is just JSON, it can be authored in a UI, stored, shared, A/B tested, and replayed by humans or by the ML Service.
It maps cleanly onto our data model. The `searchTime` window lines up with the time-series clustering key, and `tableName` with our one-table-per-event-type design, so every query has an efficient execution path.
Optimization: Parallelism, Thread Starvation, and Virtual Threads
A query that touches one table is easy. But our real queries like the five – model segment earlier fan out across multiple tables, columns, and date ranges in a single request. Doing that one model at a time would have been far too slow for millisecond targeting. This is where the optimization work began.
Step 1: Understand the query before you run it
Before executing anything, the engine scans the incoming query first to segregate exactly what work needs to be done - the distinct tables, columns, and date ranges each model requires. Knowing the full shape of the work up front let us plan the reads intelligently instead of discovering them as we went.
Step 2: Parallelize across tables
With the work mapped out, we ran a separate thread for each table, using Java’s `CompletableFuture` to execute the model reads concurrently and then join all the results back together. Instead of paying the latency of each Cassandra read in series, we paid roughly the cost of the slowest one. Leveraging parallelism this way was the single biggest lever for keeping multi-model queries fast.
Step 3: Hitting the wall - thread starvation at 100K req/s
This worked beautifully… until it didn’t. As traffic climbed to around 100K requests per second, we started seeing query execution slow down - not because Cassandra was struggling, but because of thread starvation.
The problem was structural. With classic platform threads, every concurrent table read consumed an OS thread from a bounded pool. At 100K req/s, each spawning multiple `CompletableFuture` tasks, the pool simply couldn’t keep up - requests queued waiting for a thread to free up, and latency spiked. We were starving for threads, not for CPU or database capacity.
Step 4: Java 21 virtual threads
This is where the move to Java 21 virtual threads paid off. Virtual threads are lightweight, JVM-managed threads that aren’t pinned 1:1 to OS threads, so you can have a very large number of them in flight at once. The blocking Cassandra reads that previously tied up scarce platform threads now ran on cheap virtual threads instead.
The effect was dramatic: queries that had been backing up under thread starvation executed seamlessly, because thread availability was no longer the bottleneck. We got the simple, readable blocking-style concurrency model and the scalability to handle the load.
Step 5: Taming the memory spike with ZGC
Virtual threads solved the thread starvation, but they introduced a new symptom: with so many threads in flight at once, each holding its own stack and short-lived objects, we saw memory usage spike under heavy load. More concurrency meant more allocation pressure, and that put more work on the garbage collector.
The fix was to switch to ZGC (the Z Garbage Collector). ZGC is a low-latency, concurrent collector designed to handle very large heaps while keeping pause times in the sub-millisecond range – it does most of its work concurrently with the application instead of stopping the world. Even when virtual threads pushed memory up, ZGC reclaimed it very fast and without the long GC pauses that would have eaten into our millisecond latency budget. Virtual threads gave us the concurrency; ZGC kept that concurrency from turning into latency.
Step 6: Scale out, not just up
Finally, the engine runs as a containerized service that autoscales. When request volume surges, more instances spin up to share the load; when it subsides, they scale back down. Combined with virtual threads handling concurrency within each instance, this gave us headroom both vertically (per instance) and horizontally (across instances).
Tuning Cassandra for Real-Time Reads
Parallelism and virtual threads got the application tier out of the way - but the engine is only as fast as the database underneath it. Getting Cassandra to serve recent player behavior with predictable, low latency took deliberate tuning.
Caching the hot path: row cache and key cache
Our access pattern is heavily skewed toward recent data most queries ask about what a player did in the last few minutes, hours, or days. That makes caching enormously effective:
Key cache keeps partition-key locations in memory, so Cassandra can skip straight to the right data on disk instead of hunting for it.
Row cache keeps the actual hot rows in memory, so repeated reads of a player’s latest events are served without touching disk at all.
Together these dramatically speed up recent reads (and writes) exactly the rows our real-time queries hit most often. For a workload like ours, where the “last N events for this player” is asked over and over, the cache hit rate is high and the latency win is real.
Compaction: time-window strategy + TTLs
The second big lever was compaction strategy. Because every event table is a time series and we attach a TTL to events (they age out automatically once they’re no longer relevant), we chose a time-based / time-window compaction strategy rather than the default size-tiered approach.
This pairing is a natural fit:
Events written in the same time window are compacted together into the same SSTables.
When those events expire via TTL, whole SSTables can be dropped at once, instead of expired data lingering and being repeatedly rewritten.
That means far less wasted compaction work - and, crucially, less garbage-collection pressure on the JVM, which keeps latency steady and avoids GC pauses creeping into our millisecond budget.
Results
When the dust settled, the Dynamic Real-Time Query Engine delivered on the promise that started it all:
Millisecond query latency. Behavioral questions that used to be impossible in real time are now answered in milliseconds, fast enough to act on a player while they’re still on screen.
Scale we can grow into. After the move to Java 21 virtual threads, the engine sustains around 100K requests per second, with containerized autoscaling absorbing traffic spikes and Cassandra giving us horizontal, petabyte-scale headroom for event volume.
One engine, many consumers. What began as a CRM problem became shared infrastructure – powering the CRM and targeting engine, and serving as a real-time data provider for the ML Service that predicts player behavior.
Self-serve for the business. The CRM team now defines brand-new behavioral segments in JSON and puts them live in minutes, with no engineer in the loop.
What I learned leading the architecture
The technology was the fun part, but leading the effort taught me just as much.
Reframe the problem before you solve it. The CRM team handed us a CRM problem. The moment we recognized it was really a data problem, the solution stopped being a patch and became a platform. The most valuable work happened in that reframing – in the room with the CTO and VP – before any code was written.
Constraints are a feature. Restricting the type system, building a bounded JSON DSL instead of exposing raw SQL, modeling one table per event type – each of these removed options on purpose. Those constraints are exactly what kept the engine fast, safe, and predictable under load.
Make the data model the first decision, not the last. In a real-time system, the partition key, clustering key, compaction strategy, and TTL aren’t tuning details you bolt on at the end – they’re the foundation the millisecond latency rests on.
Your bottleneck is rarely where you think. At our peak load we assumed the database was the limit. It wasn’t – it was our threading model. Measuring carefully, rather than guessing, is what pointed us at virtual threads instead of throwing more Cassandra nodes at the problem.
Raise the level of abstraction for the people you serve. The biggest win wasn’t any single optimization – it was handing the business a language to ask its own questions. Great infrastructure makes other teams faster without making them wait on you.
Conclusion
We set out to solve a problem nobody else could crack: understanding a player’s behavior the instant it happened and acting on it within milliseconds. What we built was bigger than the original ask – a Dynamic Real-Time Query Engine that turns live player events into answers fast enough to change outcomes in the moment.
The pieces all reinforce one another. A time-series data model in Cassandra, keyed by player and ordered by time. A small, safe JSON query language that lets the business express rich behavioral questions as data. Parallel, virtual-thread-powered execution that scales to roughly 100K requests per second. Caching and time-window compaction that keep the hot path in memory and let old data evaporate cleanly. Every decision points in the same direction – toward predictable, low-latency answers at scale.
But the real lesson is one that outlasts any particular stack: the best architecture doesn’t just solve the problem in front of you – it reframes that problem into a capability the whole business can build on. A CRM team’s six-month headache became a foundational platform that now powers targeting, machine learning, and whatever comes next.
If you’re tackling something similar – real-time systems, behavioral data, or just the messy art of leading an architecture from a whiteboard to production – I’d love to compare notes.
Wrapping Up
To recap the journey in one place:
The problem: a gaming CRM team needed to compute player behavior from a live event and act on the player, on screen, within milliseconds – something existing CRM tools couldn’t do.
The decision: instead of patching CRM, we built the Dynamic Real-Time Query Engine as a shared platform building block that also feeds the ML Service and the targeting engine.
The stack: Java 21 and Spring Boot for the engine, RabbitMQ for point-to-point event delivery, and Cassandra as a horizontally scalable, petabyte-scale NoSQL store.
The data model: one table per event type, modeled as a time series with player_id as the partition key and event_time as the clustering key, using a small fixed set of data types.
The query language: a JSON DSL with aggregates (sum, count, avg, max, min, uniqueCount, in-a-row), conditions (gt, gte, lt, lte, eq, noteq, eqic), and operators (and, or, nor, compare) – composed from queryModels and an expression.
The performance: query-aware parallelism with CompletableFuture, Java 21 virtual threads to beat thread starvation at ~100K req/s, autoscaling containers, plus Cassandra row/key caching and time-window compaction with TTLs.
The outcome: millisecond behavioral queries at scale, and a self-serve platform the business builds new segments on in minutes.
If there’s one thing to take away: the best architecture doesn’t just solve today’s problem – it turns that problem into a capability the whole organization can build on.
Mama, you work on games, right? How would you design Call of Duty and PUBG–kind of games?”
(In our family I am Mama— uncle.)
He expected a short answer — maps, guns, graphics, maybe a cool explosion.
I almost said “it’s complicated.” Then I realized he had asked the right question for an architect not how the game looks, but how the system is arranged so players can find a match, stay in sync, and not melt the servers when a whole country logs in after school.
So I answered him the way I wish more design conversations started — with a map.
Not Erangel. Not Verdansk.
A map of regions, zones, and services.
This article is that answer. It is not a leak of PUBG’s or Activision’s private blueprints. Those backends are not fully public. It is how PUBG-like battle royale and Call of Duty–like multiplayer systems are typically shaped — the same vocabulary we use when we design high-scale live games: where the player lands, where the match runs, and which services do the work.
Draw the map before you name a tool.
Layer
Plain meaning
Design rule
Region
Geography for ping, friends, and often data rules (India, EU, US-East…)
Matchmaking pools and “who can play with whom” are usually region-scoped
Zone
Capacity and failure slice inside a region (AZ / cluster / pod group)
A zone can die without killing the whole region; matches drain carefully
Service
A job the software must do (matchmaking, dedicated game host, store, anti-cheat)
Put each job where latency and ownership demand — not where the slide looks busy
Mama’s rule: Players don’t talk to “PUBG” or “CoD.” They talk to a region. Matches run in a zone. Work is done by services.
If you cannot place a component on that map, you are not designing yet. You are collecting logos.
Naming trap — three different “zones”
Kids (and engineers) mix these up:
1. Cloud / infra zone — failure domain and capacity (this article’s “Zone”)
2. Map / safe zone — the shrinking circle in a battle royale
3. Interest / AOI zone — which other players and objects your client needs to know about right now
Same English word. Three layers. Keep them separate or the design review becomes comedy.
Walk one player from the sofa to the victory screen.
Here is the journey I walked with him — overlaid on the map.
1. Launch / login → hits platform services (identity, entitlement). Often multi-region capable, with a home region.
2. Lobby / party → region-aware presence: you queue with friends who share a sensible ping story.
3. Matchmaking → region-scoped queues (skill, playlist, fill rules). Output: “here is your match” + where to connect.
4. Connect to the match → client is steered to a dedicated game host (or relay path) sitting in a zone inside that region.
5. In match → authoritative simulation ticks; state replication; interest management; voice often match- or region-local.
6. Match end → results hand off to progression / inventory / battle pass (platform path — not on every bullet).
7. Side planes (always on) → anti-cheat signals, telemetry, crash reports — async, must not block the tick.
This is the same shape as systems I have lived with in live gaming
matchmaking as a door (ask and get placed), then a live channel / dedicated path as the room (stay connected for the session). Different genre. Same map logic.
What sits where — service placement.
Service
Typical place on the map
Why
Identity / account
Platform (multi-region or hub + cache)
Shared identity; not every shot
Store / battle pass / inventory
Platform
Money and catalog; after or beside the match
Friends / party / presence
Region-aware
Parties hate cross-ocean ping
Matchmaking
Region
Pool quality + latency
Dedicated game servers / match instances
Zone inside region
Blast radius; capacity; drain on failure
Real-time game gateway / relay
Edge / region
Low latency into the match
Voice
Match- or region-local
Latency and moderation scope
Anti-cheat (client + server signals)
Match-local collect → hub aggregate
Hot path light; intel can be global
Telemetry / analytics
Side plane → lake
Never block the tick
Content / config catalog
Hub + CDN
Versioned rules, maps, playlists
Hot path rule: bullets, movement, and tick authority stay near the player (region / zone). Store, heavy CRM, and batch analytics do not sit on the shot path — same lesson as handing wallet work off a live game loop.
Same map — different match shape (PUBG-like vs CoD-like)
I did not crown a winner. Both sit on Region → Zone → Services. The match they host looks different, so the fleet math changes.
Concern
PUBG-like battle royale
CoD-like multiplayer modes
Match shape
Large map, many players, longer session
Smaller maps, shorter matches, many playlists
Dedicated fleet
Fewer, heavier matches per host
Higher match churn; faster spin-up / teardown
In-match pressure
Wide map + vehicles + loot; strong AOI needed
Tighter spaces; higher tick / precision pressure
Region pressure
Launch and country spikes; fill 80–100 seats
Playlist and seasonal spikes; many small rooms
What stays identical
Region routing, zone isolation, platform vs game split, async side planes
Same
And, not vs: PUBG-kind and CoD-kind games are not rival architectures. They are two workloads on one map.
Region design — what my nephew already understood as “ping”
Ping budget decides who can share a match comfortably.
Party constraints beat “perfect MMR” if the squad cannot hear each other in time.
Empty lobby risk — a region that is too fine-grained never fills; too coarse and someone plays on 180 ms.
Data and policy — some player data and commerce prefer staying in-region.
Spill / overflow— when a region is on fire, you need a written rule (queue, wait, or rare cross-region with honesty about ping).
Region is a product decision as much as a cloud dropdown.
Zone design — why we don’t put every match in one basket
Inside a region, zones exist so that:
Hardware or AZ failure takes some capacity, not all evening matches
You can drain live matches (finish or migrate carefully) instead of hard-killing everyone
You scale dedicated fleets in slices — warm pools, playlist packs, BR vs small-modes packing
If matchmaking returns a host, that host’s zone is part of the placement decision — not an afterthought for Ops.
In-match: who owns the truth
For both genres, the dedicated (or authoritative) simulation owns match truth: positions, damage, win conditions. Clients predict for feel; server corrects for fairness.
That is the same ownership lesson as a live game table :many concerns in flight (inputs, timers, broadcasts), but one clear writer of shared state beats “a thread per player mutating the pot.” Concurrency is required. Chaos is optional.
Interest management (AOI) decides what to send whom — critical on a big BR map, still important in tight CoD lanes. That is game zoning, not your cloud zone.
“One contest, a million players” — how scale actually works
This is the question my nephew asked next: if a big contest has millions of players, do you build one giant server? When people die, do servers shrink? How does one website send everyone to the right place?
First correction — millions are not one match
A PUBG-like battle royale match is on the order of 100 players (squads fill seats). A CoD-like mode is often fewer per room.
A million concurrent players means roughly:
many regions taking traffic
huge matchmaking queues
thousands / tens of thousands of matches in parallel
each match on a dedicated host (or a packed host running N matches) inside a zone
So the platform scales by creating many small rooms, not by stuffing a million people into one simulation. One match still has one authoritative world. Parallelism across the system is many matches at once the same idea as many live tables running together.
Scale up — when the lobby catches fire
Autoscaling is driven by signals, not vibes:
Signal
What scales up
Login / store spike
Platform pods / gateway capacity
Queue depth / wait time
Matchmaking workers + dedicated game fleet
Assigned matches with no free host
Warm pool → cold start more game servers in the zone
Telemetry / anti-cheat volume
Side-plane consumers (async)
Typical dedicated-fleet pattern:
1. Keep a warm pool of ready game hosts per region/zone (already loaded build, waiting).
2. Matchmaking fills a lobby → allocates a host (or a match slot on a packed host) → returns connection info to clients.
3. If warm pool is empty and queue is deep → scale out more hosts (VM/container/bare metal — product choice).
4. Cap scale with max so a bug cannot bankrupt you; spill to “queue longer” rather than infinite machines.
Platform and matchmaking scale on HTTP/API metrics. The game fleet scales on matches needing a home.
Scale down — when the game finishes (not when one player dies)
When a match ends:
1. Dedicated host writes results → hands off to progression / rewards (platform).
2. Players disconnect from that match path.
3. Host is recycled (back to warm pool) or terminated if pool is fat.
4. Autoscaler watches idle hosts + queue depth → scale in slowly (hysteresis), so a two-minute lull does not thrash.
Important: when players are eliminated mid-match (“next circle, fewer alive”), you usually do not tear down or resize away the dedicated server. The match is still live until a winner (or draw) is declared. What can drop is work inside the match:
fewer clients to replicate to
lighter AOI / bandwidth
fewer voice peers
The map safe zone shrinks (game rules). The cloud zone fleet shrinks when matches complete and demand falls — different clocks.
Eliminated players often return to lobby / spectate / platform that traffic moves back to lobby and platform services, while the match host keeps serving survivors.
One domain — how everyone reaches the right service
Players remember one name: `play.example.com` or the game client’s embedded API host. Behind that single domain:
Client
→ DNS (often geo / latency aware) → regional edge
→ API gateway / load balancer
├─ /auth /store /profile → Platform services
├─ /party /presence → Region-aware social
├─ /matchmaking → Matchmaking
└─ after “match found”
→ dedicated host or game gateway
(host:port / token / session from matchmaking — not the store URL)
(host:port / token / session from matchmaking — not the store URL)
So:
One public front door (domain + gateway) for lobby and platform.
Matchmaking is the redirector in product terms— it tells the client which game service/host owns this match.
In-match traffic often goes straight to the assigned game endpoint (UDP/dedicated), not through the store cluster.
Sticky cookies / tokens / session tickets bind “this player → this match host” for the life of the session.
That is the same door then room idea: REST (or similar) to get placed; persistent/realtime path to play.
What “services change” as the match levels up
Moment
What changes
Queuing
Matchmaking + platform busy; game fleet warming
Match start
One host owns that match; clients leave matchmaking hot path
Players eliminated / circle shrinks
Game rules + AOI load change; dedicated host stays
Match over
Host frees; progression spikes briefly; fleet can scale down
Contest evening ends
Queue collapses → scale in matchmaking and game fleet toward baseline
Services do not randomly morph mid-bullet. Ownership moves along the journey: platform → matchmaking → dedicated match → platform again. Autoscaling follows which layer is busy, match by match, region by region.
What not to put on the map’s hot path
Charging a card on every elimination
Synchronous “call the data warehouse” mid-fight
Treating matchmaking REST polls as the game loop
One global region “because simpler”
One mega-zone “because Kubernetes”
Draw the map. Then pick UDP, dedicated hosts, queues, and stores into the boxes — not instead of the boxes.
Wrapping-Up
My nephew wanted guns and maps. I gave him regions, zones, and services.
Region— where the player belongs for ping and pools.
Zone — where this match’s capacity and failure story live.
Services— the jobs: platform, matchmaking, dedicated simulation, edge, side planes.
PUBG-kind and Call of Duty–kind games share that map. They differ in match shape and fleet churn, not in inventing a different physics of distributed systems.
Mama’s rule: draw the map before you name the tool.
Mama’s second rule: a million players means many matches and an autoscaler with a warm pool — not one infinite server that shrinks every time someone is eliminated.
When someone asks how you would “design PUBG or CoD,” start with: Which region? Which zone? Which service owns this moment in the player’s journey? Then ask: What signal scales that service up — and what event lets it scale down?
One honest caveat: this design is my assumption — a teaching map from how large multiplayer backends are typically shaped, and from patterns I have used in live gaming. It may not be the exact architecture the owners of PUBG or Call of Duty run in production. Their real systems will differ in topology, naming, vendors, and secrets we do not see. Use the map to think; do not treat it as a reverse-engineered blueprint.
How our game server evolved from a monolith to micro game servers
When I started my career at an Indian gaming startup called a23.com, I had no idea I was about to get a front-row seat to one of the hardest problems in online gaming: scale.
I joined a small team – just me, my CEO, and a mentor who doubled as my manager – and I was handed the backend. My job was to build the game server – the piece that keeps everyone in a match synchronized, fair, and connected. In the early days, “scale” meant something modest. We were happy to serve 20 to 100 concurrent players without things falling over.
In the beginning: one socket server to do everything
We started simple. The whole backend was a single socket server, and it did almost everything.
Clients didn’t talk to a web API or a queue — they opened a persistent socket connection straight to the game server and stayed connected for the life of the session. That one server carried the entire weight of the game:
Game events — every move, deal, and turn at the table.
Monetary transactions — wallets, buy-ins, and payouts, all real money.
State and broadcasting— keeping every player at a table in sync.
The loop was beautifully straightforward. A client would send an input over its socket. The server would receive it, process the game logic, update the table state, and broadcast the result to every player seated at that table. Take input, compute, fan out — that was the heartbeat of the whole game.
It was a classic two-tier architecture — clients on one side, a single game server (with its database) on the other
For 20 to 100 players, this was perfect. One process, one connection per player, one place to reason about everything. We could hold the entire system in our heads.
That simplicity was exactly what would later break.
The smartphone wave: when traffic stopped being polite
Around 2010, the ground shifted under us.
Smartphones were suddenly everywhere, and cheap, fast mobile internet — 3G, then 4G — put a gaming client in everyone’s pocket. People who’d never owned a PC were now playing on the bus, in queues, on their lunch break. For the business, this was a dream. For our backend, it was a flood.
Traffic didn’t grow politely. It surged. And our single monolithic server, the one we used to hold entirely in our heads, started to strain. It could still work — but “perfectly” was off the table. One process trying to do game events, money, and broadcasting for an ever-growing crowd was a bottleneck waiting to happen.
We weren’t just trying to survive today’s load. We were getting ready for 10x the traffic.
Step 1: Split the monolith by responsibility
The first move was to stop making one server do everything. We broke the monolith into modular servers, separated by domain— each one owning a clear slice of the system:
Lobby server — players browsing, joining, and matchmaking into tables.
Game server — the actual gameplay: events, table state, broadcasting.
Transaction server— wallets, buy-ins, payouts, and everything money.
Crucially, there was no single gateway in front of these. The client connected to each service directly: one socket to the lobby server for browsing and matchmaking, and a separate socket to the game server once it sat down at a table. Two independent connections, each to the service that owned that part of the experience.
The transaction server sat slightly apart. Instead of talking to it over a direct socket, the game server and transaction server communicated through a message broker. When money needed to move — a buy-in, a payout — the game server published a message and let the broker carry it to the transaction server, rather than blocking gameplay on a synchronous call. This kept the money path decoupled and reliable even if transactions queued up under load, the game kept running.
Now a spike in people browsing the lobby couldn’t drag down a live game, and a heavy moment in gameplay didn’t put financial transactions at risk. Each concern could scale — and fail — on its own.
Step 2: Shard the game servers
Splitting by responsibility bought us room, but a single game-server tier still couldn’t hold all the live tables at scale. So we sharded it.
Instead of one game server for everything, we ran many — and assigned each shard by game type and bet type. A specific game at a specific stake had its own dedicated game servers. Players were routed to the right shard for the table they wanted to play.
Sharding gave us two things we badly needed: horizontal scale (just add more game servers for the hottest game/bet combinations) and isolation (a problem on one shard didn’t ripple across the whole platform).
From garage startup to enterprise: the move to microservices
By 2020, the company I’d joined in a garage wasn’t a startup anymore. It had grown into a full enterprise gaming company. We’d added game after game, and the player base had exploded to 80 million registered players, with up to 200,000 (2 lakh) concurrent players at peak.
Our modular, sharded setup had carried us a long way but the rules of the game had changed again, and this time the pressure came from two directions at once:
Management wanted to optimize infrastructure cost. Running fleets of always-on servers sized for peak traffic meant paying for capacity we didn’t use most of the day.
The business wanted on-demand scaling without manual intervention. When a tournament or a festival spike hit at midnight, no one should have to wake up and spin up servers by hand.
Meanwhile, cloud computing had matured. What used to mean racking our own machines could now be rented, automated, and scaled by API. The timing was right to rethink the foundation.
So we made the leap: containerization with Docker, on a major cloud provider, and a full move to microservice architecture.
Each piece of the system lobby, game shards, transactions, and everything that had grown around them — became an independent, containerized service. That shift gave us the three properties an enterprise platform at this scale can’t live without:
Auto-scaling — services scale up when traffic surges and scale back down when it fades, so we pay for what we actually use. We drove this with CPU-based scaling when a service’s CPU utilization crossed a threshold, the platform automatically added more containers, and removed them again once load eased.
Fault tolerance — if one container dies, it’s automatically replaced; a single failure no longer takes down the experience.
High availability — the platform stays up through deploys, spikes, and hardware hiccups, because no single instance is a point of failure.
This was the moment our “game server” stopped being a server at all. It became a living fleet of micro game servers — spinning up and winding down on their own, healing themselves, and absorbing 200,000 concurrent players as a matter of routine rather than a fire drill.
Step 1: Make the game engine stateless
Auto-scaling sounds great until you ask the hard question: if any container can be killed and replaced at any moment, where does the game state live?
In the old world, a game server held the table state in its own memory. That’s fine when a player is pinned to one server for the whole session — but it’s fatal for auto-scaling. If that one instance dies, the table dies with it. And you can’t freely add or remove nodes if each one is the only place its tables exist.
So the first and most important move was to separate game state from the game engine.
We pulled table state out of the engine’s local memory and into a distributed in-memory cache. State now lived in a fast, shared layer that every game node could read and write — not locked inside a single process.
With state externalized, the game engine itself became stateless.Any game node could now serve any player: it would simply pull the current table state from the cache, apply the move, write it back, and broadcast the update through the message broker to everyone at the table.
That one change unlocked everything else. Because no node “owned” a table anymore, we could add nodes, kill nodes, and reschedule containers freely— exactly what auto-scaling and fault tolerance require.
On top of that, we added a few more pieces to complete the picture:
A matchmaking service — a scalable REST service that the client calls first. Based on matchmaking rules (game type, bet type, seats available, and so on), it picks the right table and returns the game table info to the client. It reads the live picture of tables and seats from the cache to make that decision. Only after this does the client know where to play.
WebSockets for clients— we upgraded the client connections from raw sockets to WebSockets, a better fit for a modern, containerized, cloud-fronted platform.
An API gateway in front of the WebSockets — clients now connect through an API gateway rather than straight to a node. That let us lean on gateway features we’d otherwise have to build ourselves: rate limiting, security, authentication, and routing.
Game engines on a container service — the engine ran as containers on the cloud’s container platform, ready to be scaled and replaced on demand.
Put together, the flow became beautifully elastic. A player first calls the matchmaking REST service, which looks at the available tables in the cache and hands back the game table info. Armed with that, the client opens a WebSocket to the game services for that table, and any available game node can serve them — reading and writing state in the cache, persisting to the database, and fanning out updates through the broker. No node is special. No node is irreplaceable.
Step 2: The hybrid stateful redesign
The stateless design was elegant — but at our peak, elegance got expensive.
At full load we were processing roughly 300,000 (3 lakh) concurrent game events. Because the engine was fully stateless, every one of those events meant a round-trip to the cache and a broadcast through the message broker — and since players at the same table could land on different nodes, nodes had to chatter with each other to stay in sync. Two things started to hurt:
The message broker was spiking, straining under the sheer volume of events flowing through it.
The game engine burned resources on inter-node communication, coordinating state that lived everywhere and nowhere.
Pure statelessness, it turned out, had a cost of its own. So we went looking for a middle path – a hybrid design that kept the operational benefits of microservices but stopped paying the per-event tax.
The core idea: pin one game to one node.
One game, one node. Each game runs entirely on a single game node, which keeps that game’s state in local memory.Gameplay reads and writes happen in-process — no cache round-trip, no cross-node coordination, per move.
Sticky sessions via the API gateway. We map each `gameId` to a gateway cookie, creating a sticky session that always routes a given game on a given node. One sticky session = one game on one node.
Matchmaking hands out the cookie. When the matchmaking service places a player at a table, it returns the cookie along with the table info. The client uses that cookie to connect — through the API gateway — to the same node hosting that game table, every time.
Because all the players of a table now share one node, the engine can use local state for live gameplay. That single change let us dramatically cut message-broker traffic and game-server resource usage the inter-node chatter and constant broadcasting largely went away.
But we didn’t abandon the cache. We still write game state to the distributed cache in the background. Local memory makes gameplay fast; the cache is our safety net – it’s what makes the system fault tolerant, because a game’s state can be recovered even if the node holding it disappears.
A custom scaler service
There was one more problem: CPU is the wrong signal for this workload.A node hosting many low-activity tables and a node hosting a few intense ones can show similar CPU, even though their real load — number of games — is very different. So we dropped CPU-based scaling and built our own.
We added a dedicated Scaler service that owns both scaling and fault tolerance, driven by a metric that actually matters for us: the number of games per node.
Scaling. Game nodes scale out and in based on how many games each node is hosting, not CPU. When games-per-node climbs past our threshold, the scaler adds nodes; when it falls, it removes them.
Fault tolerance. When a node crashes, the scaler spins up a replacement and redistributes the failed node’s games across the remaining nodes in round-robin — rehydrating each game’s state from the cache. Players are reconnected to a healthy node, and play continues.
This hybrid model gave us the best of both worlds: the speed of local state for 300,000 concurrent events, and the resilience and elasticity of microservices for 200,000 concurrent players —without melting the message broker in the process.
Wrapping up: what a decade of scaling taught me
When I look back at the journey — from a single socket server in a garage to a self-healing fleet of micro game servers serving 200,000 concurrent players — the most striking thing isn’t any one piece of technology. It’s that every stage was the right answer for its moment, and the wrong answer for the next one.
A few lessons stuck with me:
Start simple. Embarrassingly simple.That one monolithic socket server was the right call at 100 players. If we’d begun with microservices, sharding, and a custom scaler, we’d have drowned in complexity before we ever shipped. Architecture should match the problem you have, not the one you imagine.
Scale arrives because of the business, not the tech. Every rewrite was triggered by something outside engineering — smartphones, cheap data, 80 million users, a finance team watching the cloud bill. The best architecture decisions came from listening to where the business was heading.
Split by responsibility before you split by scale. Breaking the monolith into lobby, game, and transaction services bought us clarity and isolation long before sharding bought us raw throughput.
There is no “correct” answer on state. We went stateless to unlock auto-scaling, then deliberately walked back toward local state when statelessness got too expensive. Stateless vs. stateful isn’t a religion — it’s a trade-off you re-evaluate as load changes.
Measure what actually matters. CPU looked like a scaling signal until it lied to us. Switching to games-per-node a metric tied to real load made scaling and fault tolerance finally behave.
Decoupling is what lets you sleep. Message brokers, caches, sticky sessions, a dedicated scaler almost every hard-won improvement was really about making one part of the system fail without taking the rest down with it.
If you’re early in your own scaling journey, don’t try to leap straight to the final diagram. Build the thing that works today, watch where it hurts, and let the pain point you to the next architecture.That’s not a failure of planning — that’s how systems that handle hundreds of thousands of players actually get built.
We didn’t design our way to 200,000 concurrent players in one shot. We evolved there, one bottleneck at a time.
As an Enterprise Architect, I collaborate with business stakeholders and engineering stakeholders all the time. Outcomes, SLAs, capacity, latency, failure modes — that rhythm is familiar.
Data scientists who build the ML algorithms were a different experience in a good way.
They showed up with business flavour and tech flavour in the same conversation. In one breath simulation accuracy, what churn means for the player, payment and bonus buckets. In the next, XGBoost, Random Forest, sklearn self-learning paths, training windows on ten years of history. They were not pure business and not pure platform engineering. They sat between and that made the Architecture Review Board (ARB) interesting.
Stakeholder
What they brought
What the ARB had to decide
Business
Payment/bonus buckets, retention goals, act in session
At the ARB we had to translate three worlds into one production decision:
Business — act while the player is still in session churn capacity and purchase capacity must map to real buckets
Data science — 80% simulation accuracy on churn and purchase models demo landed; artifacts ready
Engineering — can we execute those models in real time when game actions end, at gaming concurrency, without the prediction arriving after the player left?
If you are not married with your ML — ML aligned with engineering — you will not get results. Not “ML vs engineering” Not a handoff and goodbye. A marriage analytics owns the models platform owns real-time execution both meet at the seam where game events become predictions and buckets.
That is where the marriage happens not in a hallway after the demo, but in the ARB room when all three flavours are in the same frame. If you run an ARB, the useful question for ML is not only “what is the accuracy?” It is: “What must be true. in production for this model to count as a result?”
Our ML analytics team had done serious work. They built churn and purchase models on ten years of player history, ran simulations, hit around 80% accuracy, demoed the room applauded. Then the ask landed on architecture when a game action ends in real time, execute the right model, return a prediction, and recommend payment and bonus buckets according to churn capacity and purchase capacity.
That is a different problem from the notebook. An accurate model with no event-driven platform, no serving layer, and no infra under concurrent load does not change outcomes. It stays in a slide deck.
This article is how we built that platform a Python Prediction Engine on Flask first, upgraded to FastAPI when concurrent predictions became the bottleneck and what we learned when ML and engineering had to work as one system.
Accuracy is necessary. It is not sufficient.
What ML delivered
What the platform had to deliver
Models on 10 years of history
Real-time scoring on live game events
80% simulation accuracy
Low latency under production concurrency
Demo on sample data
Integration with game engine and targeting
Model artifacts handoff
Routing by event — churn vs purchase handlers
Works in the lab
Payment & bonus bucket recommendations in session
The gap between those columns is architecture and engineering not another week of feature engineering alone.
That gap closes only when ML and engineering are married, not when one team “finishes” and throws artifacts over the wall the same lesson we learned at the ARB before a line of platform code shipped.
The business problem: act on the game, not on yesterday’s warehouse
Players do not churn in batch files. They churn in sessions.
When a game ends, we already know something useful: how they played, whether they won, how they bet, how long they stayed. The business wanted to use that moment to
Estimate churn risk and steer retention offers
Estimate purchase capacity and place the player in the right payment / bonus bucket
Do it in real time— not after a nightly Spark job.
So the game engine would emit an event whenever a relevant game action completed. Our job was to consume that event, call the right model, and return a recommendation the targeting and payments stack could act on.
That became the Prediction Engine.
How we designed it: event-driven, command pattern, handler factory
We designed an event-driven flow.
Game engine (game ends / action completes)
→ event on the bus
→ Prediction Engine consumes
→ HandlerFactory picks handler by event type
→ Model inference (churn or purchase)
→ Response: bucket / offer recommendation
→ CRM / targeting / payments path
Command pattern + HandlerFactory
Not every event should run every model. A game-end event might need a churn handler. A purchase-intent or cash-action event might need a purchase-capacity handler. We used the Command pattern.
Each event type maps to a handler(a command object)
A HandlerFactory returns the right handler from the event name / type
The handler loads features, calls the model, shapes the response
That kept the engine extensible. New model, new event — add a handler and register it in the factory. The game engine stayed dumb emit event, do not know model internals.
Flask v1: ship fast, prove the path
We built the first version in Python with Flask.
Simple REST endpoints for health and internal ops
Consumers pulled work from the messaging layer and invoked handlers
For a while it worked fine. Predictions landed. Buckets updated. The demo became production.
What broke with Flask under high concurrent predictions.
Load tests passing once is not the same as sustained concurrent predictions at gaming traffic.
Flask’s default synchronous WSGI model became the ceiling
Symptom
Under the hood (Flask sync WSGI)
Latency spikes at peak
Each request blocks a worker for full inference path
Queue depth grows
More game-end events than free Gunicorn workers
Upstream timeouts
Targeting / CRM cannot wait past SLA
“Add workers” stops helping
CPU-bound predict() does not scale linearly with threads
GIL pressure
Python threads contend on CPU during heavy inference
We were not failing because the models were wrong. We were failing because the serving model — one blocking request per worker — could not accommodate the concurrency profile once many tables ended games at once.
Eighty percent accuracy does not help if the prediction arrives after the player left the screen.
Why we upgraded to FastAPI (and what it actually fixed)
This is not Flask vs FastAPI as a religion. It is match the framework to the load.
We moved the Prediction Engine to FastAPI because we needed a better concurrency story at the API and orchestration layer.
Layer
FastAPI benefit for Prediction Engine
I/O-bound work
Async routes while waiting on features, cache, downstream calls
Contracts
Pydantic — fewer bad payloads hitting handlers
Integration
OpenAPI — CRM and internal consumers onboard faster
Inference
CPU-bound predict() in thread pool — do not block event loop
Operations
Async shell + bounded inference workers — clearer scaling model
FastAPI did not make the churn model smarter. It made the platform able to serve many concurrent predictions without worker exhaustion on the hot path.
Containerized deployment and autoscaling — still required
Models from the analytics team — same artifacts, better house.
The upgrade was the serving and concurrency layer, not a rewrite of the ML story.
Thread pools at consumers — and why events still piled up
Under concurrent load we used thread pools at the message consumers. That was reasonable for v1 each game-end event got a worker thread, the handler ran, features were fetched, XGBoost / RF / sklearn`predict()` executed, the result went out.
It worked until events piled up.
When many tables ended games at once, the pattern looked like this.
A bounded thread pool caps throughput — backlog is inevitable under burst
Many threads mean memory and context-switch overhead, not free parallelism
Blocking I/O (RTQE, Cassandra, HTTP) ties up threads while they wait
CPU-bound `predict()` keeps threads busy for the full inference window
We were not GPU-bound. We were concurrency-shaped wrong for a burst of single-row tabular scoring.
Layer
Thread pool at consumer (v1)
Async + Uvicorn / uvloop (v2)
Consume event
One OS thread per in-flight event
await — many events multiplexed on event loop
Feature fetch (RTQE)
Thread blocked while waiting on network/DB
await — thread not held during I/O wait
XGBoost / RF predict()
Thread blocked for full CPU inference
run_in_executor — bounded pool, off event loop
Under burst (game ends)
Pool exhausted → events pile up → latency spikes
I/O stays cheap; inference capped but queue drains faster
GPU needed?
No — tabular CPU models
No — fix serving shape, not hardware
The async pattern: Uvicorn, event loop, and bounded inference
The fix was not “more threads.” It was async for waiting, thread pool for predicting.
We moved the Prediction Engine to FastAPI served by Uvicorn, with uvloop as the event loop where we could — a faster asyncio loop for I/O-heavy work. The pattern.
async consumer / handler
├─ await consume event (I/O — event loop handles many in flight)
├─ await run_in_executor(predict) (CPU — bounded thread pool, NOT on event loop)
└─ await ack / publish result (I/O)
Rules we enforced.
1. Never call `predict()` directly inside `async def`— it blocks the event loop and recreates the pile-up.
2. Bounded inference pool — cap concurrent XGBoost/RF/sklearn calls (semaphore or fixed executor size).
3. Async for orchestration — consume, fetch, ack let the loop multiplex I/O.
4. Scale out — more container replicas when queue depth rises async is not infinite capacity.
uvloop did not make the models more accurate. It made the consumer path stop drowning in thread overhead when events stacked up. GPU would not have fixed that either our models are tabular, CPU-native, one player per game end.
This is the same lesson as on the Java side with virtual threads at RTQE scale: match the concurrency model to what actually blocks I/O on the loop, CPU in a pool.
The full stack picture
Our models were tabular — not deep learning. The analytics team trained on ten years of history using XGBoost, Random Forest, and sklearn self-learning / incremental models. Simulation accuracy was strong (~80%). Serving them in production was a different shape of problem:
Model family
Role in Prediction Engine
Serving note
XGBoost
Primary churn and purchase scorers
CPU inference — one player per game-end event; GPU not required
Random Forest
Supporting / ensemble paths (sklearn)
CPU-only; keep forests lean for millisecond budgets
sklearn self-learning
Incremental updates offline; frozen artifact in prod
Retrain on schedule — do not partial_fit on every live event
GPU does not improve accuracy for this stack. It can help large-batch deep learning — not single-row XGBoost/RF scoring at game end. The bottleneck we hit was concurrent serving(Flask workers), not matrix math on a GPU.
Flow in production.
1. Game engine— live table ends; emits event (game end, cash action)
2. Message bus— decouples gameplay from scoring (same event-driven seam as the modular game engines).
7. Targeting / CRM / payments — act while the player is still in session
Features came from the same real-time data path we had already built for segmentation the Dynamic Real-Time Query Engine and related platform services. Accurate models plus live features plus an engine that could keep up that is when recommendations started to matter in production.
Lessons we took to the architecture board
1. Demo accuracy ≠ production value. Simulation at 80% is a starting line. Without real-time integration and infra, it is a handoff to nowhere.
2. Marry ML with engineering — or you will not get results. Separate ownership is fine; separate reality is not. Analytics trained on ten years of history architecture built the event-driven engine, feature path, and serving layer. Neither side wins alone. Handoff without partnership is applause without outcomes.
3. The ARB is where ML, business, and engineering marry.Data scientists bring business and tech flavour together — simulation accuracy and model mechanics. Use the board to ask what must be true in production for the model to count as a result.
4. Join model team and platform team at the seam. Analytics builds models; architecture builds the event-driven engine that runs them on live game actions — together, not in sequence with a six-month gap.
5. Design for events, not cron. Game-end is a natural trigger. Command + factory keeps new models from polluting the game engine.
6. Load test the serving path, not just the model. Concurrency tests on Flask passed — until production traffic shape changed. Test concurrent predictions, not one request at a time.
7. Upgrade frameworks when the bottleneck is serving, not when a blog says so. Flask was the right v1. FastAPI was the right v2 for our concurrency profile.
8. Async does not fix CPU-bound inference. Run `predict()` in a thread pool or dedicated workers. FastAPI wins on orchestration and I/O, not on magic faster math.
9. XGBoost / RF / sklearn are CPU-native. Do not reach for GPU to fix accuracy or single-row game-end latency. Optimize the serving layer and feature path first.
10. Thread pools at consumers are not enough under burst. Use async for I/O(Uvicorn / uvloop) and a bounded pool for `predict()`— never block the event loop with inference.
When Flask is still fine
Internal tools, low QPS, batch scoring
Prototypes and first vertical slice
Teams that will never see game-end burst concurrency
When many game actions end at once and payments and bonus buckets must update in session, plan for serving architecture early not after the ML demo.
Wrapping up
Our ML team did serious work: ten years of history, churn and purchase models, 80% simulation accuracy, a convincing demo. None of that produces player outcomes until architecture gives you a Prediction Engine that.
Consumes game events in real time.
Routes through a HandlerFactory and command handlers.
Runs models under concurrent load
Returns payment and bonus bucket recommendations while it still matters.
We started on Flask — containerized, load-tested, deployed, working. We moved to FastAPI when high concurrent predictions exposed the limits of synchronous serving.
Marry ML with engineering. Eighty percent accuracy in simulation is not a result. An event-driven Prediction Engine at gaming concurrency is. Without that marriage models plus platform plus infra you do not get outcomes. You get applause in a meeting room.
If you have shipped ML to production — what broke first the model, the features, or the serving layer? Drop a comment.