Migrating terabytes of player data for a live game across cloud providers

Andy profile photo
Andy
November 4, 2026

How do you move terabytes of live player data to a new cloud provider without taking millions of players offline for more than a few minutes?

As it turns out, the tools to do this are all built into Postgres. The hard part is knowing their trade-offs well enough to choose the right ones. Our infrastructure team recently did exactly this for Loom Games, one of our most successful studios and the team behind Pixel Flow. This is the story of how we pulled it off.

The short version: We used Postgres logical replication, split across parallel streams so it could keep up with the game. Then we paused traffic at the old load balancer for a few minutes, let the streams catch up, and forwarded every player to the new site at once.

The challenge

Pixel Flow launched in August 2025 and became one of the fastest-scaling mobile games in recent memory. More than 10 million people have played it, and over a million log in every day. To put that into perspective, Pixel Flow passed $100 million in revenue within ten months of launch. In August, it was named Game of the Year at the Pocket Gamer Mobile Games Awards.

All of that success rests on a database holding the progress of millions of players. When Loom came to us about moving it to a new cloud provider, they had a few conditions:

  • There had to be minimal disruption to players - think minutes, not hours.
  • They needed to be able to try out the new database before switching anyone over.
Pixel Flow gameplay

Moving a database is usually a routine job, but as you’ve seen, Pixel Flow is no ordinary game.

Normally, a game would put up a maintenance screen, copy its database, and then switch over within the hour. Pixel Flow needed to move a few terabytes of player data, far too much to copy in the few minutes Loom could afford to be offline. So the copy had to happen while the game was running.

During peak times, Pixel Flow’s backend handles over 6,000 requests per second. Nearly every one of those requests is reading or writing player data, like level completion, purchases, and leaderboard scores. Each write has to be repeated on the new database, on top of the terabytes already being copied. And like most cloud databases, the new one keeps its data on network-attached storage, a separate service reached over the network rather than a drive inside the server. Every disk access is a network round trip, roughly an order of magnitude slower than a local SSD.

The true challenge, then, was copying terabytes of data that changes thousands of times a second, onto storage where every write takes a trip across the network.

What is replication?
Replication means keeping a copy of a database on another machine, updated continuously as the original changes. It’s how a database survives a server failure. If the main copy goes down, a replica takes over. It’s also how you move a database without switching it off. You replicate to the new location, then switch over.

Different ways to move bits

Copying a database is a bit like copying a book. You can photocopy every page, or you can read it aloud and have someone write it down. The photocopy is perfect, but you only get an exact copy and nothing else. There’s more flexibility when you copy by dictation, and the writer can lay out the pages however they like. Postgres has a built-in version of each, neither version is superior. Choosing between the two depends on what you need to do with the copy.

Physical replication

Physical replication is the photocopy method. The result is a byte-for-byte copy of the disk. It’s what we rely on day to day for failover, where a database’s replica keeps a mirror of its disk, ready to take over the moment the primary fails.

Under the hood, Postgres isn’t actually copying over literal bytes. Rather, it reuses a mechanism originally built for surviving crashes. Every change is first recorded in the write-ahead log (WAL), so that after a crash, Postgres can replay the log on top of its last known state and end up exactly where it left off. Physical replication ships that same log to a second machine, which replays it continuously. A physical replica even reports itself as “in recovery”, because that’s what it’s doing internally. It’s “recovering” from a crash, except the source keeps extending the log.

Animation of physical replication: changed pages from the source's table and two indexes travel as WAL records down a single stream, then land on the same page of the same file on a read-only replica.
One log carries the changes for every file, including the table and each of its indexes. Each change lands on the same page on the replica.

The log’s records are storage-level instructions, such as “write this row at this position on this page” or “split this index page”. They’re expressed in terms of files and pages, one level below the tables and indexes you’d work with in SQL. Replay them in order on an identical starting copy, and you get an identical result. The replica never needs to know which table a change belongs to, or why it was made.

That simplicity makes physical replication very reliable, but it comes with a major caveat: nothing else can write to the replica. In other words, you can look at a physical replica, but you can’t use it. To make it writable you have to promote it, which ends the replication.

