# Best Practices

**URL:** https://heroiclabs.com/docs/nakama/server-framework/introduction/best-practices/
**Categories:** nakama, best-practices, introduction

---


# 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](../../go-runtime), [Lua modules](../../lua-runtime), [TypeScript modules](../../typescript-runtime), [hooks](../hooks), [RPCs](../#rpc-functions), and [match handlers](../../../concepts/multiplayer/authoritative/#match-handler). 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

<a id="batch-per-row-database-calls"></a>

{{< accordion title="Batch per-row database calls" >}}
When you call a batchable API once per item inside a loop, you're paying N round trips where one suffices. [`StorageRead`](../../go-runtime/function-reference#StorageRead), [`AccountsGetId`](../../go-runtime/function-reference#AccountsGetId), [`WalletsUpdate`](../../go-runtime/function-reference#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:**

```go
func rpcLoadClanRoster(ctx context.Context, nk runtime.NakamaModule, memberIDs []string) ([]*api.StorageObject, error) {
	states := make([]*api.StorageObject, 0, len(memberIDs))
	for _, userID := range memberIDs {
		// N+1: one StorageRead per member. For 100 members this is 100
		// round trips where StorageRead already accepts a batch of reads.
		objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
			Collection: "progression",
			Key:        "season_state",
			UserID:     userID,
		}})
		if err != nil {
			return nil, err
		}
		if len(objs) > 0 {
			states = append(states, objs[0])
		}
	}
	return states, nil // 100 members -> ~101 round trips
}
```

**Correct:**

```go
func rpcLoadClanRoster(ctx context.Context, nk runtime.NakamaModule, memberIDs []string) (map[string]*api.StorageObject, error) {
	reads := make([]*runtime.StorageRead, 0, len(memberIDs))
	for _, userID := range memberIDs {
		reads = append(reads, &runtime.StorageRead{
			Collection: "progression",
			Key:        "season_state",
			UserID:     userID,
		})
	}

	// BATCHED: one call for the whole roster, regardless of size.
	objs, err := nk.StorageRead(ctx, reads)
	if err != nil {
		return nil, err
	}

	// Re-index by owner. Batch responses are NOT order-guaranteed and omit
	// missing keys entirely, so never assume objs[i] pairs with memberIDs[i].
	stateByUser := make(map[string]*api.StorageObject, len(objs))
	for _, o := range objs {
		stateByUser[o.UserId] = o
	}
	return stateByUser, nil // any roster size -> 1 round trip
}
```

