How to check whether a failed data store write was saved

GetAsync can return a cached value for four seconds, so a check after a failed write can be wrong. How to read past the cache before refunding.

warningLuaudit check: cached-verification-reads

What it looks like

A data store write errors, and the code reads the key back with GetAsync to decide what happened: refund the player, retry, or give the item anyway. The read uses the default cache, so it can return a value from before the write.

Luau
local ok = pcall(store.UpdateAsync, store, key, transform)
if not ok then
	local current = store:GetAsync(key)
	...
end

How to detect it

  • Find code that runs after a failed UpdateAsync, SetAsync, IncrementAsync or RemoveAsync, usually in the pcall's failure branch, and reads the same key.
  • Roblox's data store caching guide says GetAsync keeps values in a local cache for four seconds and returns the cached value instead of asking the backend. It says verification reads after a write, especially one that errored, should pass DataStoreGetOptions with UseCache set to false, because a normal read can return stale data.
  • A write that errors may still have been saved. Check that the code decides refunds and retries from the backend's answer, not from a cached copy.
  • Look at what each wrong answer costs. A stale read that says "not saved" leads to a refund or a second grant for something that was saved. One that says "saved" loses what the player paid for.
  • The cache is per data store instance. Reads through a different instance, such as one opened with AllScopes, can disagree with the instance that wrote.

What an exploiter could do

They make writes fail on purpose, for example by buying or trading while the server is busy or the store is throttled, so they're refunded for things that were also saved, or get items twice. Honest players see the opposite: purchases that vanish or charges that come back.

How to fix it

  • After a failed write, read the key with a DataStoreGetOptions whose UseCache is false, and decide from that value.
  • Better, make the write itself carry an id, such as a purchase or trade id, so a later read can tell for certain whether this write landed.
  • If the read also fails, don't refund or grant yet. Retry later, or keep the player's request pending.

Vulnerable example

Vulnerable
local DataStoreService = game:GetService("DataStoreService")

local market = DataStoreService:GetDataStore("Market")

local function buyListing(listingId: string, buyerId: number): boolean
	local ok = pcall(market.UpdateAsync, market, listingId, function(listing: any)
		if typeof(listing) ~= "table" or listing.SoldTo ~= nil then
			return nil
		end
		listing.SoldTo = buyerId
		return listing
	end)
	if ok then
		local listing = market:GetAsync(listingId)
		return typeof(listing) == "table" and listing.SoldTo == buyerId
	end
	local found, listing = pcall(market.GetAsync, market, listingId)
	return found and typeof(listing) == "table" and listing.SoldTo == buyerId
end

return buyListing

Fixed example

Fixed
local DataStoreService = game:GetService("DataStoreService")

local market = DataStoreService:GetDataStore("Market")
local fresh = Instance.new("DataStoreGetOptions")
fresh.UseCache = false

local function buyListing(listingId: string, buyerId: number): boolean?
	local sold = false
	local ok = pcall(market.UpdateAsync, market, listingId, function(listing: any)
		sold = false
		if typeof(listing) ~= "table" or listing.SoldTo ~= nil then
			return nil
		end
		listing.SoldTo = buyerId
		sold = true
		return listing
	end)
	if ok then
		return sold
	end
	local found, listing = pcall(market.GetAsync, market, listingId, fresh)
	if not found then
		return nil
	end
	return typeof(listing) == "table" and listing.SoldTo == buyerId
end

return buyListing

A nil result means the outcome is unknown, so the caller keeps the buyer's coins held and tries again later instead of refunding.

Common false positives

  • Reads with UseCache set to false, like the fixed example, are the fix.
  • Reads that only show a value to the player, with nothing granted or refunded from them, can use the cache.
  • Code that refunds on an error without reading the key back makes no cached read, so it isn't this finding. Mention as advice that the write may have been saved.
  • UpdateAsync callbacks see the current stored value, not the cache, so decisions made inside them are fine.