Why is a physical replica read-only?
Postgres runs a physical replica as a read-only hot standby because of the way replay works. Each log record is applied to a specific disk page, and every record assumes the replica’s pages are identical to the source’s. A single local write would break that assumption, and the next record would land on a page that no longer matches. Postgres has no way to reconcile the two on the fly.

That ruled physical replication out. Loom wanted to test the new database before switching players over, which means writing to it. And we wanted to use the move to partition a busy table into several smaller ones, which a byte-for-byte copy can’t do.

Logical replication

Where physical replication works with files and pages, logical replication works with tables and rows. The source decodes the same WAL into row-level instructions, such as “find the row with this primary key and update it,” “create a row with these values,” or “delete that row.”

Postgres implements this as publish and subscribe. The source defines a publication, the set of tables whose changes it will send, and the destination creates a subscription to it. Each subscription first copies the existing rows of its tables, then streams every change made since the copy began. That initial copy is how the existing terabytes gets across; the stream keeps it current.

To the destination, these instructions are ordinary inserts, updates and deletes, no different from an application’s. And because Postgres is built for many concurrent writers, the destination stays writable while replication runs. This is what allowed Loom to connect to the new database early and play around with it to gain confidence that everything was in order before switching traffic.

The risk is that a test write can collide with data still arriving from the source. A duplicate key stops replication outright, and an edited row can be silently overwritten. So existing player data was off-limits, and only new accounts that existed solely on the destination were fair game.

Animation of logical replication: changes to the source's table are decoded into a single row, which travels to the replica. An apply step there works out which pages to change in the table and both indexes, then writes them.
The source decodes its log into rows. On the replica, an apply worker turns each row back into page changes for the table and each of its indexes.

There’s a second benefit. Since the destination applies instructions rather than copying a disk, its tables don’t have to match the source’s one-to-one. It’s tempting to treat a migration like a renovation. If you’re moving anyway, why not fix the plumbing? We split one of Pixel Flow’s busiest tables into several on the way across. For other studios, we’ve used the same approach to upgrade Postgres versions or reorganize tables.

What logical replication gains in flexibility, it gives up in speed. For a game as popular as Pixel Flow, speed (or lack thereof) was the problem.

Both replication methods replay changes with a single process, but physical replication is told exactly what to write and where, while logical replication has to work it out. For each row, the destination’s apply worker looks it up by primary key, writes the new version into the table, and updates every index to point to it.

In effect, the apply worker is a single client trying to replay every change the game makes on its own, and one client can only push a database so far. Each lookup and write can mean a trip to disk, which costs about a millisecond on network storage, and the worker waits for each trip to finish before starting the next, so it never has more than one request in flight. For this workload, that came to roughly 1,000 changes per second, while the replica’s CPU and storage sat mostly idle, so a bigger server wouldn’t have helped. Pixel Flow’s players generate writes much faster than that, so a single stream falls further behind every second.

Parallel streams

If one stream can’t keep up, the obvious fix is to run several side by side. Logical replication makes that possible because it works at the level of tables and rows. You can give one table its own publication and subscription, another table another, and each gets its own stream up to the number you need.

Splitting by table only goes so far, however, because writes aren’t spread evenly. In practice, a few tables end up taking the majority of writes, and the busiest ones were too much even for a stream of their own.

So what do you do with a table that’s too busy for one stream? You can’t make the stream any faster. But a table is just a collection of rows, and every row has a key. What if each stream only took some of those keys?

In effect, we invented a kind of sharding inside a single table. Even keys went down one stream, odd keys down another, and the busiest tables ended up split 8 or 16 ways.

Animation of parallel replication: four streams run side by side from the source to the replica, one each for tables A and B, and two for table C split by odd and even keys.

So far, every fix in this story has come with a catch. Parallel streams are fast, but they quietly break a promise the database has kept all along. Each apply worker processes transactions in the order they committed on the source, which is what guarantees transactional consistency. But that guarantee only holds within a single subscription. Across subscriptions, you can no longer promise that a transaction’s changes arrive together.

Consider a shop purchase. One transaction takes coins out of the player’s wallet and adds the item to their inventory. These are two halves of the same operation, which live in two separate tables. On the source, both changes commit together or not at all. Put those tables on different streams, and each stream commits its half independently. For a moment, the destination can show the coins gone and the item missing, or the reverse. The destination reflects a state that never existed on the source.

