Appearance
State and Data
Written: 2026-10-14
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.
- 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
Samples use --!strict annotations throughout. Each annotation is explained where it first appears, and Strict Typing 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 ItemCatalogOne 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 guide documents each loss.
The 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 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 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 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 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 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.
Cap table length and cap each field. Re-check catalog membership on the server. The 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.
ModuleScript in ReplicatedStorage named GameTuning
luau
--!strict
local GameTuning = {
buyCooldown = 2,
maxBuyCount = 5,
shopRadius = 12,
}
table.freeze(GameTuning)
return GameTuningThree 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 and API docs. You keep it server-only under ServerPackages, matching the server realm in its wally manifest, 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 and its docs site 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 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
- Convert a position-based catalog to an id-keyed dictionary.
- Cache a DataStore read with pcall and cleanup on leave.
- Replace a Get-plus-Set grant with one UpdateAsync call.
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 FixedCatalogScript 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.
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.
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 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 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
endLeaderboards come from ordered 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 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 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.