The same shape applies across the batchable APIs. [`AccountGetId`](../../go-runtime/function-reference#AccountGetId) becomes `AccountsGetId`. Per-user [`WalletUpdate`](../../go-runtime/function-reference#WalletUpdate) calls become a single `WalletsUpdate`. Per-record [`StorageWrite`](../../go-runtime/function-reference#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.
{{< /accordion >}}

<a id="hoist-loop-invariant-calls-out-of-loops"></a>

{{< accordion title="Hoist loop-invariant calls out of loops" >}}
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:**

```go
func rpcGrantClanReward(ctx context.Context, nk runtime.NakamaModule, memberIDs []string, clanID string) error {
	for _, userID := range memberIDs {
		// REDUNDANT: the clan config is identical for every member, yet it is
		// re-read from storage on every iteration.
		clanCfg, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
			Collection: "clans",
			Key:        clanID,
		}})
		if err != nil {
			return err
		}
		reward := parseRewardTier(clanCfg) // same value every pass
		if _, _, err := nk.WalletUpdate(ctx, userID,
			map[string]int64{"coins": reward.Coins}, nil, true); err != nil {
			return err
		}
	}
	return nil
}
```

**Correct:**

```go
func rpcGrantClanReward(ctx context.Context, nk runtime.NakamaModule, memberIDs []string, clanID string) error {
	// HOISTED: read the invariant config exactly once, above the loop.
	clanCfg, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
		Collection: "clans",
		Key:        clanID,
	}})
	if err != nil {
		return err
	}
	reward := parseRewardTier(clanCfg)

	for _, userID := range memberIDs {
		// The loop now reuses the single result. No I/O in the loop body.
		if _, _, err := nk.WalletUpdate(ctx, userID,
			map[string]int64{"coins": reward.Coins}, nil, true); err != nil {
			return err
		}
	}
	return nil
}
```

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.
{{< /accordion >}}

<a id="read-all-compute-write-once"></a>

{{< accordion title="Read all, compute, write once" >}}
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:**

```go
func completeMission(ctx context.Context, nk runtime.NakamaModule, userID, missionID string) error {
	progObjs, err := nk.StorageRead(ctx, []*runtime.StorageRead{
		{Collection: "player", Key: "progression", UserID: userID},
	})
	// ...
	if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{{ // write #1, transaction A
		Collection: "player", Key: "progression", UserID: userID, Value: string(progVal),
	}}); err != nil {
		return err
	}

	invObjs, err := nk.StorageRead(ctx, []*runtime.StorageRead{
		{Collection: "player", Key: "inventory", UserID: userID},
	})
	// ...
	if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{{ // write #2, transaction B
		Collection: "player", Key: "inventory", UserID: userID, Value: string(invVal),
	}}); err != nil {
		return err
	}
	// If the server crashes between write #1 and write #2:
	// mission is marked complete but reward was never granted.
	return nil
}
```

**Correct:**

```go
func completeMission(ctx context.Context, nk runtime.NakamaModule, userID, missionID string) error {
	// 1. Read everything needed: single batch, single round-trip.
	objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{
		{Collection: "player", Key: "progression", UserID: userID},
		{Collection: "player", Key: "inventory", UserID: userID},
	})
	if err != nil {
		return err
	}

	// Index by key. Batch responses are not order-guaranteed.
	byKey := make(map[string]*api.StorageObject, len(objs))
	for _, o := range objs {
		byKey[o.Key] = o
	}

	// 2. Compute all changes in memory.
	progression := parseProgression(wrap(byKey["progression"]))
	progression.CompletedMissions = append(progression.CompletedMissions, missionID)
	inventory := parseInventory(wrap(byKey["inventory"]))
	inventory.Items = append(inventory.Items, missionReward())

	progVal, _ := json.Marshal(progression)
	invVal, _ := json.Marshal(inventory)

	// 3. Write all changes: single batched call, applied as one transaction.
	_, err = nk.StorageWrite(ctx, []*runtime.StorageWrite{
		{Collection: "player", Key: "progression", UserID: userID, Value: string(progVal), Version: versionOf(byKey["progression"])},
		{Collection: "player", Key: "inventory", UserID: userID, Value: string(invVal), Version: versionOf(byKey["inventory"])},
	})
	return err
}
```

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-runtime/function-reference#MultiUpdate) (Go), [`nk.multiUpdate`](../../typescript-runtime/function-reference#multiUpdate) (TypeScript), or [`nk.multi_update`](../../lua-runtime/function-reference#multiUpdate) (Lua) to commit storage and wallet changes as one transaction.
{{< /accordion >}}

<a id="shard-contended-writes-across-keys"></a>

{{< accordion title="Shard contended writes across keys" >}}
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:**

```go
func rpcContributeToEvent(ctx context.Context, nk runtime.NakamaModule, amount int64) error {
	objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
		Collection: "events",
		Key:        "global_progress", // ONE row for the entire player base
	}})
	// ...
	// Every concurrent player writes back to the SAME key. Writers serialise
	// on the row lock, and version conflicts cause retry storms under load.
	_, err = nk.StorageWrite(ctx, []*runtime.StorageWrite{{
		Collection: "events",
		Key:        "global_progress",
		Value:      string(value),
		Version:    version,
	}})
	return err
}
```

**Correct:**

```go
const eventShardCount = 64

func shardKeyFor(userID string) string {
	h := fnv.New32a()
	_, _ = h.Write([]byte(userID))
	return fmt.Sprintf("global_progress_shard_%d", h.Sum32()%eventShardCount)
}

func rpcContributeToEvent(ctx context.Context, nk runtime.NakamaModule, userID string, amount int64) error {
	key := shardKeyFor(userID)
	objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
		Collection: "events",
		Key:        key,
	}})
	// ...
	// The read-modify-write remains, but now it contends only with the ~1/N
	// of players mapped to this shard.
	_, err = nk.StorageWrite(ctx, []*runtime.StorageWrite{{
		Collection: "events",
		Key:        key,
		Value:      string(value),
		Version:    version,
	}})
	return err
}

func readEventTotal(ctx context.Context, nk runtime.NakamaModule) (int64, error) {
	reads := make([]*runtime.StorageRead, 0, eventShardCount)
	for i := 0; i < eventShardCount; i++ {
		reads = append(reads, &runtime.StorageRead{
			Collection: "events",
			Key:        fmt.Sprintf("global_progress_shard_%d", i),
		})
	}
	objs, err := nk.StorageRead(ctx, reads) // reads scale fine; writes were the problem
	if err != nil {
		return 0, err
	}
	var total int64
	for _, o := range objs {
		total += parseEventShard([]*api.StorageObject{o}).Subtotal
	}
	return total, nil
}
```

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.
{{< /accordion >}}

<a id="claim-unique-keys-with-conditional-writes"></a>

{{< accordion title="Claim unique keys with conditional writes" >}}
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:**

```go
func generateInviteCode(ctx context.Context, nk runtime.NakamaModule) (string, error) {
	const maxAttempts = 20
	for i := 0; i < maxAttempts; i++ {
		code := randomCode(6) // small space -> collisions common -> many attempts
		objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
			Collection: "invite_codes", Key: code,
		}})
		if err != nil {
			return "", err
		}
		if len(objs) == 0 {
			// TOCTOU: another request can claim this same code between the
			// check above and the write below.
			if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{{
				Collection: "invite_codes", Key: code, Value: "{}",
			}}); err != nil {
				return "", err
			}
			return code, nil
		}
	}
	return "", runtime.NewError("could not allocate invite code", 13)
}
```

**Correct:**

```go
func generateInviteCode(ctx context.Context, nk runtime.NakamaModule) (string, error) {
	const maxAttempts = 5 // in a large space, a collision is rare; a few tries suffice
	for i := 0; i < maxAttempts; i++ {
		code := randomCode(8) // larger space -> collision probability negligible

		_, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{{
			Collection:      "invite_codes",
			Key:             code,
			Value:           "{}",
			Version:         "*", // succeed only if the key does not already exist
			PermissionRead:  2,
			PermissionWrite: 0,
		}})
		if err == nil {
			return code, nil // claimed atomically, zero reads
		}
		if !isVersionConflict(err) {
			return "", err // a real error, not a collision; stop, don't retry
		}
		// genuine (rare) collision, try another code
	}
	return "", runtime.NewError("could not allocate invite code", 13)
}
```

`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.
{{< /accordion >}}

<a id="cap-loop-counts-driven-by-runtime-values"></a>

{{< accordion title="Cap loop counts driven by runtime values" >}}
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:**

```go
func awardLevelUps(ctx context.Context, nk runtime.NakamaModule, userID string, fromLevel, toLevel int) error {
	for lvl := fromLevel + 1; lvl <= toLevel; lvl++ {
		// N+1: one read per level, AND the loop bound is unguarded.
		objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
			Collection: "level_rewards", Key: fmt.Sprintf("level_%d", lvl),
		}})
		// ...
	}
	return nil
}
```

**Correct:**

```go
func awardLevelUps(ctx context.Context, nk runtime.NakamaModule, userID string, fromLevel, toLevel int) error {
	const maxLevelsPerCall = 50

	jump := toLevel - fromLevel
	if jump <= 0 {
		return nil
	}
	if jump > maxLevelsPerCall {
		// A jump this large is almost certainly a bug or exploit.
		return runtime.NewError(
			fmt.Sprintf("level jump %d exceeds max %d", jump, maxLevelsPerCall), 3)
	}

	// Batch: one read for every level reward.
	reads := make([]*runtime.StorageRead, 0, jump)
	for lvl := fromLevel + 1; lvl <= toLevel; lvl++ {
		reads = append(reads, &runtime.StorageRead{
			Collection: "level_rewards", Key: fmt.Sprintf("level_%d", lvl),
		})
	}
	objs, err := nk.StorageRead(ctx, reads)
	if err != nil {
		return err
	}

	// Accumulate all grants in memory, then apply once.
	var combined Reward
	for _, o := range objs {
		combined = combined.Add(parseReward([]*api.StorageObject{o}))
	}
	return grantAll(ctx, nk, userID, combined)
}
```

The guard is cheap and explicit. Batch the per-iteration work so that even a legitimate large-but-bounded count stays within budget.
{{< /accordion >}}

## Storage design

<a id="use-the-storage-engine"></a>

{{< accordion title="Use the storage engine" >}}
Nakama's [Storage Engine](../../../concepts/storage/) (collection/key/user objects with `StorageRead`, `StorageWrite`, [`StorageList`](../../go-runtime/function-reference#StorageList), [storage indexes](../../../concepts/storage/search/), [wallets](../../../concepts/user-accounts/#virtual-wallet), [leaderboards](../../../concepts/leaderboards/), and [friends](../../../concepts/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:**

```go
// Hand-written SQL through the db handle, bypassing the Storage Engine.
rows, err := db.QueryContext(ctx, `
    SELECT user_id, value FROM my_scores WHERE season = $1 ORDER BY points DESC LIMIT 100`, season)
