View as Markdown

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 #

Storage design #

Match handlers #

Hooks #

Background work #

Caching #

Module structure #


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 db handle; 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. StorageIndexList not 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 inside MatchLoop or per-message handling; state loaded in MatchJoin, persisted in MatchTerminate.

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.ReadFile plus json.Unmarshal inside 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/types package imported by sibling packages; no sibling packages importing each other; no forced exports to satisfy an artificial import boundary.

Related Pages