How to Build a Scalable Game Backend: Leaderboards & Cloud Saves

Anonymous players who upgrade to real accounts, all-time and weekly leaderboards with tie-aware ranks, versioned cloud saves, and a score submission that says what it protects against. Seeded to a million rows and timed from outside.

A leaderboard card with ten tied first places at 9999 points out of a million seeded scores, a My rank card reading number 52511 with 7780 points, and a rejection line reading CEILING_EXCEEDED, score exceeds the ceiling for level 1

An indie developer’s backend question is rarely “which engine”. It is whether a general backend-as-a-service can hold player accounts, a leaderboard that stays fast as the score table grows, and cloud saves with a version check, without a server to run.

This post builds exactly that on Parse Server at Back4app, then fills the leaderboard with a million scores and times it from the outside.

The feasibility run was on October 20, 2026, and the build, the seed and the measurements below on October 23, against the live platform. The numbers come from those runs; the files behind them are in the repository.

Try it, deploy it, read it: the demo client runs at gamebackendstarter-nqa56pyp.b4a.run: play as a guest, submit a score, try the two cheat buttons, see your rank among a million. The repository is github.com/templates-back4app/game-backend-starter, MIT.

What the backend holds, and who can touch it

Five classes, each with one reason to exist, and a rule for who writes it: nobody but Cloud Code. The client signs in, calls functions, and reads the leaderboard class directly because it is public.

ClassOne row perWho readsWho writes
Playeruserowner onlyCloud Code
Scoreaccepted submission, append-onlyowner onlyCloud Code
Bestplayer and leveleveryoneCloud Code
Saveplayer and slot (0 to 2)owner onlyCloud Code
RejectedScorerejected submissionmaster key onlyCloud Code
FunctionSigned inDoes
ensurePlayeryescreates the profile on first call, updates display name and country later
submitScoreyesvalidates, logs rejections, appends a Score, upserts Best
leaderboardnotop-N from Best, all-time or weekly, ranks with ties shared
myRankyes1 + count of Best rows with a greater score
putSaveyesoptimistic write; answers STALE_SAVE with the current version when behind
getSaveyesversion and blob; version 0 and no blob for an empty slot

Writes are refused twice. The class-level permissions deny every client write, and a beforeSave hook on each class refuses any request that does not carry the master key, so a permission widened by mistake changes nothing. The live tests send those writes with the keys a client holds; all answered code 119.

The demo client's Account and Scores sections: a guest named acme-tester, the Play a round, Submit an impossible score and Replay buttons, a rejection line reading CEILING_EXCEEDED, and the top 10 with ten tied first places

The main path in five calls

Before the tables, the path a game client takes, with the Parse JS SDK. Each line is one request; everything else in this post is what happens around them.

await Parse.AnonymousUtils.logIn();                       // 1. a guest account, no form
await Parse.Cloud.run('ensurePlayer', { displayName: 'acme-tester' });

const r = await Parse.Cloud.run('submitScore', {          // 2. a score, bounded by the server
  level: 1, score: 7780, sessionCounter: 1, clientNonce: crypto.randomUUID(),
});                                                       //    → { accepted, best, weekScore, replay }

const top = await Parse.Cloud.run('leaderboard', { level: 1, limit: 10 });   // 3. public, no session needed
const me  = await Parse.Cloud.run('myRank', { level: 1 });                   // 4. 1 + count(score > mine)

const slot = await Parse.Cloud.run('getSave', { slot: 0 });                  // 5. { version: 0, blob: null } when empty
await Parse.Cloud.run('putSave', { slot: 0, baseVersion: slot.version, blob: JSON.stringify(state) });
// a stale baseVersion answers STALE_SAVE with the current version instead of overwriting

Errors come back as a Parse error whose message is a JSON envelope with a code such as CEILING_EXCEEDED or STALE_SAVE; a ten-line helper in the client unwraps it.

Players: anonymous first, upgraded later

A player starts as an anonymous Parse user, created by the first call with a random id the client keeps. That is enough to submit scores and save games. When the player wants to keep the progress, the client sets a username and password on the same user; the Player, Best and Save rows point at the same id and stay attached. A test does exactly that and checks all three.

One SDK detail decides whether this works: the JS SDK’s signUp() on an anonymous user sends authData.anonymous = null with the credentials, which is what turns the guest into an account rather than creating a second one.

The raw REST test sends the same body, and the social login post covers what happens to sessions and linked methods from there.

Score submission: what it protects against, precisely

