Your gamepass vanishes when the Roblox API hiccups. Here is the 40-line fix
MarketplaceService:UserOwnsGamePassAsync fails quietly under load and returns false. The player who paid loses the perk and files a ticket. The fix is cache + pcall + retry, and it fits in one module.
AI-assisted draft, reviewed, tested and edited by a human before publishing. See our Editorial Policy. · Reviewed by Gustavo Cantino, 9/2/26 Editorial Policy
You sell a $5 gamepass, a player buys it, and twenty minutes later they come back saying they lost the item. You test on your account and it works. They send a screenshot. You assume they're lying.
They're not. What happened is that MarketplaceService:UserOwnsGamePassAsync failed, and your code treated the failure as "doesn't own it".
The problem
UserOwnsGamePassAsync is a network call. It can:
- hang (up to 30 seconds under load)
- throw (throttling, a 500 from Roblox's backend)
- return
falsebecause the player genuinely doesn't own it
The code almost everyone writes cannot tell the second case from the third:
-- WRONG: an error becomes "no gamepass"
if MarketplaceService:UserOwnsGamePassAsync(player.UserId, PASS_ID) then
grantPerk(player)
endWhen the call throws, the whole line aborts, or worse, a sloppy pcall turns it into false. The paying player gets nothing.
Under normal load this hits 0.5% to 2% of calls. In a game with 5,000 daily players and 8% conversion, that's 2 to 8 paying customers a day receiving nothing. They don't file tickets — they leave.
The fix, step by step
Three layers, each solving a different case:
- pcall — separates "it errored" from "they don't own it"
- retry with backoff — most failures disappear on the second attempt
- per-session cache — Roblox doesn't change pass ownership mid-session (a purchase fires
PromptGamePassPurchaseFinished, which you handle separately), so checking more than once per player is waste and it's what gets you throttled
The point almost every tutorial gets wrong: on a definitive error, return nil, not false. The caller needs to be able to decide whether to grant or deny when in doubt. For a cosmetic pass, granting on doubt costs nothing and saves the customer.
Common mistake
The most expensive mistake here is caching the false. If the player buys the pass during the session and you stored "doesn't own", they pay and receive nothing until they rejoin — and now you have an angry paying customer, which is worse than a lost player.
The module below caches only positive results and confirmed negatives; errors never enter the cache.
The second common mistake is retrying in a tight loop. Three attempts with growing waits (1s, 2s, 4s) fix almost everything; ten attempts back to back get you throttled and turn one failure into five.
What to do next
With the module in place, wire up a simple counter: how many times a day ownsPass returned nil. If it goes past 2% of calls, the problem isn't your code — it's volume, and the answer is checking less often, not retrying more.
After that, read up on PromptGamePassPurchaseFinished to invalidate the cache at purchase time, which is the only moment ownership changes within a session.
Ready-to-use artifact
--!strict
-- GamePassCache — gamepass ownership that survives API failure.
--
-- Returns:
-- true = owns the pass (confirmed)
-- false = does NOT own the pass (confirmed)
-- nil = could not determine (API failed all 3 attempts)
--
-- The caller decides what to do with nil. For a cosmetic pass, granting on
-- doubt costs less than losing the customer.
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local GamePassCache = {}
local ATTEMPTS = 3
local BASE_WAIT = 1 -- seconds; doubles each attempt
-- cache[userId][passId] = true | false
-- Errors NEVER go in here: caching a failure turns a hiccup into a permanent
-- session bug.
local cache: { [number]: { [number]: boolean } } = {}
local function store(userId: number, passId: number, value: boolean)
if not cache[userId] then
cache[userId] = {}
end
cache[userId][passId] = value
end
function GamePassCache.ownsPass(userId: number, passId: number): boolean?
local forUser = cache[userId]
if forUser ~= nil and forUser[passId] ~= nil then
return forUser[passId]
end
local wait = BASE_WAIT
for attempt = 1, ATTEMPTS do
local ok, result = pcall(function()
return MarketplaceService:UserOwnsGamePassAsync(userId, passId)
end)
if ok then
-- only here do we actually know
store(userId, passId, result)
return result
end
warn(string.format(
"[GamePassCache] attempt %d/%d failed for user=%d pass=%d: %s",
attempt, ATTEMPTS, userId, passId, tostring(result)
))
if attempt < ATTEMPTS then
task.wait(wait)
wait *= 2 -- 1s, 2s, 4s
end
end
-- gave up: nil, and NOT cached
return nil
end
-- Call this from PromptGamePassPurchaseFinished. It is the only moment
-- ownership changes mid-session.
function GamePassCache.invalidate(userId: number, passId: number)
local forUser = cache[userId]
if forUser then
forUser[passId] = nil
end
end
MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player, passId, bought)
if bought then
store(player.UserId, passId, true)
end
end)
Players.PlayerRemoving:Connect(function(player)
cache[player.UserId] = nil
end)
return GamePassCache