```

```go
// Custom DDL. Creates a table outside the Storage Engine's managed schema.
_, err := db.ExecContext(ctx, `
    CREATE TABLE IF NOT EXISTS my_scores (
        user_id UUID, season INT, points BIGINT
    )`)
```

**Prefer:**

```go
// Player and game state -> storage objects (collection/key/userId).
objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
    Collection: "scores", Key: fmt.Sprintf("season_%d", season), UserID: userID,
}})

// Ranked data -> leaderboards, not a custom ORDER BY table.
records, _, _, _, err := nk.LeaderboardRecordsList(ctx, leaderboardID, nil, 100, "", 0)

// Currency -> wallets. Lookups over object contents -> a storage index.
```

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.
{{< /accordion >}}

<a id="cap-and-rotate-growing-storage-objects"></a>

{{< accordion title="Cap and rotate growing storage objects" >}}
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:**

```go
func onMatchEnd(ctx context.Context, nk runtime.NakamaModule, userID string, summary MatchSummary) error {
	// ...
	profile.MatchHistory = append(profile.MatchHistory, summary) // grows forever
	// serializes the entire history every time
	value, _ := json.Marshal(profile)
	// ...
}
```

**Correct:**

```go
const recentMatchesKept = 20

func onMatchEnd(ctx context.Context, nk runtime.NakamaModule, userID string, summary MatchSummary) error {
	// ...
	// Ring-buffer: append, then keep only the newest N.
	profile.RecentMatches = append(profile.RecentMatches, summary)
	if len(profile.RecentMatches) > recentMatchesKept {
		profile.RecentMatches = profile.RecentMatches[len(profile.RecentMatches)-recentMatchesKept:]
	}

	profileVal, _ := json.Marshal(profile)
	historyVal, _ := json.Marshal(summary)

	// One batched write: the bounded profile plus one per-entry history object.
	_, err = nk.StorageWrite(ctx, []*runtime.StorageWrite{
		{Collection: "player", Key: "profile", UserID: userID,
			Value: string(profileVal), Version: version},
		{Collection: "match_history", Key: summary.MatchID, UserID: userID,
			Value: string(historyVal)},
	})
	return err
}