The server never saw the gameplay, so it cannot know that 7,780 points were earned. What submitScore can do is make the obvious abuses expensive or impossible, and say which ones:

CheckRejectsLogged as
per-level ceiling, default 10,000 × levelimpossible scoresCEILING_EXCEEDED
a session counter that must increase per player, consumed even by rejected submissionsreplays and out-of-order sessionsCOUNTER_NOT_MONOTONIC
ten submissions per 60 s per player, accepted or rejectedfloodsRATE_LIMITED
a client nonce per submissiona retried request counts once and gets the original answernot a rejection
sign-in required, writes only through the functiondirect inserts, forged rowsParse 119
This bounds abuse. It does not make scores server-authoritative, because the server never saw the game. A client that lies plausibly still gets on the board.

Real validation needs a replay the server can re-run or a deterministic simulation, and that is a different article. What this one gives you is a RejectedScore row for every refusal, so at least you can see who is probing. The rate limit is per player, not per device or IP: a cheater with many anonymous accounts has many budgets, and the README says what to pair it with.

The Scores section after the cheat button: a red line reading Rejected CEILING_EXCEEDED, score exceeds the ceiling for level 1, above the top 10 and a My rank card reading number 52511 with 7780

Leaderboards: one row per player and level, two numbers per row

Score is history and is never scanned for a board. Best holds one row per player and level with the all-time best, the ISO week of the latest submission and the best within that week. Every accepted submission upserts it. A top-N is one indexed query on Best; a rank is one indexed count.

Three decisions follow, and each has a test:

  • Weekly boards reset without a job. The weekly board filters on the current week key; a row nobody touched this week does not match. Last week’s board survives only for players who did not play since, because there is no history table. If you need archives, that table is the one addition.
  • Ties share a rank. Rank is 1 + the number of strictly greater scores, in both functions, checked against a brute-force ranking of 500 seeded rows. Display order among equals is by the Best row’s createdAt, which is when the player first entered that level’s board, not when they reached their current score. The precise version is a bestAt timestamp written whenever the score improves, indexed in place of createdAt; the template keeps the simpler rule and says so.
  • Rank is a count, never a skip. The feasibility run on October 20 measured skip=500000 on the sorted board at 2.77 s, and a sort with a tie-break on an unindexed field failed outright with HTTP 500, MongoDB’s in-memory sort limit. Every field a board filters or sorts by is in an index, and the section on the million rows below says what it took to make that true.

The demo client's leaderboard section with the all-time top 10, all tied at 9999, next to My rank reading number 51814 with 7780, and the Cloud save slot below

A million scores, seeded with the REST key

Seeding a million rows needs the master key, unless the work happens inside Cloud Code. A seedBest function, guarded by a SETUP_ENABLED switch, takes 50 rows per call and inserts them with master rights; the script sends 20,000 such calls through a token bucket at 16 requests per second, because the Free plan’s throttle refuses faster clients.

That throttle is the first thing to know about load on this platform: it comes back as HTTP 400 with Parse code 155, not as 429, so a retry policy keyed on 429 never sees it. The seeder inspects the body and backs off.

In this run the million rows took 2,864 s, 47.7 minutes, for exactly 20,000 requests with zero failed calls. 150 rows were skipped by their seed key after a throttled request was retried, which is the dedup path doing its job.

Seeded rows have no _User behind them, deliberately: creating a million users from a client key is neither possible nor desirable. The leaderboard marks them seeded: true, and a real player’s rank counts them like anyone else.

Latency at 1,000,000 rows, measured three times

npm run bench signs in as a guest, submits the one score the seed assigned to that player, then runs 20 warm-ups and 100 timed calls per operation from one machine outside the US. The first run was bad, and the two fixes it forced are the most useful thing in this post.

Operation, level 1, 1,000,000 rows, p50 / p95 round tripRun 1: schema indexes onlyRun 2: plus the _created_at indexesRun 3: plus the count hint
top-100 all-time4,560 / 5,237 ms428 / 520 ms468 / 518 ms
top-100 this week4,617 / 5,322 ms426 / 516 ms426 / 510 ms
my rank all-time, player at rank 545,2932,446 / 3,043 ms2,355 / 2,984 ms2,365 / 2,685 ms
my rank this week, same player2,456 / 3,068 ms2,359 / 2,764 ms2,363 / 2,592 ms

The three runs are 52 minutes apart, 100 timed calls each after 20 warm-ups, zero errors; the CSVs are named by run in docs/evidence/.