However, this is really only a problem if someone reads the destination before the streams finish. As long as no players are on it, nobody sees the inconsistent state. This is also why, as a rule, game traffic should never go to both databases at once. Only to the source, or only to the destination.

A split transaction is a matter of timing. Given enough time, every piece arrives and the destination is consistent again. The problem is that on a live game, “enough time” never comes, because new writes keep arriving behind the old ones.

Moving day

Let’s do a quick recap: Logical replication let Loom test the new database early, and let us optimize busy tables on the way across. Parallel streams made it fast enough to keep up with a game that a million players log in to every day. The downside is that the destination is only consistent once every stream has caught up, which can only happen if the writes stop. At some point, the game has to pause.

That pause is also when every player moves to the new database, all at once. To keep it short, we didn’t touch the game clients at all. Players never talk to the database directly. They connect to an address, and behind that address is a load balancer that hands each request to the game backend. So rather than giving every device a new address, we kept the old one and changed where its load balancer sends traffic, turning it into a proxy for the new site. It’s like mail forwarding: everyone keeps writing to your old address, and the post office quietly sends any new deliveries on to the new one.

Migration day is the one part of this you can’t fully rehearse. You can clone the database, simulate traffic and time every step, but production always has a surprise waiting, like a permission that’s wrong or an index that isn’t what you expected. So the plan for the day is kept simple, and everyone is ready to think on the spot. It has five steps:

  1. Before we begin. Replication has been running for a day or two, every stream is keeping pace with the game, and Loom has been testing the new database using new accounts only.
  2. Pause traffic at the old load balancer. No new writes reach the source, so nothing new enters the streams.
  3. Wait for every stream to catch up. With the source quiet, each stream works through its backlog until the destination has applied everything the source committed.
Animation of streams catching up: while writes flow, the end of the source log keeps moving and every stream trails behind it. When writes pause, the end of the log stops, each stream catches up to it, and the copy is in sync.
How do we know the streams are done?
Postgres tracks every stream’s progress as a position in the source’s WAL, called a log sequence number (LSN). That includes how far the destination has applied and committed. When the writes stop, note the source’s position. Once every stream has applied changes up to that point, the two databases match.
  1. Point the old load balancer at the new site. Instead of passing requests to the old game backend, it forwards them to the new site's load balancer. Players still connect to the same address.
  2. Resume traffic. Every player lands on the new database at once, and the old site sits idle. From pause to resume, the switchover takes about five minutes, roughly how long Google Cloud takes to propagate the load balancer change.
Animation of the switchover: players connect to the old load balancer. Traffic pauses there while replication finishes, then the old load balancer forwards every request to the new site, whose database becomes the live one. Players never change their address.

All told, the whole switch took about five minutes, well inside Loom’s planned window. Those of you well-familiar with Postgres might wonder why we didn’t shrink that time even further with a connection pooler such as PgBouncer. A pooler sits between the game servers and the database, and it can pause queries, point at a new database and resume, all in seconds.

The catch is that a pooler only switches the database. This migration moved to a new cloud provider and a new region, so the game servers would have stayed behind, and every query would have had to cross regions to reach the database. A single player request can run many queries, and the delay adds up with each one. Say the round trip takes 30 ms and a request runs 20 queries. That’s 600 ms added to every request. We’ve seen setups like that tank performance before.

Switching at the load balancer moves the game servers and the database together. It adds one extra hop per request rather than per query, so the game stays fast once traffic resumes.

At no point do both databases take player traffic. That rule is what lets the design have it both ways: parallel streams for speed, and only one writer at a time for safety.

What made it work

From the outside, work like this can look like magic. We don’t see it that way. Postgres has been refined over decades and its behavior is predictable once you understand its layers. That understanding is what shows you the path forward, and what gets you out of trouble when something surprises you on migration day. You hold a mental map of where things are and where they need to be, and work back from there.

If you’re planning a move like this, start with the trade-offs. Decide what your dealbreakers are first and the right tools tend to follow. Or come talk to us, and we’ll work through them with you.

Enterprise game backends

See how studios ship multiplayer at scale without rebuilding infrastructure.

Walk through Nakama and Heroic Cloud: org-level access control and managed ops without rewriting your stack. Book a demo to see it in action.

  • SOC 2 Type II certified
  • Proven at 2M concurrent users
  • Dedicated capacity, no CCU limits