Why purchases and bans don't belong in memory stores

Memory store entries expire and writes fail at the quota. What to keep in data stores instead, and when memory stores are the right tool.

warningLuaudit check: memory-store-only-saves

What it looks like

Something a player earned or paid for, such as a purchase, a title, a ban, a streak or a market listing's item, is written only to MemoryStoreService. Nothing copies it to a data store.

Luau
titles:SetAsync(tostring(player.UserId), title, 30 * 24 * 60 * 60)

How to detect it

  • Find every memory store write: MemoryStoreHashMap:SetAsync and UpdateAsync, MemoryStoreSortedMap:SetAsync and UpdateAsync, and MemoryStoreQueue:AddAsync.
  • For each one, ask whether losing the value would cost the player something they earned or paid for, or let a punished player off.
  • Roblox's memory store overview says they suit frequent, short-lived data that doesn't need to be durable, and that data vanishes when it reaches its maximum lifetime. Its best practices say not to use them for persistent player records or data that must survive expiration.
  • Every write takes an expiration time, and the overview gives 45 days as the default for queues and sorted maps. An entry with a long expiration still disappears.
  • The overview also says writes that increase memory use fail once the game reaches its memory quota. Check what the code does when a write fails. If the player has already paid, the purchase is lost.
  • A memory store used as a cache in front of a data store is fine, as long as the data store is the source of truth and is written first.

What an exploiter could do

Nobody has to do anything. Players lose purchases and progress when entries expire or writes fail at the quota, and bans held only in a memory store lapse on their own. A player who wants to escape a ban or a debt only has to wait.

How to fix it

  • Save lasting data in a data store, usually in the player's profile, and treat any memory store copy as a cache.
  • Use memory stores for things that can be rebuilt or are meant to end: matchmaking queues, live auctions, server lists, short cooldowns.
  • When an item moves through a memory store, such as a market listing, keep the authoritative record of who owns it in a data store.

Vulnerable example

Vulnerable
local MemoryStoreService = game:GetService("MemoryStoreService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local titles = MemoryStoreService:GetHashMap("Titles")
local titlePrice = 1000
local keep = 30 * 24 * 60 * 60

ReplicatedStorage.Remotes.BuyTitle.OnServerEvent:Connect(function(player: Player)
	local profile = PlayerData.get(player)
	if not profile or profile.Coins < titlePrice then
		return
	end
	profile.Coins -= titlePrice
	pcall(titles.SetAsync, titles, tostring(player.UserId), "Champion", keep)
end)

Fixed example

Fixed
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local titlePrice = 1000

ReplicatedStorage.Remotes.BuyTitle.OnServerEvent:Connect(function(player: Player)
	local profile = PlayerData.get(player)
	if not profile or profile.Coins < titlePrice or profile.Claimed.ChampionTitle then
		return
	end
	profile.Coins -= titlePrice
	profile.Claimed.ChampionTitle = true
end)

Common false positives

  • Data meant to expire, such as a weekly event leaderboard, a temporary boost or a matchmaking ticket, belongs in a memory store.
  • A memory store hash map used as a cache, where the code reads from a data store when the entry is missing, is fine.
  • If the value is lost through a race rather than expiry, report Memory store values changed with a read then a write instead.