The route between that client and the platform was not constant that afternoon: identical calls answered in about 0.4 s in one ten-minute window and about 1.3 s in the next. The absolute values belong to the day.

The differences between the columns, four seconds on the top-100 and none on the rank, are an order of magnitude larger than that drift, which is the only reason to read anything into them.

One more caveat sits next to these numbers. A million rows is a data-volume test: one client, one call at a time, after warm-ups. It says what a query costs at that table size. It says nothing about many players submitting at once, and the 500-player burst in the plan has not been run yet.

Fix one: the index key names. Parse.Schema creates MongoDB indexes with the field names you pass, untranslated. An index on createdAt indexes a key no document has, because MongoDB stores that column as _created_at.

So the schema’s {level, score, createdAt} index served order by score but not the tie-break order by score, createdAt, which fell back to sorting a million rows in memory: 6.6 s for the raw query against 1.3 s without the tie-break.

The dashboard’s Index Manager does translate the name. Two indexes created there, on _created_at, took about three minutes to build on a million rows and brought the top-100 from 4.56 s to 428 ms, round trip included.

Fix two was only half a fix. myRank is count where level = 1 and score > mine. Raw against the REST API, the MongoDB 3.6 planner ran that count in about 3 s whether 298 rows or 300,000 matched, and a hint on the schema’s index brought it back to an index range: 1.2 s round trip at 298 matches.

The function now passes that hint; the PostgreSQL adapter ignores it, so the local tests do not change.

But the third column shows the hint did not move the function’s number for a player at rank 545,293, and a fourth measurement says why. Four throw-away players at four positions, ten timed calls each, in the same minute the top-100 took about 1.3 s:

Player’s score and rankmyRank p50
9,998, rank 361,205 ms
9,000, rank 34,6711,313 ms
5,000, rank 206,1942,032 ms
100, rank 783,9564,124 ms

The count is linear in the number of rows above the player, about 3.7 µs per row on this engine. For the top of the board that is free; for the bottom of a million-row board it is four seconds. The design consequence is in the next section.

Why the planner matters here is visible in MongoDB’s own explain, which Parse Server returns for a find through the REST API. For level = 1 and score > 9990 without a hint, the winning plan scans the weekly index with the week unbounded and filters score after fetching each document: 1,000,013 keys examined, 1,000,013 documents examined, 299 returned, 2,508 ms.

With hint=best_alltime the index bounds are on score itself. The plans are in docs/evidence/; a count cannot be explained through the REST API, so the count figures above remain measurements without a plan.

What to do about a linear rank count

Show exact rank where it is cheap and approximate it where it is not. The top of the board is the cheap part: a player at rank 36 pays nothing beyond the round trip. For everyone else, three options cost less than a four-second count, and the repository points at where each one goes.

  • Cache it. Store the rank on the Best row when a score is accepted and refresh it on a schedule; the count runs once per player per interval instead of once per screen.
  • Bucket it. Keep a small table of score thresholds per percentile, rebuilt by a job, and answer “top 12%” from it in one indexed lookup.
  • Estimate it. Keep a small table of score thresholds (every 1% of the board, rebuilt by a job), find the bucket the player’s score falls in with one lookup, and count exactly only inside that bucket’s window. Bucket start plus window count is a global rank with a bounded error, labelled “about”, which is what most games show anyway. A window count alone, without the bucket, is not a rank.

None of these is in the template, on purpose: the count as written is correct, and the measurement above tells you when it stops being affordable.

Cloud saves, versioned

A save is a JSON string of at most 500 KB in one of three slots. putSave takes the version the client loaded; if the server’s version is higher, it answers STALE_SAVE with the current version, and nothing is overwritten. getSave returns version 0 and no blob for an empty slot.

The round trip, the stale rejection, the slot and size validation and the owner-only read are each a test, locally and live.

Errors carry a code the client can act on. Parse errors are a number and a string, so the string is a JSON envelope: {"code":"STALE_SAVE","currentVersion":3} inside a Parse 142, or UNAUTHENTICATED inside a 209 so SDK clients drop a dead session. A small helper in the client and in the scripts unwraps it.

Security tests, with the client’s keys

A client holds the Application ID and a client key, both public by design. The tests send what a hostile client would send with exactly those, and the live backend answered:

