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.
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.
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,IncrementAsyncorRemoveAsync, usually in thepcall's failure branch, and reads the same key. - Roblox's data store caching guide says
GetAsynckeeps 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 passDataStoreGetOptionswithUseCacheset tofalse, 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
DataStoreGetOptionswhoseUseCacheisfalse, 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
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
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
UseCacheset tofalse, 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.
UpdateAsynccallbacks see the current stored value, not the cache, so decisions made inside them are fine.