Lua Scripting

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.

by Gustavo Cantino2 min read

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 false because 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)
end

When 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:

  1. pcall — separates "it errored" from "they don't own it"
  2. retry with backoff — most failures disappear on the second attempt
  3. 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

GamePassCache.lua — ModuleScript in ServerScriptService
lua
--!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