Attempt, with the Application ID and a client keyAnswer from the live backend
any player function without a session400, code 209, UNAUTHENTICATED
leaderboard without a session200, it is public
POST /classes/Score with a session119
PUT /classes/Best on your own row119
read another player’s Save or Playernot found; own rows readable, counters protected
GET /classes/RejectedScore with a session119
POST /classes/Cheat, a class that does not exist201 on the Free plan

The last row is a platform setting, not an app bug, and it is worth its own paragraph.

What building it found

  • The first Cloud Code deploy of a fresh backend shipped config.js but not the edited main.js. The editor’s unsaved buffer is not part of a deploy; a second edit and Deploy shipped it. The proof is a request: setupSchema answered 141 until then, then the full schema report.
  • config.js only uploads with the folder selected. The dashboard’s upload lands in the selected explorer node; with nothing selected the file is silently dropped.
  • Index directions survive on MongoDB and not on the local PostgreSQL adapter, which keeps column order and drops the -1. Harmless on PostgreSQL, where a btree is read both ways. The key names are the real trap, above: nothing in the local run could have shown it, because the PostgreSQL adapter maps the column and the MongoDB one does not.
  • Master-key sign-ups still create sessions. A seed that creates users with the master key also creates one _Session row each. The REST-key seed avoids the question by creating no users at all.
  • The first call after an idle period takes about 4.5 s. Cloud Code on the Free plan cold-starts; the same leaderboard call answered in 1.1 s on the next request. Warm-ups are part of the bench for that reason.
  • A plan change redeploys the container, and an upload-sourced app cannot redeploy itself. The demo client was created with Containers’ Upload files source; upgrading it to Starter triggered a redeploy that failed with This deployment has no uploaded code while the old container kept serving. Action → Redeploy latest code rebuilt it from the code the Source Code page still held.
  • Cloud Code runs on Node 19.9. Nothing here needs newer syntax, and the cloud folder is CommonJS by a package.json of its own, because the root package is ESM for the scripts.

What it would cost, as a request estimate

This is an estimate derived from measured usage and sourced prices, not a bill. One demo session as the client plays it makes about 11 requests: sign-in, ensurePlayer, a leaderboard, a rank, five submissions, a save load and a save write. At 1,000 such sessions a day that is roughly 330,000 requests a month.

Back4app’s pricing page, read on October 23, 2026, lists the Free plan at 25K requests a month, MVP at US$15 per app per month billed annually (US$25 billed monthly) with 500K requests and US$5 per extra 100K, and Pay as you go at US$80 billed annually (US$100 monthly) with 5M requests and US$2 per extra 100K.

Storage is the other limit, and on the Free plan it is the first one a seeded leaderboard hits. After the million Best rows, the dashboard’s Plan Usage page read 329 MB of database storage against the 250 MB included: about 260 MB of documents plus 66 MB of indexes, roughly 330 bytes per row all in. The seed also spent 20,000 of the 25K monthly requests.

The MVP plan’s 1 GB holds about three million such rows. Score, which keeps one row per accepted submission, and RejectedScore grow on top of that and are the tables to watch.

Deploying it

  1. Backend. Create an app in the Back4app dashboard and copy the Application ID, JavaScript key and REST key into .env. The master key stays in the dashboard; nothing here needs it.

  2. Cloud Code. In the Cloud Code page select the cloud folder, upload cloud/config.js, paste cloud/main.js into the editor, confirm Files pending deploy (2), Deploy, and verify with a request as above.

  3. Schema. npm run setup calls setupSchema with the REST key: five classes, fields, permissions and indexes in one call. It refuses a bare re-run once Best has rows, unless you ask.

  4. Indexes. Database → Index Manager → Best: add best_alltime_ca (level asc, score desc, createdAt asc) and best_weekly_ca (level asc, week asc, weekScore desc, createdAt asc). The Fields column must show _created_at; the schema-created ones with createdAt stay, the counts use them.

  5. Tests, seed, bench. TEST_SERVER_URL=https://parseapi.back4app.com npm test on a staging app, then npm run seed -- --n 1000000 --levels 1 and npm run bench -- --level 1 --label back4app if you want your region’s numbers.

  6. Close the setup door. SETUP_ENABLED: false in cloud/config.js, upload, Deploy, and confirm that setupSchema answers 119. While it is true, anyone with the Application ID can seed rows and read or delete any row.

  7. Web client. Containers → Deploy a web app from the repository, Node.js Buildpack, port 8080, health check /healthz, and three variables: APP_ID, JS_KEY, SERVER_URL.

When not to do it this way

