Roblox Matchmaking: MemoryStore Queues + Reserved Servers
Build Roblox matchmaking with MemoryStore and TeleportAsync reserved servers. Covers cross-server handoff, queue ownership, MMR, retries and recovery.
For a session-based Roblox experience, the native stack is already strong: MemoryStore holds short-lived queue or assignment state, MessagingService can wake other live servers, and TeleportService sends the matched players into a public or reserved destination.
The difficult part is not calling TeleportAsync(). It is designing ownership so that two lobby servers do not match the same player, a failed teleport does not lose the ticket, and players distributed across different lobby servers still converge on the same reserved server.
Native-first rule: keep ephemeral queue and teleport coordination inside Roblox unless you have a concrete cross-experience or external-operations reason not to. Durable MMR, progression, rewards and support/audit data are different concerns.
The native matchmaking flow
Lobby server A ─┐
├─► MemoryStore queue / rating buckets ─► match coordinator
Lobby server B ─┘ │
├─ reserve destination
├─ write shared assignment
└─ notify source lobbies
│
each source lobby teleports
only its local Player objects
│
▼
same reserved server
| Primitive | Use it for | Do not use it for |
|---|---|---|
| MemoryStoreQueue | Short-lived FIFO/priority tickets and work claiming | Permanent MMR, inventory or match history |
| MemoryStoreSortedMap | Ordered rating/time indexes when range selection matters | Large durable player profiles |
| MemoryStoreHashMap | Assignment state such as ticket → match/reservation | Long-term progression |
| MessagingService | Best-effort cross-server wake-up/notification | The only copy of an assignment |
| TeleportService | Server-side movement into the chosen destination | Client-trusted matchmaking decisions |
1. Put small, expiring tickets in MemoryStore
Roblox describes MemoryStore as high-throughput, low-latency shared memory for rapidly changing data, and explicitly lists skill-based matchmaking as a use case. The queue documentation also gives you the key processing primitive: a read makes items temporarily invisible, and they become visible again if you fail to remove them before the invisibility timeout.
That gives you an at-least-once work pattern. Make the ticket itself idempotent:
local MemoryStoreService = game:GetService("MemoryStoreService")
local HttpService = game:GetService("HttpService")
local queue = MemoryStoreService:GetQueue("ranked-eu-v1", 30)
local function enqueuePlayer(player, mmr)
local ticket = {
ticketId = HttpService:GenerateGUID(false),
userId = player.UserId,
sourceJobId = game.JobId,
mmr = mmr,
queuedAt = os.time(),
}
-- Short TTL: expiration is a safety net, not long-term storage.
queue:AddAsync(ticket, 120, 0)
return ticket.ticketId
end
Do not put an entire player profile into the queue. Keep tickets compact and write persistent rating/progression to DataStore or your external persistent backend.
2. Queue vs sorted map for skill matching
A single queue is excellent when your rule is “oldest compatible party first” or when you have coarse buckets such as bronze-eu-duo. Priority queues can also influence processing order.
If your rule is “find players within ±100 MMR, then widen the window every ten seconds,” a sorted map can be easier because rating/time are naturally ordered. Another simple option is multiple queues by region/mode/rating band and periodically expand which neighboring bands a worker reads.
The important thing is to make the matching rule explicit. A priority value alone is not a complete skill-based matcher.
3. Claim work before you create the destination
MemoryStoreQueue:ReadAsync() returns the items plus an identifier used by RemoveAsync(). During the queue's invisibility timeout, another worker should not see the same claimed items. If your worker crashes before removal, the items eventually become visible again.
local function tryReadMatch(queue, teamSize)
local items, readId = queue:ReadAsync(teamSize * 2, true, 2)
if #items ~= teamSize * 2 then
return nil
end
return {
tickets = items,
readId = readId,
}
end
For production, validate that every ticket is still eligible: the user has not cancelled, the ticket is not already assigned, party membership is unchanged, and your mode/rating rules still match. Assume a queue item can be delivered more than once over its lifetime.
4. Reserve one destination for the whole match
You have two current TeleportService shapes for a fresh reserved destination:
- set
TeleportOptions.ShouldReserveServer = truewhen callingTeleportAsync(); or - call
ReserveServerAsync(placeId), keep its access code, then use that code throughTeleportOptions.ReservedServerAccessCode.
The second form is useful when multiple source lobby servers need to send their local players to the same destination, because they can all receive the same reserved-server access code.
local TeleportService = game:GetService("TeleportService")
local accessCode, privateServerId = TeleportService:ReserveServerAsync(MATCH_PLACE_ID)
local assignment = {
matchId = matchId,
placeId = MATCH_PLACE_ID,
accessCode = accessCode,
privateServerId = privateServerId,
expiresAt = os.time() + 120,
}
Roblox documents that the access code remains valid indefinitely, that a server starts when the code is first used, and that PrivateServerId is stable across server instances created from that access code while JobId is not. Treat the access code as sensitive session-routing material even though its platform lifetime is long.
5. The cross-server handoff is the part most tutorials skip
A matchmaking worker can read tickets from every server in the experience, but a Roblox Player instance exists only in the game server that currently owns that player. If Alice is in lobby A and Bob is in lobby B, lobby A cannot pass Bob's remote Player instance to TeleportAsync().
Use shared assignment state plus a wake-up signal:
- write
ticketId → assignment(oruserId → assignment) into a short-lived MemoryStore hash map; - publish a small MessagingService notification containing only the ticket/user identifier;
- each source lobby receives the signal, or discovers the assignment by polling, and looks up the shared assignment;
- that source lobby calls
TeleportAsync()only for the local players it owns.
Do not make MessagingService the database. Roblox documents delivery as best effort and not guaranteed. The message should mean “check shared state now,” not carry the only copy of the match assignment.
6. Teleport from server code and retry the failures that can recover
Roblox's current teleport guide says TeleportAsync() should be called from server scripts for secure teleports, wrapped in pcall(), and paired with TeleportInitFailed because a teleport can fail even after the initial call succeeds.
local TeleportService = game:GetService("TeleportService")
local function teleportToAssignment(players, assignment)
local options = Instance.new("TeleportOptions")
options.ReservedServerAccessCode = assignment.accessCode
options:SetTeleportData({
matchId = assignment.matchId, -- routing hint only
})
local ok, result = pcall(function()
return TeleportService:TeleportAsync(assignment.placeId, players, options)
end)
if not ok then
warn("TeleportAsync failed", result)
end
return ok, result
end
TeleportService.TeleportInitFailed:Connect(function(player, result, message, placeId, options)
-- Retry only according to your policy/result type.
-- The event supplies options targeting the same destination.
warn("Teleport failed for", player.UserId, result, message)
end)
Roblox also warns that teleport data is visible to the client and unencrypted. Put a match identifier or non-secret routing hint there, not currency, inventory truth, server credentials or an authorization decision.
7. Do not delete matchmaking state too early
The dangerous sequence is:
read queue → remove queue item → attempt teleport → teleport fails → player is lost
Instead, create the assignment first, make the handoff retryable, and only retire the queue claim when your state machine can recover the player from the assignment. A useful lifecycle is:
QUEUED → CLAIMED → ASSIGNED → TELEPORTING → ARRIVED
│ │
└── timeout ───┴──► requeue / cancel
The exact acknowledgement mechanism is yours: the destination can mark each expected user as arrived in MemoryStore, or your persistent backend can record session admission. The key property is that “RemoveAsync succeeded” is not treated as proof that a player reached the destination.
8. Current MemoryStore limits change the sharding decision
As of September 2026, Roblox documents MemoryStore quotas at the experience level, including a request-unit budget of 1000 + 120 × concurrent users per minute and memory that grows with concurrent users. A single queue or sorted map is also a single-partition structure and has its own size/item limits.
That does not mean “one queue is bad.” It means you should shard when your workload requires it, commonly by mode/region/rating band, and watch the Memory Store observability dashboard instead of inventing a fixed global queue count in advance.
9. Reserved-server details worth designing around
- Studio limitation: reserved-server teleport behavior must be tested in a published experience / Roblox app, not ordinary Studio playtesting.
- Stable private identity:
PrivateServerIdis associated with the reservation;JobIdchanges across server instances. - Cross-play caveat: Roblox documents that console players with cross-play disabled can arrive in a different server despite the same
PrivateServerId;DataModel.MatchmakingTypecan distinguish those instances. - TeleportOptions are mutually exclusive: do not combine
ReservedServerAccessCode,ShouldReserveServerandServerInstanceIdin incompatible combinations.
10. Where a persistent backend actually helps
None of the native machinery above requires Crux. Add a persistent backend only where the requirement is genuinely outside the ephemeral Roblox queue:
- durable MMR / rank history and season state;
- trusted post-match reward/progression writes;
- cross-experience identity or progression that you intentionally want outside Roblox's own persistence boundary;
- operator/support tools that run outside the live Roblox servers;
- auditable match/session history and live configuration shared with non-Roblox services.
Crux's Roblox server-token SDK can cover persistent documents, leaderboards, economy, server registry and live config from trusted server scripts. Its current player-token matchmaking queue is not a replacement for this native server-token MemoryStore flow, so do not ship a Crux server token to a LocalScript just to call that queue.
Implementation checklist
- Are tickets short-lived and idempotent?
- Can a player cancel without being matched from a stale queue item?
- Can two workers process the same logical ticket without creating two matches?
- Is the reservation/assignment stored outside best-effort MessagingService?
- Does each source lobby teleport only players local to that server?
- Do you handle both synchronous TeleportAsync errors and
TeleportInitFailed? - Can a failed handoff requeue/cancel instead of losing the player?
- Is secure state excluded from
TeleportData? - Have you tested reserved teleports in a published experience?
- Are MemoryStore request/memory quotas observable before launch?
Related reading
- Roblox cross-server messaging and server browser: cross-server state and notification patterns.
- Roblox + Crux quickstart: add persistent server-authoritative services without moving the native session queue.
- Matchmaker vs server browser: decide whether you need an automatic queue at all.
- Crux matchmaking boundary: current capabilities and limitations outside Roblox.
Sources
Technical, pricing, and product claims were checked against these primary sources on the verification date above.