Performance and Scalability Best Practices
A game backend that performs well on launch day is designed for real player load from the start. From helping studios load test their backends before launch, we see the same handful of design patterns behind the deployments that stay fast and scale smoothly. This guide distills those patterns into practices you can apply at design time, so your backend delivers consistent performance from the first load test through launch and beyond.
How to read this guide #
Most practices here apply to server runtime code: Go modules, Lua modules, TypeScript modules, hooks, RPCs, and match handlers. The batching and hoisting practices in the Database access section apply equally to client SDK code, where the same N+1 and redundant-call patterns appear in client-side storage reads.
Core principles #
Budget your database round trips. Each RPC or handler should make at most 5 database interactions on its worst-case path. A call inside a loop counts as N. A batched call counts as one. The authoritative match loop has a budget of zero per tick. This is a design threshold, not a hard server limit: if a handler legitimately needs more after batching, that’s fine, but you should know exactly why.
Keep handlers traceable end to end. Reads scattered across nested helpers can’t be batched because no single function sees the full set. Structure handlers so all state access is visible from the top, and follow what helpers do before concluding a handler is within budget.
Database access #
When you call a batchable API once per item inside a loop, you’re paying N round trips where one suffices. StorageRead, AccountsGetId, WalletsUpdate, and their SDK equivalents all accept a slice of keys; collect the whole set first, call once, then re-index the results. Batch responses are not order-guaranteed and omit missing keys entirely, so never pair results with inputs by index.
Incorrect:
| |
Correct:
| |
The same shape applies across the batchable APIs. AccountGetId becomes AccountsGetId. Per-user WalletUpdate calls become a single WalletsUpdate. Per-record StorageWrite calls collapse into one batched write.
Watch for the hidden form: a single-item helper that a caller invokes in a loop. The loop and the read live in different functions, so neither looks wrong alone, but the call tree issues N transactions. Give helpers a batch form and gather the keys at the caller.
A loop-invariant call returns the same result on every iteration: re-reading a clan config once per member, or fetching the same account inside two helper functions that share one execution path. The fix is to call it once and reuse the result.
Incorrect:
| |
Correct:
| |
The same problem appears without a loop: two helper functions each fetching the same account on one execution path. Fetch once at the top of the path and pass the result down.
Any function that changes several pieces of player state should follow one shape: read everything at the top, compute every change in memory, then write everything in a single batched call. That gives you one read round trip, one write round trip, and one atomic unit. If the server crashes between two independent writes, the player ends up in a state your game’s rules never allow.
Incorrect:
| |
Correct:
| |
Pass the Version field from each read back into each write. That optimistic lock means a concurrent modification is rejected rather than silently overwritten. When the operation must also move currency atomically, use nk.MultiUpdate (Go), nk.multiUpdate (TypeScript), or nk.multi_update (Lua) to commit storage and wallet changes as one transaction.
A hot row is a single storage object that many concurrent players read-modify-write simultaneously. Each write takes a lock, so concurrent writers queue behind one another, and throughput is capped by how fast one row accepts writes regardless of server or cluster size. Adding more servers doesn’t help because every write still funnels through the same row lock. Worse, optimistic locking amplifies the problem: more concurrent writers means more version conflicts and more retries, each of which adds load rather than relieving it.
Incorrect:
| |
Correct:
| |
The simpler alternative is to key each contribution by user ID, so a player only ever contends with themselves and the global total is the sum of all per-user rows. Sharding is the middle ground when a bounded, known number of rows to aggregate is preferable to one row per player.
A generate-check-retry loop tries to allocate a unique value by reading storage to test whether a candidate exists, then writing it if the slot is free. Each attempt costs a read, and the loop can spin many times in a crowded namespace. The deeper problem is a race: between the read that finds the key free and the write that claims it, another concurrent request can do the same and both succeed, silently duplicating a value that was supposed to be unique. No amount of batching closes this gap.
Incorrect:
| |
Correct:
| |
Version: "*" means “create only, reject if present.” The write itself is the atomic check. Distinguish a version conflict from a real error before retrying: retrying on a real database error wastes the attempt budget and hides failures. Use a large enough code space that collisions are rare, so the loop almost never iterates more than once.
When the number of iterations in a loop is set by player state, event data, a client-supplied count, or a retry budget, the loop’s cost is outside your control. Ordinary values pass testing; an extreme value from a live-event multiplier, a stacking bug, or a malformed request can turn a routine handler into a burst of hundreds of round trips. A clear rejected request is better than a silent multi-hundred-iteration stall.
Incorrect:
| |
Correct:
| |
The guard is cheap and explicit. Batch the per-iteration work so that even a legitimate large-but-bounded count stays within budget.
Storage design #
Nakama’s Storage Engine (collection/key/user objects with StorageRead, StorageWrite, StorageList, storage indexes, wallets, leaderboards, and friends) is the sanctioned persistence layer. Don’t execute hand-written SQL through the db handle or create custom tables with DDL. Both bypass the managed layer and forfeit upgrade compatibility, clustering support, the object permission model, and schema management.
Don’t:
| |
| |
Prefer:
| |
The object model covers the large majority of game persistence needs. When a use case genuinely doesn’t fit, contact Heroic Labs before reaching for custom SQL. There’s often a supported path, and where there isn’t, Heroic Labs can advise one that stays compatible with the managed platform.
A storage object is read and written as one value. When code appends to a list inside that value on a recurring path (a match summary per match, a purchase per transaction) and nothing trims it, the object grows for the life of the account. Read cost, write cost, serialization cost, and the optimistic-lock conflict window all grow with it, invisibly at launch and more severely for your most active players.
Incorrect:
| |
Correct:
| |
Choose the shape by access pattern. Data read every session (loadout, currency, recent results) belongs in the bounded object. Data read rarely and by page (full history, audit trails) belongs as one object per entry in its own collection. Ranked data belongs in a leaderboard.
A Nakama storage index is a bounded, derived projection over a collection. It’s capped by maxEntries, and once the collection grows past that cap, evicted entries are absent with no error and no automatic rebuild. Any read that treats the index as the complete or authoritative set will silently return a partial answer.
Don’t:
| |
Prefer:
| |
The same discipline applies to eviction: an index miss doesn’t prove an object doesn’t exist. On an index miss for an existence check, fall back to a keyed storage read before concluding the object is absent. Treat the index as a rebuildable optimization for finding some matching objects, never as the record of what exists.
A storage index extracts configured fields from object values in its scope (the collection, or the collection plus a key). If objects in that scope have different shapes, the failure is silent: an object with none of the indexed fields is excluded from the index entirely, while an object with only some of them is indexed inconsistently. Queries return well-formed, plausible, incomplete result sets with no error.
Incorrect:
| |
Correct:
| |
Marshal into typed structs rather than ad hoc maps: the shape becomes a compile-time fact rather than a per-call-site decision, and it’s impossible to write a struct that’s missing its own fields. Use the collection name as the type discriminator.
Match handlers #
An authoritative match loop runs once per tick, and everything that tick does happens inside that one call. A database round trip inside the loop blocks the whole match for its duration. One slow storage read inside a loop ticking 10 times a second consumes a significant portion of every tick’s budget and produces visible stutter for every player in the match at once. Hundreds of concurrent matches with in-loop I/O also become a database load generator that scales with tick rate, not player actions.
Load state when players join. Mutate it in memory as match state the loop already owns. Persist at match end, or on a coarse interval if the match genuinely needs mid-match durability.
Incorrect:
| |
Correct:
| |
If the match genuinely needs mid-match durability, persist on a coarse interval such as every N seconds or on significant events like round end. Per-tick persistence is never the answer.
Hooks #
A Before*/After* hook runs on every invocation of the operation it wraps. Work placed in a hook on a high-frequency operation is a tax multiplied across the entire request volume. A Before hook’s latency sits directly in the caller’s critical path: the client waits for the hook before the operation proceeds. A synchronous analytics or webhook call inside an after-authenticate hook chains login availability to a third party’s uptime, turning an analytics outage into a login outage.
Incorrect:
| |
Correct:
| |
Ask two questions before adding work to a hook. Does it have to happen before the client gets its response? If not, move it off the synchronous path. Does every caller of this operation need it, or only specific flows? Work specific to one flow belongs in that flow’s RPC, not in a hook that taxes everyone.
Background work #
An analytics or telemetry HTTP call issued inline in an RPC, hook, or match callback puts a third-party network dependency on the player-facing path. The player waits on the vendor’s response time, and if the error propagates, a vendor outage fails gameplay operations that were otherwise fine. Analytics is observability, not game state: it should never add latency to or fail the operation it observes.
Incorrect:
| |
Correct:
| |
Nakama’s event system processes events on a fixed-size background queue with a pool of worker goroutines (controlled by runtime.event_queue_size and runtime.event_queue_workers). The handler returns without waiting. If the queue fills, events are dropped rather than back-pressuring gameplay, which is the right trade for telemetry. Don’t use this mechanism for economy- or progression-critical data, which belongs in the transactional write.
Events can be emitted from any server runtime (Go, Lua, TypeScript), but event processors can only be registered in Go.
(Go runtime.) A request-scoped context.Context is cancelled the moment the RPC or hook that owns it returns, and often earlier if the client disconnects. Any work that outlives the handler (an async callback, a queued event handler, a goroutine) must use its own context for database or Nakama calls. Context-aware operations short-circuit on a cancelled context, so the deferred write silently never happens and produces no error.
Incorrect:
| |
Correct:
| |
Pull any values you need from the request context into plain variables before going async: user ID, request ID, trace spans are all gone once the request ends. Because a deferred callback has no caller to return an error to, a bare logger.Error without a retry or escalation path means a failed write disappears silently. Route failures to a retry queue, dead-letter, or alert.
Apply this only to work that genuinely outlives the request. Work that runs within the handler should keep using the request context so client disconnects and deadlines still cancel it.
Caching #
Static data is anything whose lifecycle is tied to a deploy and that no player owns: asset definitions (the catalog of building types and their stats, not the buildings a player has placed), shop catalogs, drop tables, progression curves, localization tables, or a GeoIP database queried through an in-memory map. A handler that reads and parses such a file on every call pays disk I/O plus a full JSON parse per request for a result that is identical every time. Load it once at InitModule into a package-level structure and read it directly in RPCs, hooks, and match handlers.
Incorrect:
| |
Correct:
| |
Because the cache is read-only after init, no mutex is needed and no data race is possible. If the cache ever needs to be written after init (a reload RPC), use sync.RWMutex or atomic.Pointer. Failing in InitModule converts a per-request runtime error into a boot-time failure: bad content fails at deploy instead of producing errors in production traffic.
The test is ownership, not shape. A building definition is static data; the buildings a player owns are player state and belong in storage. Don’t cache per-player or per-match data as module-level globals: a map keyed by user ID or match ID that grows without eviction is unbounded growth, not a static cache.
A handler that fetches the same login-time fact on every call (client version, early access flag, subscription tier, a data-migration flag) pays a database round trip per request for data that can’t have changed since authentication. Session variables are key/value string pairs embedded in the session token at authentication, readable in any RPC or hook from the runtime context at zero cost.
Session vars originate from two places, and both end up in the same token:
- Server-side: a before-authentication hook sets vars derived from server state. Use this for facts the server owns.
- Client-side: the client passes vars in its authenticate call. Use this for facts only the client knows, such as its own version.
Incorrect:
| |
Correct (server-side, in a before-authentication hook):
| |
Correct (client-side, at authentication):
| |
| |
Treat client-supplied vars as hints, not facts. They’re fine for data like the client version, but anything that influences what the server grants or allows must be set (or validated and overwritten) in a before-authentication hook, since clients control what they send.
Don’t use session vars for state the session can mutate: wallet balance, energy, inventory, or anything that must take effect immediately mid-session (a ban check, for example, must not be cached in a token). Those belong in storage; fetching them per request is correct.
Module structure #
(Go runtime.) Nakama Go modules compile to a single .so. Splitting features into sub-packages in that context buys nothing at deployment and creates recurring costs: when two feature packages need each other, the build fails and types get exiled into an artificial common package to break the cycle; internals get exported just so sibling packages can reach them, turning implementation details into API that every refactor must preserve.
Avoid:
| |
| |
| |
Prefer:
| |
Organize by filename prefix rather than sub-package: rpc_economy.go, hook_auth.go, match_battle.go. All files in package main see each other freely, so nothing is forced public and no import boundary can create a cycle.
Extract a separate package only when it’s genuinely reusable across projects unchanged and imports nothing from the module that uses it (a client for a third-party API, for example). Dependencies point one way: application to library, never back.
Launch checklist #
Use this checklist before shipping a Nakama module to production.
Database access #
- Batch per-row calls. All batchable APIs (
StorageRead,AccountsGetId,WalletsUpdate) called once with a full slice, not once per item in a loop. - Hoist invariant reads. No read returns the same value on every loop iteration; no read appears twice on one linear execution path.
- Read all, compute, write once. Reads are gathered at the top, all mutations are computed in memory, and a single batched write closes the function.
- Shard global state. No globally-shared storage object is read-modify-written on a high-concurrency player path.
- Claim unique keys with conditional writes. No generate-check-retry loop; optimistic
Version: "*"write is the uniqueness guard. - Cap runtime loop bounds. Every loop whose count comes from player state, event data, or client input has an explicit max check before the loop body.
Storage design #
- Use the storage engine. No hand-written SQL via the
dbhandle; no custom DDL; no custom tables. - Cap and rotate growing objects. No slice field in a storage object grows unbounded on a recurring path; ring buffers or per-entry objects used for historical data.
- Authoritative reads go to storage.
StorageIndexListnot used for full-population enumerations, headcounts, or existence proofs. - One shape per index scope. Every object written to an indexed collection/key carries all the indexed fields in a consistent shape; different shapes use different collections.
Match handlers #
- I/O-free match loop. No
StorageRead,StorageWrite,WalletUpdate,AccountGetId, or HTTP call insideMatchLoopor per-message handling; state loaded inMatchJoin, persisted inMatchTerminate.
Hooks #
- Hot-path hooks are near zero-cost. Hooks on authenticate, session refresh, or storage operations issue at most one batched read; no synchronous external HTTP calls on the request path.
Background work #
- Analytics off the request path. No synchronous analytics or telemetry HTTP calls in RPCs, hooks, or match callbacks; Nakama event system used for delivery.
- Deferred work uses a background context. Any goroutine or callback that outlives its handler uses
context.Background()with a timeout, not the request context; errors are routed, not swallowed.
Caching #
- Static data loaded at init. No
os.ReadFileplusjson.Unmarshalinside an RPC, hook, or match handler for content that doesn’t change between deploys. - Session-stable data in session vars. Login-time facts (client version, feature flags, subscription status) read from the session context, not fetched per request.
Module structure #
- Flat package layout. No
common/shared/typespackage imported by sibling packages; no sibling packages importing each other; no forced exports to satisfy an artificial import boundary.