If your game needs the server to decide the outcome, run the simulation, or match players in real time, you need a game server, and this backend becomes its database and account store rather than the thing players talk to. If “one person, one account” must hold across devices and reinstalls, anonymous-first needs an upgrade prompt early, because a guest who uninstalls is gone.

And note what this post did not run: 500 simulated players submitting within a ten-second window, and save round trips at 50 KB and 500 KB, both in the plan and both still ahead. When those runs happen, this paragraph gets replaced by their results.

The repository

github.com/templates-back4app/game-backend-starter, MIT. cloud/main.js holds the six functions, the setup helpers and the hooks; cloud/config.js the tunables and the schema; scripts/ setup, seed and bench; public/ the demo client.

test/ has 19 tests that run locally against parse-server with PostgreSQL and, with one variable, against the live backend.

docs/notes/build-diary.md records the decisions and the live run, and docs/evidence/ the bench CSVs and the seed log behind every number here.

Tagsgame-backendleaderboardcloud-saveparsecloud-codemongodbanonymous-usersindexesrate-limitbenchmark

Frequently asked questions

Does the server validate that a score is real?

No, and the post says so on the first screen. The server never saw the gameplay. What it does is bound abuse: a per-level ceiling, a session counter that must increase, ten submissions per minute per player, and a nonce so a retried request counts once. Every rejection is logged. A modified client can still submit a plausible invented score; real validation needs a replay or a server-side simulation, which is out of scope.

How fast is a top-100 query at a million scores?

With the schema's own indexes the top-100 took 4.56 s at p50, because the tie-break key was misnamed; with the right index, created in the dashboard, 428 ms round trip from a client outside the US. The rank count is different: it grows with the rows above the player, 1.2 s at rank 36 and 4.1 s at rank 784,000 on the Free plan's MongoDB 3.6, and the post says how to cache, bucket or bound it.

Why is rank a count and not a position in a sorted list?

Because rank = 1 + the number of scores strictly greater than yours is one indexed count, while 'skip to my row' is a deep pagination that took 2.77 seconds at a skip of 500,000 in our feasibility run. Ties share a rank by this definition, and the leaderboard assigns ranks the same way, which a test checks against a brute-force ranking of 500 rows.

How do weekly leaderboards reset without a scheduled job?

Each best row carries the ISO week of its most recent submission and the best score within that week. The weekly board filters on the current week key, so a row nobody touched this week simply does not match. Nothing to schedule, nothing to forget. The trade-off is that last week's board is not archived; the post says where a history table would go.

What happens when two devices save the same slot?

A save carries the version the device loaded. If the server's version is higher, it answers STALE_SAVE with the current version instead of overwriting, and the client decides. That protects against a stale device; two devices sending the same version in the same instant are a different case, and the post says it was not tested. A blob is a JSON string of at most 500 KB, three slots per player.

Can a client create new classes on the Free plan?

Yes, and it cannot be switched off there. Client Class Creation lives under App Settings → Advanced Options and is fixed at Yes on the Free plan; paid plans can set it to No. The template's own classes are protected regardless by permissions and hooks, which the live tests confirm, but a client with the Application ID can still create a class of its own on a Free app.

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Oct 23, 2026 — Backend: Back4app, Parse Server 7.5.2 (MongoDB), Cloud Code on Node 19.9, Free plan · Client for the measurements: one machine outside the US, Node 25, REST key only · Web client: plain JavaScript with the Parse JS SDK, Express on Back4app Containers · Local check: parse-server 7.5.4 on PostgreSQL 17.

Changelog

  • Oct 27, 2026 — Revision after editorial review: a five-call example up front; the million-row benchmark labelled as a data-volume test, not a concurrency test; tie-break rule described precisely; MongoDB explain output added behind the count finding; the approximate-rank recipe corrected (bucket plus window); the storage claim corrected with the measured 329 MB against the Free plan's 250 MB.
  • Oct 27, 2026 — Retitled from 'Game Backend on a BaaS: Player Accounts, a Leaderboard at 1,000,000 Scores and Cloud Saves on Parse Server'; URL changed from /blog/game-backend-accounts-leaderboards-cloud-saves, which now redirects here.

More from Back4app Engineering

Backend · tutorial

Your Frontend Talks Straight to the Database. Here Is How to Lock It Down [Full Repo]

Backend · tutorial

How to Find the Nearest Driver in an Uber-Style App [Full Repo]

Backend · error-guide

Why Does the API Return 101, 119, 142 or 209? Every Backend Error Code, Reproduced [Full Repo]