// Old entries are read on demand with pagination, never all at once.
func listMatchHistoryPage(ctx context.Context, nk runtime.NakamaModule, userID, cursor string) ([]*api.StorageObject, string, error) {
	return nk.StorageList(ctx, "", userID, "match_history", 50, cursor)
}
```

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.
{{< /accordion >}}

<a id="route-authoritative-reads-to-storage-not-the-index"></a>

{{< accordion title="Route authoritative reads to storage, not the index" >}}
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:**

```go
// Lists "all players" from a storage index. Once the player base grows
// past maxEntries, the oldest entries are evicted and silently absent.
func listAllPlayers(ctx context.Context, nk runtime.NakamaModule) ([]*api.StorageObject, error) {
	var all []*api.StorageObject
	cursor := ""
	for {
		objs, nextCursor, err := nk.StorageIndexList(ctx, "", "players_index", "*", 100, nil, cursor)
		// ...
		// paginated the WHOLE index, but the index only ever held ~maxEntries
	}
	return all, nil
}
```

**Prefer:**

```go
// For a full-population read, page the actual storage objects.
// StorageList reflects the real collection with no cap.
func listAllPlayers(ctx context.Context, nk runtime.NakamaModule) ([]*api.StorageObject, error) {
	var all []*api.StorageObject
	cursor := ""
	for {
		objs, next, err := nk.StorageList(ctx, "", "", "players", 100, cursor)
		if err != nil {
			return nil, err
		}
		all = append(all, objs...)
		if next == "" {
			break
		}
		cursor = next
	}
	return all, nil // complete: every object in the collection
}
```

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.
{{< /accordion >}}

<a id="keep-one-object-shape-per-storage-index-scope"></a>

{{< accordion title="Keep one object shape per storage index scope" >}}
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:**

```go
// An index extracts "mmr" from player_rating/rating, but the shape written
// there depends on game mode. "casual" objects lack "mmr" entirely and are
// silently absent from the index.
func SavePlayerRating(ctx context.Context, nk runtime.NakamaModule, userId string, gameMode string, rating int) error {
	var value []byte
	switch gameMode {
	case "ranked":
		value, _ = json.Marshal(map[string]interface{}{"mmr": rating, "wins": 10, "losses": 3})
	case "casual":
		value, _ = json.Marshal(map[string]interface{}{"elo": rating, "gamesPlayed": 42}) // no "mmr"
	}
	// All shapes land in the same indexed scope.
	_, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{{
		Collection: "player_rating",
		Key:        "rating",
		UserID:     userId,
		Value:      string(value),
	}})
	return err
}
```

**Correct:**

```go
// Each shape gets its own collection. The collection name is the type
// discriminator. An index on rating_ranked always finds "mmr".
type RankedRating struct {
	MMR    int `json:"mmr"`
	Wins   int `json:"wins"`
	Losses int `json:"losses"`
}

