---
url: /intermediate/state-and-data.md
description: >-
  Model data, respect serialization limits, persist with DataStores, and
  validate input shapes.
---

# State and Data

*Written: {{ $frontmatter.date }}*

## Learning objectives

* You can model rows by stable ids.
* You can respect remote and store serialization limits.
* You can wrap every DataStore call in pcall.
* You can cache reads and choose UpdateAsync for races.
* You can budget DataStore requests against the published [limits](https://create.roblox.com/docs/en-us/cloud-services/data-stores/error-codes-and-limits).
* You can rank integer values with ordered stores.
* You can validate client-provided shapes on the server.
* You can describe server-owned ProfileStore sessions conceptually.

## Prerequisites

* [Patterns and Boundaries](./patterns-and-boundaries.md)
* [Server Authority](../roblox-fundamentals/server-authority.md)
* [Remotes and Networking](../roblox-fundamentals/remotes-and-networking.md)

Samples use `--!strict` annotations throughout. Each annotation is explained where it first appears, and [Strict Typing](../strict-typing/modes.md) teaches the full system later.

## Model rows by id

Collections use stable ids as keys. Array positions shift on insert, sort, or remove, while ids survive those moves.

A player inventory maps item id to count, and a shop catalog maps item id to price. Look up and update by id.

**ModuleScript in ReplicatedStorage named ItemCatalog**

```luau
--!strict
export type CatalogEntry = {
	Price: number,
	MaxStack: number,
}

local ItemCatalog: { [string]: CatalogEntry } = {
	Apple = { Price = 5, MaxStack = 5 },
	Bread = { Price = 3, MaxStack = 10 },
}
table.freeze(ItemCatalog)
return ItemCatalog
```

One frozen dictionary holds the data. Reading `ItemCatalog["Apple"]` directly stays correct after new items arrive, with no search for position one or two. Expected behavior: id lookups stay correct after you add new items.

## Respect serialization limits

Remotes and DataStores carry data only. Functions arrive as nil. Metatables vanish. Tables arrive as copies, so identity checks fail. Non-string keys convert to strings. Instances outside the receiver view arrive as nil, like a ServerStorage child sent to a client. The [remote events](https://create.roblox.com/docs/en-us/scripting/events/remote) guide documents each loss.

The [data stores](https://create.roblox.com/docs/en-us/cloud-services/data-stores) guide covers plain tables, strings, and numbers only. Send ids and counts, not Instances or closures.

**LocalScript in StarterPlayerScripts**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket:FireServer("Apple", 2)
```

Two plain values go out. Both replicate cleanly. Expected behavior: the server receives a readable id and count it can validate.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, itemId: unknown, count: unknown): ()
	if typeof(itemId) ~= "string" then
		return
	end
	if typeof(count) ~= "number" then
		return
	end
	if not math.isfinite(count) then
		return
	end
	print(player.Name)
	print(itemId)
end)
```

Create a RemoteEvent named `BuyItem` in ReplicatedStorage. Both inputs narrow from `unknown`, and non-finite counts get rejected. Expected behavior: valid orders print the buyer and the id, while functions, Instances, and malformed tables never reach your logic as trusted values.

## Wrap every store call in pcall

DataStore calls cross the network and can fail. Wrap `GetAsync`, `SetAsync`, and `UpdateAsync` in `pcall` and branch on success. The [data stores](https://create.roblox.com/docs/en-us/cloud-services/data-stores) guide shows this pattern for every read and write.

Reads go through a small cache. The cache fills once per join and serves hot reads from memory. Writes go through to the store, then update the cache on success.

**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local coins = DataStoreService:GetDataStore("CoinsV1")
local cache: { [number]: number } = {}

local function readCoins(player: Player): number
	local cached = cache[player.UserId]
	if cached ~= nil then
		return cached
	end
	local done, saved = pcall(function()
		return coins:GetAsync("Coins" .. tostring(player.UserId))
	end)
	if not done then
		return 0
	end
	if typeof(saved) ~= "number" then
		return 0
	end
	if not math.isfinite(saved) then
		return 0
	end
	cache[player.UserId] = saved
	return saved
end

Players.PlayerAdded:Connect(function(player: Player): ()
	local owned = readCoins(player)
	print(owned)
end)

Players.PlayerRemoving:Connect(function(player: Player): ()
	cache[player.UserId] = nil
end)
```

Each service is fetched once, and the cache is keyed by UserId. The code guards the cached hit, the pcall result, and the saved shape, then clears the row on leave. Expected behavior: joins print the cached or freshly loaded coin count, while failed reads fall back to zero.

## Prefer UpdateAsync for races

`SetAsync` writes blindly. Two servers can read `10`, add `5`, and both write `15` when `20` was correct. `UpdateAsync` reads and writes atomically on the backend. It retries with the fresh value. The [data stores](https://create.roblox.com/docs/en-us/cloud-services/data-stores) guide recommends update for multi-server writes.

The update callback must not yield. It receives the current value and returns the next value. Keep it pure, with no waits, yielding calls, or remote calls inside it. `pcall` passes yields through from the function it wraps, so the ban covers yielding work inside the transform. Error handling around it stays fine. Wrap the outer `UpdateAsync` in `pcall` instead.

**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")

local coins = DataStoreService:GetDataStore("CoinsV1")

local function grant(userId: number, amount: number): boolean
	if not math.isfinite(amount) then
		return false
	end
	if amount < 1 or amount > 50 then
		return false
	end
	local done, _saved = pcall(function()
		return coins:UpdateAsync("Coins" .. tostring(userId), function(current: number?): number
			local owned = current or 0
			return owned + amount
		end)
	end)
	return done
end

print(grant(100, 5))
```

Bounds check before the call. A pure callback adds without yielding. The pcall flag returns. Expected behavior: Output prints `true` on success and the backend never loses a concurrent grant.

## Budget requests like a production system

Retries alone do not cover launch traffic. DataStores enforce request budgets, and over-budget calls queue, then drop with throttle errors. Design inside the numbers from the [limits](https://create.roblox.com/docs/en-us/cloud-services/data-stores/error-codes-and-limits) reference.

Each key holds at most 4,194,304 characters, and store names, key names, and scopes each stay under 50 characters. The experience-wide budget scales with concurrent players: Standard reads allow 300 plus 40 per player each minute, Standard writes allow 300 plus 20 per player each minute, and every `UpdateAsync` call spends from both budgets because it reads and writes. Ordered stores use the same read budget but a smaller write budget, so scope these numbers to the store kind you use. Each server also gets a smaller default slice for Standard stores, 60 plus 40 per player per minute for reads and writes, which you can tune with the rate-limit APIs. Per-key throughput caps reads at 25 MB per minute and writes at 4 MB per minute. Queues hold 30 requests each; past that, calls fail with throttle errors instead of waiting.

Three habits follow. Batch reads and cache rows so joins cost one read per player, not one per system. Autosave on a slow timer and save on leave instead of writing every coin pickup. Keep values small, because one 4 MB row burns a whole minute of per-key write throughput. The [data system](../projects/data-system.md) project applies all three.

## Rank integers with ordered stores

Standard stores never sort. When you need a leaderboard, you open an ordered store with `GetOrderedDataStore` instead of `GetDataStore` and store one integer per key. You read ranks with `GetSortedAsync`, which pages through keys in order, and you read several known keys at once with `BatchGetAsync`, where each requested key counts as one read. Writes use the same Set, Increment, and Update calls with the ordered budget. The [data stores](https://create.roblox.com/docs/en-us/cloud-services/data-stores) guide shows the paging loop and the batch shape. You never page a leaderboard by reading keys one by one in a loop.

## Validate client shapes

Treat every client table as `unknown`. Check permission first, then type, then structure, then value. Reject with a silent return. This order comes from [Server Authority](../roblox-fundamentals/server-authority.md).

Cap table length and cap each field. Re-check catalog membership on the server. The [server authority techniques](https://create.roblox.com/docs/en-us/projects/server-authority/techniques) guide keeps these checks server side.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ItemCatalog = require(ReplicatedStorage:WaitForChild("ItemCatalog"))

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, order: unknown): ()
	local character = player.Character
	if not character then
		return
	end
	if typeof(order) ~= "table" then
		return
	end
	local data = order :: { [string]: unknown }
	local itemId = data["Id"]
	local count = data["Count"]
	if typeof(itemId) ~= "string" then
		return
	end
	if typeof(count) ~= "number" then
		return
	end
	if not math.isfinite(count) then
		return
	end
	if count < 1 or count > 5 then
		return
	end
	local entry = ItemCatalog[itemId]
	if entry == nil then
		return
	end
	print(player.Name)
	print(entry.Price * count)
end)
```

Life checks first. The table narrows, then each field. The id must exist in the frozen catalog. Expected behavior: valid orders print the buyer and the total price, while spoofed ids and bad shapes return silently.

## Keep config in frozen tables

Tuning lives outside systems. Freeze it. Pass it through `start` args or require it from Shared. Never hardcode prices inside a Service.

Frozen config prevents accidental retunes. It also documents balance in one place. This split follows [Patterns and Boundaries](./patterns-and-boundaries.md).

**ModuleScript in ReplicatedStorage named GameTuning**

```luau
--!strict
local GameTuning = {
	buyCooldown = 2,
	maxBuyCount = 5,
	shopRadius = 12,
}
table.freeze(GameTuning)
return GameTuning
```

Three numbers grouped in one table. The table freezes on the return path. Expected behavior: every system reads the same cooldown, cap, and radius.

## Describe ProfileStore sessions conceptually

All persistence routes through one server module. Only that module touches the store. Other Services ask it through injected accessors. This single-owner rule prevents double writes.

ProfileStore is a community session package that wraps this pattern with session locks and reconcile hooks, per the [ProfileStore source](https://github.com/MadStudioRoblox/ProfileStore) and [API docs](https://madstudioroblox.github.io/ProfileStore/api/). You keep it server-only under ServerPackages, matching the server realm in its [wally manifest](https://raw.githubusercontent.com/MadStudioRoblox/ProfileStore/main/wally.toml), and you never require it from client or Shared code. Its exact function names and options change across releases, so you verify the current API in the [ProfileStore repository](https://github.com/MadStudioRoblox/ProfileStore) and its [docs site](https://madstudioroblox.github.io/ProfileStore/) before you call it.

Conceptually one session opens per player, reads go through the session while the player stays, and the session closes on leave. Wrap each network step in `pcall` with a retry plan. The [data store best practices](https://create.roblox.com/docs/en-us/cloud-services/data-stores/best-practices) guide covers retry and caching.

## Common mistakes

**Mistake 1: you trust array positions.**

Wrong example below. It buys whatever sits first.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, slot: number): ()
	local catalog = { "Apple", "Bread" }
	local picked = catalog[slot]
	print(player.Name)
	print(picked)
end)
```

It breaks when you reorder or insert. A client can also send slot `99` and read nil. Fix it by sending a stable id and by looking it up in a frozen dictionary.

**Mistake 2: you SetAsync over a race.**

Wrong example below. It reads then writes in two steps.

**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")

local coins = DataStoreService:GetDataStore("CoinsV1")

local function grantSlow(userId: number): ()
	local done, saved = pcall(function()
		return coins:GetAsync("Coins" .. tostring(userId))
	end)
	if not done then
		return
	end
	local owned = saved or 0
	if typeof(owned) ~= "number" then
		return
	end
	local ok, _wrote = pcall(function()
		return coins:SetAsync("Coins" .. tostring(userId), owned + 5)
	end)
	if ok then
		print("saved")
	end
end

grantSlow(100)
```

Two servers interleave between the read and the write. One grant vanishes. Fix it with one atomic `UpdateAsync` call whose callback adds without yielding.

**Mistake 3: you trust a client table.**

Wrong example below. It multiplies whatever arrives.

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local basket = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
basket.OnServerEvent:Connect(function(player: Player, order: any): ()
	print(player.Name)
	print(order.Price * order.Count)
end)
```

It errors on missing fields and it trusts client prices. Fix it by narrowing from `unknown`, by capping counts, and by pricing from your frozen catalog.

## Exercises

1. Convert a position-based catalog to an id-keyed dictionary.
2. Cache a DataStore read with pcall and cleanup on leave.
3. Replace a Get-plus-Set grant with one UpdateAsync call.

::: details Solution 1
**ModuleScript in ReplicatedStorage named FixedCatalog**

```luau
--!strict
local FixedCatalog = {
	Apple = { Price = 5, MaxStack = 5 },
	Bread = { Price = 3, MaxStack = 10 },
}
table.freeze(FixedCatalog)
return FixedCatalog
```

**Script in ServerScriptService**

```luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local FixedCatalog = require(ReplicatedStorage:WaitForChild("FixedCatalog"))
local entry = FixedCatalog["Bread"]
if entry == nil then
	return
end
print(entry.Price)
```

Expected behavior: Output prints `3` and new items never shift old ids.
:::

::: details Solution 2
**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local gems = DataStoreService:GetDataStore("GemsV1")
local cache: { [number]: number } = {}

Players.PlayerAdded:Connect(function(player: Player): ()
	local done, saved = pcall(function()
		return gems:GetAsync("Gems" .. tostring(player.UserId))
	end)
	if not done then
		return
	end
	if typeof(saved) ~= "number" then
		return
	end
	cache[player.UserId] = saved
	print(cache[player.UserId])
end)

Players.PlayerRemoving:Connect(function(player: Player): ()
	cache[player.UserId] = nil
end)
```

Expected behavior: joins print the saved gem count once, while leaves free the row.
:::

::: details Solution 3
**Script in ServerScriptService**

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")

local coins = DataStoreService:GetDataStore("CoinsV1")
local done, _saved = pcall(function()
	return coins:UpdateAsync("Coins100", function(current: number?): number
		local owned = current or 0
		return owned + 5
	end)
end)
print(done)
```

Expected behavior: Output prints `true` on success and concurrent grants no longer overwrite each other.
:::

## Cross-server events with MessagingService

DataStores persist state; [MessagingService](https://create.roblox.com/docs/reference/engine/classes/MessagingService) notifies other live servers about it. Publish with `PublishAsync(topic, message)` and listen with `SubscribeAsync(topic, callback)`. Delivery is best-effort within 1 to 2 seconds and not guaranteed. Design so a dropped message is never critical. Topics are 1 to 80 characters, messages cap at 1KB, and per-minute send/receive/subscription budgets scale with players and servers.

```luau
--!strict
local MessagingService = game:GetService("MessagingService")

local connection = MessagingService:SubscribeAsync("ShutdownNotice", function(message)
	print(message.Data)
	print(message.Sent)
end)
local ok, err = pcall(function()
	MessagingService:PublishAsync("ShutdownNotice", "restart in 60s")
end)
if not ok then
	print(err)
end
connection:Disconnect()
```

Use it for global shutdowns, live-ops flags, and cross-server matchmaking signals. Pair it with MemoryStore for ephemeral coordination state and DataStores for durable state.

## Budgets, retries, shutdown saves, and leaderboards

Every store call spends budget. [Data store limits](https://create.roblox.com/docs/cloud-services/data-stores/error-codes-and-limits) throttle per experience and per server: standard reads scale at 300 + players × 40 and writes at 300 + players × 20 requests per minute, `UpdateAsync` spends from both budgets, and throttled calls queue (30 deep) before failing with 301 to 306 errors. Per-key throughput caps at 25 MB/min reads and 4 MB/min writes, single values cap at ~4 MB, and names/keys/scopes cap at 50 characters. Wrap everything in `pcall`, retry throttles with exponential backoff, and never `SetAsync` from two servers on one key, use idempotent `UpdateAsync` transforms instead.

```luau
--!strict
local DataStoreService = game:GetService("DataStoreService")

local store = DataStoreService:GetDataStore("WalletV1")
local function saveWithRetry(key: string, value: number): boolean
	local tries = 0
	while tries < 3 do
		tries += 1
		local done, _ = pcall(function(): ()
			store:SetAsync(key, value)
		end)
		if done then
			return true
		end
		task.wait(tries)
	end
	return false
end
```

Leaderboards come from [ordered data stores](https://create.roblox.com/docs/cloud-services/data-stores): `GetOrderedDataStore` plus `GetSortedAsync(ascending, pageSize)` pages of up to 100 entries, walked with `GetCurrentPage`, `IsFinished`, and `AdvanceToNextPageAsync`. Ordered stores support no versioning or metadata. Cache the rendered board and refresh on an interval, never page per viewer.

[Memory stores](https://create.roblox.com/docs/cloud-services/memory-stores) cover fast-changing shared state: `GetHashMap` for keyed data, `GetSortedMap` for rankings and auctions, `GetQueue` for matchmaking. Quotas scale with users (memory 64 KB + 1.2 KB × users; requests 1000 + 120 × users per minute), items expire by TTL, and hot partitions throttle near ~30,000 units/min, shard hot keys, cache reads locally, and back off exponentially. Items cap at 32 KB.

Shutdowns must not eat progress. [BindToClose](https://create.roblox.com/docs/reference/engine/classes/DataModel) runs your save before the server dies, with 30 seconds for bound functions and parallel execution. Guard with `RunService:IsStudio()` so Studio test closes do not write production data, and combine it with a session-lock token so two servers never own one player's data at once.

```luau
--!strict
local RunService = game:GetService("RunService")

game:BindToClose(function(closeReason: Enum.CloseReason): ()
	if RunService:IsStudio() then
		return
	end
	print(closeReason)
	task.wait(1)
end)
```

## Summary

Key data by id. Send data, not behavior. Wrap every store call in `pcall` and retry throttles with backoff. Respect per-minute budgets and per-key throughput caps. Page leaderboards from ordered stores and cache them. Coordinate live servers with MemoryStore quotas and MessagingService topics. Save on BindToClose with a Studio guard and session locks. Next, schedule work in [Async and Concurrency](./async-and-concurrency.md).