type CasualRating struct {
	Elo         int `json:"elo"`
	GamesPlayed int `json:"gamesPlayed"`
}

func SaveRankedRating(ctx context.Context, nk runtime.NakamaModule, userId string, r RankedRating) error {
	value, err := json.Marshal(r)
	if err != nil {
		return err
	}
	_, err = nk.StorageWrite(ctx, []*runtime.StorageWrite{{
		Collection: "rating_ranked", // collection name is the type discriminator
		Key:        "profile",
		UserID:     userId,
		Value:      string(value),
	}})
	return err
}
```

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.
{{< /accordion >}}

## Match handlers

<a id="keep-the-match-loop-free-of-blocking-io"></a>

{{< accordion title="Keep the match loop free of blocking I/O" >}}
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:**

```go
func (m *Match) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} {
	s := state.(*MatchState)
	for _, msg := range messages {
		// One storage read PER MESSAGE, inside the tick.
		objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
			Collection: "player", Key: "loadout", UserID: msg.GetUserId(),
		}})
		// ...
		applyAction(s, msg, parseLoadout(objs))
	}
	// One storage write PER TICK, whether anything changed or not.
	value, _ := json.Marshal(s)
	_, _ = nk.StorageWrite(ctx, []*runtime.StorageWrite{{
		Collection: "matches", Key: s.MatchID, Value: string(value),
	}})
	return s
}
```

**Correct:**

```go
// Load each joining player's data once, into the in-memory match state.
func (m *Match) MatchJoin(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
	s := state.(*MatchState)
	reads := make([]*runtime.StorageRead, 0, len(presences))
	for _, p := range presences {
		reads = append(reads, &runtime.StorageRead{
			Collection: "player", Key: "loadout", UserID: p.GetUserId(),
		})
	}
	objs, err := nk.StorageRead(ctx, reads) // one batched read per join event
	if err != nil {
		logger.Error("loadout read failed: %v", err)
		return s
	}
	for _, o := range objs {
		s.Loadouts[o.UserId] = parseLoadout([]*api.StorageObject{o})
	}
	return s
}

// The loop is pure computation on in-memory state. No I/O.
func (m *Match) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} {
	s := state.(*MatchState)
	for _, msg := range messages {
		applyAction(s, msg, s.Loadouts[msg.GetUserId()])
	}
	broadcastState(dispatcher, s)
	return s
}

// Results are persisted once, when the match ends.
func (m *Match) MatchTerminate(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int) interface{} {
	s := state.(*MatchState)
	if err := persistResults(ctx, nk, s); err != nil { // one batched write
		logger.Error("failed to persist match %s: %v", s.MatchID, err)
	}
	return s
}
```

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.
{{< /accordion >}}

## Hooks

<a id="keep-hot-path-hooks-near-zero-cost"></a>

{{< accordion title="Keep hot-path hooks near zero-cost" >}}
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:**

```go
// Runs on EVERY device login. Three serial round trips plus an external
// HTTP call, all before the client gets its session back.
func afterAuthenticateDevice(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, out *api.Session, in *api.AuthenticateDeviceRequest) error {
	userID, _ := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)

	profileObjs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
		Collection: "player", Key: "profile", UserID: userID,
	}})
	// ...
	settingsObjs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{ // serial read #2
		Collection: "player", Key: "settings", UserID: userID,
	}})
	// ...
	// Synchronous external call: login now waits on a third party.
	http.Post(analyticsURL, "application/json", loginEventBody(userID))
	return nil
}
```

**Correct:**

```go
// One round trip on the login path; the side effect is detached and bounded.
func afterAuthenticateDevice(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, out *api.Session, in *api.AuthenticateDeviceRequest) error {
	userID, _ := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)

	// Batch the reads the hook genuinely needs: one call, not two.
	objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{
		{Collection: "player", Key: "profile", UserID: userID},
		{Collection: "player", Key: "settings", UserID: userID},
	})
	if err != nil {
		return err
	}
	updateLoginStreakBatched(objs)

	// Non-critical side effect: off the request path, with its own context.
	go func() {
		asyncCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := postLoginEvent(asyncCtx, userID); err != nil {
			logger.Warn("login analytics event failed for %s: %v", userID, err)
		}
	}()

	return nil
}
```

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.
{{< /accordion >}}

## Background work

<a id="send-analytics-through-the-background-event-queue"></a>

{{< accordion title="Send analytics through the background event queue" >}}
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:**

```go
func rpcClaimReward(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// ...
	if _, _, err := nk.WalletUpdate(ctx, userID, changeset, nil, true); err != nil {
		return "", err
	}

	// Analytics call on the request path. Player waits on the vendor.
	// If the vendor is down: reward granted, RPC fails, client retries, double grant.
	resp, err := http.Post("https://analytics.example.com/track", "application/json", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	return `{"granted":true}`, nil
}
```

**Correct:**

```go
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
	// Processor runs on the runtime event queue's worker pool, not on any
	// player's request. It gets its own ctx.
	if err := initializer.RegisterEvent(func(ctx context.Context, logger runtime.Logger, evt *api.Event) {
		body, _ := json.Marshal(map[string]string{
			"event":   evt.Name,
			"user_id": evt.Properties["user_id"],
		})
		resp, err := http.Post("https://analytics.example.com/track", "application/json", bytes.NewReader(body))
		if err != nil {
			logger.Warn("analytics delivery failed: %v", err) // logged, never fails a player
			return
		}
		resp.Body.Close()
	}); err != nil {
		return err
	}
	return initializer.RegisterRpc("claim_reward", rpcClaimRewardGood)
}

func rpcClaimRewardGood(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// ...
	if _, _, err := nk.WalletUpdate(ctx, userID, changeset, nil, true); err != nil {
		return "", err
	}

	// Enqueue and return. No third-party round trip on the player's path.
	if err := nk.Event(ctx, &api.Event{
		Name:       "reward_claimed",
		Properties: map[string]string{"user_id": userID},
	}); err != nil {
		logger.Warn("event enqueue failed: %v", err) // telemetry loss, not a player error
	}
	return `{"granted":true}`, nil
}
```

Nakama's [event system](../../../concepts/events/) 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.
{{< /accordion >}}

<a id="use-a-background-context-for-deferred-work"></a>

{{< accordion title="Use a background context for deferred work" >}}
(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:**

```go
// The callback closes over ctx, which is cancelled once this hook returns.
func onMatchmakerMatched(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, entries []runtime.MatchmakerEntry) (string, error) {
	placeAsync(entries, func(result PlacementResult) {
		// Fires AFTER onMatchmakerMatched has returned. ctx is already done.
		_, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{{ // silently no-ops
			Collection: "matches", Key: result.MatchID, Value: result.JSON(),
		}})
		if err != nil {
			logger.Error("write failed: %v", err)
		}
	})
	return "", nil
}
```

**Correct:**

```go
// Give deferred work its own context and lifetime, and surface failures.
func onMatchmakerMatched(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, entries []runtime.MatchmakerEntry) (string, error) {
	placeAsync(entries, func(result PlacementResult) {
		// Independent context: not tied to the finished request.
		asyncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

		if _, err := nk.StorageWrite(asyncCtx, []*runtime.StorageWrite{{
			Collection: "matches", Key: result.MatchID, Value: result.JSON(),
		}}); err != nil {
			// Async work has no caller to return to, so route the error somewhere durable.
			logger.Error("placement write failed for match %s: %v", result.MatchID, err)
			enqueueForRetry(result)
		}
	})
	return "", nil
}
```

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.
{{< /accordion >}}

## Caching

<a id="cache-static-data-in-memory-at-init"></a>

{{< accordion title="Cache static data in memory at init" >}}
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`](../../go-runtime/#develop-code) into a package-level structure and read it directly in RPCs, hooks, and match handlers.

**Incorrect:**

```go
func RpcGetItemInfo(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Disk read + full JSON parse per request, for content fixed at deploy time.
	data, err := os.ReadFile("data/item_definitions.json")
	if err != nil {
		return "", err
	}
	var items map[string]ItemDef
	if err := json.Unmarshal(data, &items); err != nil {
		return "", err
	}
	item, ok := items[payload]
	// ...
}
```

**Correct:**

```go
var itemDefs map[string]ItemDef // loaded once, read-only afterwards

func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
	data, err := os.ReadFile("data/item_definitions.json")
	if err != nil {
		return err // fail fast at startup, not per request
	}
	if err := json.Unmarshal(data, &itemDefs); err != nil {
		return err
	}
	// ...
}

func RpcGetItemInfo(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	item, ok := itemDefs[payload]
	// ...
}
```

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.
{{< /accordion >}}

<a id="use-session-variables-for-session-stable-data"></a>

{{< accordion title="Use session variables for session-stable data" >}}
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](../../../concepts/session/#session-variables) are key/value string pairs embedded in the session token at authentication, readable in any RPC or hook from the [runtime context](../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:**

```go
func rpcGetBase(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	userID, _ := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)

	// One DB round trip per call, in every RPC, to learn whether this
	// player's data has been migrated to the new schema. The answer was
	// settled at login and can't change mid-session.
	objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{{
		Collection: "meta", Key: "schema_version", UserID: userID,
	}})
	// ...
	return baseFor(migrated), nil
}
```

**Correct (server-side, in a before-authentication hook):**

```go
func beforeAuthenticateDevice(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, in *api.AuthenticateDeviceRequest) (*api.AuthenticateDeviceRequest, error) {
	migrated := migratedToV2(ctx, nk, in) // one lookup, once per session
	if in.Account.Vars == nil {
		in.Account.Vars = map[string]string{}
	}
	in.Account.Vars["schema_v2"] = strconv.FormatBool(migrated)
	return in, nil
}

func rpcGetBase(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Zero round trips: the value arrives inside the session token, so
	// every RPC branches on the migration state without a lookup.
	vars, _ := ctx.Value(runtime.RUNTIME_CTX_VARS).(map[string]string)
	return baseFor(vars["schema_v2"] == "true"), nil
}
```

**Correct (client-side, at authentication):**

```csharp
// The client injects its version at authentication. Every server RPC and
// hook for the rest of the session can read it without asking the client.
var vars = new Dictionary<string, string> { { "client_version", Application.version } };
var session = await client.AuthenticateDeviceAsync(deviceId, vars: vars);
```

```go
// Any server handler reads the client-supplied var from the context,
// for example a startup RPC that rejects sessions from outdated builds.
vars, _ := ctx.Value(runtime.RUNTIME_CTX_VARS).(map[string]string)
if !versionSupported(vars["client_version"]) {
	return "", errUpgradeRequired
}
```

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.
{{< /accordion >}}

## Module structure

<a id="prefer-a-flat-package-in-plugin-modules"></a>

{{< accordion title="Prefer a flat package in plugin modules" >}}
(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:**

```go
// internal/common/types.go
package common

// Grant lives here only to break an import cycle between economy and rewards.
// "common" grows with every future cycle.
type Grant struct {
	Coins int64
	Item  string
}
```

```go
// internal/economy/economy.go
package economy

import "myplugin/internal/common"

// Both functions must be exported so sibling packages can call them.
func Credit(userID string, coins int64) error { /* ... */ }
func Refund(userID string, g common.Grant) error { /* ... */ }
```

```go
// internal/rewards/rewards.go
package rewards

import (
	"myplugin/internal/common"
	"myplugin/internal/economy"
)

// GrantTable is capitalized only because internal/shop reads it.
// Renaming a Grant field now edits three packages.
var GrantTable = map[string]common.Grant{
	"daily": {Coins: 100, Item: "chest_basic"},
}
```

**Prefer:**

```go
// rpc_rewards.go, economy.go, hooks_auth.go: all package main.
package main

// No import ceremony, no cycle possible, nothing forced public.
var grantTable = map[string]grant{ /* ... */ }

func grantReward(ctx context.Context, nk runtime.NakamaModule, userID string, g grant) error {
	return creditWallet(ctx, nk, userID, g.coins)
}
```

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.
{{< /accordion >}}


---

## 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`](../../go-runtime/function-reference#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`](../../go-runtime/function-reference/match-handler#MatchLoop) or per-message handling; state loaded in [`MatchJoin`](../../go-runtime/function-reference/match-handler#MatchJoin), persisted in [`MatchTerminate`](../../go-runtime/function-reference/match-handler#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.