How to scale your app from 1,000 to 1 million users

These are the phases you should go through to scale your application properly and not lose users along the way.

Index

1. Which database to pick

Las seis que te vas a encontrar 01 PostgreSQL La respuesta por defecto 02 MySQL Lo mismo, si ya la conoces 03 SQLite Un fichero, una sola máquina 04 MongoDB Fichas sueltas sin forma fija 05 Redis Caché y sesiones, nunca sola 06 Firestore Móvil en tiempo real, sin backend Las seis guardan datos y las seis tienen índices. Lo que cambia es lo que te dejan preguntar después.
All six store data. What changes is what they let you ask afterwards

A database is where your data stays when you turn the server off. All of them do that. The difference is in what they let you ask afterwards, and there are three families:

  • Tables, the SQL ones: fixed columns and one row per thing, and the database knows how they relate, so asking it for "this user's orders from last month, by amount" is one line. Postgres, MySQL and SQLite.
  • Documents, what people call NoSQL: each thing is a loose card with whatever shape you want, you add a field and that is it. MongoDB and Firestore. In exchange, the crossing between cards from three different places is yours to write.
  • Key and value: you give it a name and it hands you a value in under a millisecond. Redis. It is the cache from section 4.2, not your application's database, and if it goes down what was inside is gone.

These are the six you will run into:

DatabaseHow it storesPick it ifWhat it will cost you
PostgreSQLTables with fixed columnsYou have no specific reason for anything elseNothing. It is the boring, correct option
MySQLSame as PostgresYou already know it or your host hands it to youFewer things built in than Postgres
SQLiteA single file on diskPrototype, desktop app or one single machineNo server: you cannot connect several copies
MongoDBLoose cards, each with its own shapeYour data genuinely has no fixed shapeYou write the crossings between collections yourself
RedisA dictionary in memoryCache, sessions and queues. Never as your only storeIf it restarts, it is gone. That is its job
FirestoreLoose cards that push to the phone on their ownReal-time mobile app with no backend of your ownYou pay per read and queries are limited

And the short answer, which is the one I use: Postgres. It stores tables, it stores a JSON card inside a column the day you want the comfort of a document, it searches text, and it comes ready-made in Supabase, Neon and Railway. Picking another is fine, as long as it is because you can say what it does that Postgres does not.

Four cases where there is a reason:

  • Your data genuinely has no fixed shape, every record carries different fields and it is not laziness about writing the migration. That is Mongo.
  • You want the phone screen to update itself when the data changes, with no backend to maintain. Firestore, or Supabase realtime.
  • You write millions of events a day and only ever read them by date. That is a time-series database, TimescaleDB or ClickHouse, and it lives next to the main one.
  • You ask for friends of friends of friends. That is a graph database, Neo4j.

2. Users are not the unit

De un millón de registrados a lo que tu servidor nota 1.000.000 registrados 50.000 al día 2.000 a la vez 210 peticiones/s 5 % ENTRA 4 % COINCIDE 6 AL MINUTO El último número es el único que tu servidor nota. Los tres primeros son marketing.
A million registered users fit on a small machine if they do not show up at once

"A million users" tells your server nothing. What it feels is requests per second, queries open against the database at the same time and megabytes served: a million registered users with two hundred inside in the same minute is a boring, cheap machine. Do the maths before buying anything, because it almost always lands well below the number you were carrying in your head.

And when you measure it, look at the p95, not the average. Take a hundred requests to your site, line them up from fastest to slowest and pick number 95: whatever that one takes is your p95. If it is 400 milliseconds, 95 out of every 100 people wait 400 milliseconds or less and 5 have it worse. The name comes from percentile 95, which is the spot in the line.

The average is no good because the good numbers eat it. Ninety requests of 50 milliseconds and ten of four seconds give an average of 445, which sounds fine, and meanwhile one in ten users has been staring at a frozen screen for four seconds. The p95 of that is four seconds.

Now that you have got that, start scaling:

UsersThe usual listWhat actually applies
Day oneNot on itIndexes on what you filter by. Not a phase
1,000One serverOne server, with the measuring plumbing in place
10,000Redis and connection poolingExactly that: cache in three layers and the pool
50,000Load balancer with NginxSeveral copies, and a server that keeps nothing inside
100,000Read replicas and index tuningReplicas. The indexes were already in
500,000Event queuesQueues, and everything that can wait outside the request
1M+Gateways and shardingDepends which database you picked. Almost never applies

3. STAGE 1 · Do not forget to create indexes

EL ÍNDICE, EL DÍA QUE SE ROMPE 200.000 filas y una web que se arrastra Una noche buscando por dónde 1.400 ms por consulta EL ÍNDICE, CON LA PRIMERA TABLA Una línea en la migración Un minuto de tu tiempo 3 ms por consulta Mismo servidor, misma base de datos, mismo precio. Solo cambia el momento en que te acordaste.
It is the same index. Only when you remembered it changes

If your tables have no indexes, a query filtering on a column walks the whole table, row by row. At a thousand rows nobody notices. At two hundred thousand, every request can take minutes, and since they arrive together they pile up.

The rule is short: every column you filter by, sort by or join two tables on gets an index, foreign keys included, which is the most repeated oversight. But only those, because every index is paid for on every write: on what you query, not on every column just in case.

The second thing to keep in mind is the N+1. One query to fetch the list and one more for every item in the list. Twenty orders are twenty-one queries, and it is fixed by asking for the twenty at once.

If you are not sure you are getting it right, the review takes ten minutes: turn on the slow query log, sort by the five most expensive of the week and put EXPLAIN in front. If it tells you it is doing a sequential scan on a large table, there is your missing index.

And a question I get a lot: indexes are not a SQL thing. Mongo has indexes, Firestore has indexes, DynamoDB has indexes. What changes is the command and little else, because the problem is the same everywhere: finding something without looking row by row.

4.1 STAGE 2 · Up to 1,000: one machine, the more boring the better

Todo lo que necesita una aplicación con mil usuarios Navegador El usuario CDN Imágenes y JS Un servidor Node y Fastify Postgres Gestionada, sin tocar Mientras esto aguante, esto es lo correcto. No hay premio por complicarlo antes de tiempo.
One process, one managed database and a CDN in front of the static files

There is nothing to scale here: one Node process with Fastify, a managed Postgres next to it and a CDN in front of the images and the JavaScript. The expensive mistake of this phase is not falling short, it is overshooting. Setting up an orchestrator, three microservices and a queue for forty users leaves you four things to maintain and none to show, and when real growth arrives you will rebuild it anyway.

Here you want a monorepo, meaning the frontend and the server in the same folder and with a single deploy, which is what suits you here and for the next four phases. Later on you will need microservices, which is splitting the application into pieces, each with its own database and its own deploy.

If there are parts or features taking a lot of traffic, splitting is the right call. But not now with a thousand users, later.

4.2 Add cache. And not one, three.

Dónde se para cada petición 01 El navegador ya lo tiene Cache-Control 02 El CDN lo sirve desde tu ciudad estático y páginas públicas 03 Redis contesta sin consultar ≈ 1 ms 04 El servidor calcula solo si no estaba arriba 05 La base de datos la última en enterarse Si la mayoría de peticiones llega a la última fila, tienes un problema de caché, no de base de datos.
Every layer that answers is a request the ones below never see

At ten thousand users, almost everything people ask for is what the previous person asked for. Caching is answering without recalculating, and there are three places to do it, from cheapest to dearest:

  • The CDN: images, CSS, JavaScript and pages that do not change depending on who is looking. They never touch your server.
  • Redis: the result of an expensive query, the profile every screen asks for, the front page listing. One millisecond away from your server.
  • The process memory itself: configuration, catalogues, the small things that do not change. Free, but duplicated in every copy of the server, so only for small things.

Putting something in the cache is the easy part. The hard part is throwing it away when the data changes, because until you do you are showing people an old price or a photo that is no longer theirs. There are two ways and you use both: give it an expiry, thirty seconds and it deletes itself, which covers almost everything; and delete it by hand when you change the data, with that line right next to the one that saves the change. Write it in another file and the day somebody touches that write, they will forget.

And something that only happens once there is traffic. A thousand people are asking for the same front page and the cache expires in that exact second: all thousand find it empty at once and all thousand go to the database to calculate the same thing. Everything the cache had been saving it arrives in one go. It is fixed by letting only the first one calculate and having the rest wait for its result, and you do not program that yourself: most libraries ship it, under "lock" or "single flight".

4.3 The connection pool

Sin pool Cada instancia abre las suyas 500 conexiones pedidas una por cada proceso que arranca El límite de la base son 100 y las cuenta de verdad Errores en ráfagas solo en los picos, luego va bien Con pool Un intermediario las presta 500 clientes servidos cada uno cree que tiene la suya 20 conexiones reales abiertas todo el rato Se devuelve al acabar cada transacción, no cada sesión Antes de pagar una base de datos más grande, comprueba si lo que te falta es esto.
Twenty real connections serving five hundred clients

A Postgres does not serve infinite connections: each one costs it memory, and the limit on a small managed instance sits between sixty and two hundred. It sounds like plenty until you add copies of the server or deploy to serverless, where every instance that boots wants its own.

The symptom does not look like "it is slow": the application returns too-many-connections errors in bursts, right at the peaks, and runs perfectly between them. If that happened to you and you blamed the network, this was it.

The fix is a middleman that keeps a few real connections open and lends them out to whoever asks. PgBouncer is the classic one, and Supabase, Neon and the rest already ship it: you have to use the pooler connection string instead of the direct one, which is a mistake you see a lot.

What makes it work is transaction mode: the connection goes back at the end of each transaction, not when the application shuts down. That is where twenty real connections serve five hundred clients. In exchange, prepared statements and LISTEN/NOTIFY stop working; your ORM usually has an option with that name.

5. STAGE 3 · 50,000: more copies and something in front that spreads the load

¿Aguanta tu servidor tener tres copias? La sesión vive en la memoria del proceso La sesión vive en Redis o en una cookie firmada Los ficheros que suben se quedan en el disco Los ficheros van a un bucket, fuera del servidor Las tareas programadas corren dentro de la aplicación Las dispara la plataforma, una sola vez Si te reconoces en las rojas, escalar en horizontal te va a romper cosas muy raras.
The three conditions you meet before doubling the server

The day comes when one machine is not enough. There are two ways out: a bigger machine, which is vertical scaling, or more copies of the same one, which is horizontal. The first is a button and works until the provider's catalogue runs out. The second never runs out, because you can always add another copy, but it asks one thing of you.

The condition is that your server keeps nothing inside. Nothing. If the session lives in the process memory, the second copy does not know who you are and throws you out. If the uploaded file stays on disk, half the site sees a photo the other half cannot find. If the scheduled task runs inside the application, three copies send the email three times. Sessions to Redis or a signed cookie, files to a bucket and tasks to something that fires them once.

The thing in front is not yours to build: Vercel, Cloud Run, Railway and Fly ship a balancer and autoscaling by default. Two settings almost nobody touches: scale up fast and down slowly, because shrinking right after the peak leaves you naked when the second one arrives, and set a ceiling on copies, because an infinite loop with autoscaling does not take down your site, it takes down your card. And since there is something in front, give it a request limit per IP: a thousand per second do the same damage whether they come from an attacker or from a customer's badly written loop.

There is a version where there is no server of yours at all: lambdas, or serverless functions. You upload a function, the provider starts a copy for every request and bills you by the millisecond. They are great for rare spikes, and they come with two tolls: the cold start, meaning the first request after a quiet spell takes longer, and the fact that every copy wants its own database connection, which is the problem from section 4.3.

And this is where microservices finally come in, the ones I told you in section 4.1 to leave for later. Later is here. Until now you have scaled by copying the whole application; a microservice is taking the piece that is full and giving it its own server, its own database and its own deploy.

The reason is money. A single block scales whole or it does not scale: if your most used feature takes almost all the traffic, running ten copies means paying ten times for the nine parts nobody touches as well. Split into pieces you only scale the piece that is full. And there is a second reason, the team: each group runs its own services and deploys when it wants, without waiting for anyone.

What it costs, so nobody sells you only the good half: two pieces that used to call each other with a function now call each other over the network, and the network fails. A query that joined two tables is now two requests you join yourself in code. And an error you read in one log you now read in four. That is why the answer up to here was the monorepo.

And Kubernetes, or K8s, the name that always shows up in this conversation: a program that spreads your containers across several machines, restarts them when they fall over and adds copies when people arrive. The same thing Vercel or Cloud Run do for you, except you administer it. With two services you do not need it, and setting it up early is the expensive mistake from stage 2 under another name. I cover it in full in the 9 DevOps skills.

6. STAGE 4 · 100,000: read replicas and the lag they bring

La aplicación decide a dónde va cada consulta Escribir siempre a la principal Leer lo recién escrito a la principal, unos segundos Leer todo lo demás a las réplicas La rama de en medio es la que casi nadie pone, y es la que evita el «se ha perdido lo que escribí».
Reads get spread out; writes still go to a single place

In almost any application there is far more reading than writing: twenty reads per write is ordinary, and on a content site it is two hundred. A replica is a copy of your database that keeps itself up to date and only gets asked questions. Add two and you have tripled read capacity without touching a line.

The price is that the replica runs a few milliseconds behind. It almost never matters, and when it does it is in the most visible case of all: the user saves something, the next screen reads from the replica and their change is not there. They report it as "it lost what I wrote", and nothing was lost.

The rule that fixes it is called read your writes: for a few seconds after someone writes, their reads go to the primary. A timestamp in the session and an if. And the order matters, because this one is money: replicas after the cache, never before. If the query you want to spread out is the same one two thousand times a minute, the replica charges you every month for what Redis does for free.

And at this volume you get what the list calls index tuning, which sounds like magic and is an afternoon of reviewing what you already added. You decided the indexes from section 3 looking at a table of a thousand rows, and at a hundred thousand users the queries that hurt are different ones.

It is three concrete things. One: sort your queries by the time they add up to over a whole week, not by the slowest single run, because the one repeated ten thousand times costs you more than the one taking two seconds once a day. Two: check whether any needs a two-column index instead of two one-column indexes; if you filter by company and date together, the index has to carry both, in that order, because a two-column index serves the first column alone but not the second alone. And three: delete the ones nobody uses, since every one is paid for on every write. Postgres tells you which have never been used in a table of its own called pg_stat_user_indexes.

7. STAGE 5 · 500,000: everything that can wait goes outside the request

Lo que tarda un «crear pedido» Guardar el pedido 40 ms Correo de confirmación 900 ms Factura en PDF 1.800 ms Avisar al servicio externo 1.300 ms La petición entera, hoy 4.040 ms La petición entera, con cola 44 ms Los tres de en medio se siguen haciendo. Solo dejan de hacerse mientras el usuario espera.
The errands still happen, just not while the user watches the spinner

At this volume the bottleneck stops being the database and becomes the time your server spends running errands: sending the email, generating the invoice PDF, notifying three external services, recalculating a ranking. All of it lives inside the request, and the request does not finish until the last errand does.

The fix is old and still the best one: put the job in a queue, answer the user and let another process do it in the background. The request drops from four seconds to forty milliseconds, and the slow part can be retried without anyone waiting in front of a screen.

To start, BullMQ on the Redis you already have. Kafka is a different thing and arrives much later: it is not a task queue, it is an event log several consumers reread at their own pace. If you cannot explain why you need to reread them, you do not need it yet.

And between those two sits RabbitMQ, the old classic: a server dedicated to handing out messages, with far finer routing rules than BullMQ and none of Kafka's complication. It is the option for someone who has outgrown Redis but has nothing to reread.

Before picking, the underlying difference. A task queue stores jobs: first in, first done, and once it is done it is gone. An event log stores what happened and does not delete it, so several different programs can read the same thing each at its own pace, and go back if they got it wrong. BullMQ, SQS and RabbitMQ are the first kind. Kafka is the second, and that is why it is dearer and harder.

The four you will run into, from cheapest to dearest:

ToolWhat it isPick it ifWhat it costs
BullMQA library on the Redis you already haveYou are starting out and your errands are emails, PDFs and alertsThe cheapest: if you already have Redis, nothing. On Upstash, free up to 500,000 commands a month
SQS or Cloud TasksA managed queue, no server to maintainYou want to maintain nothing and queueing plus retries is enoughCents per million messages. The first million a month is free
RabbitMQA server dedicated to handing out messagesYou need priorities, several queues and fine routing rulesA small machine and somebody to watch it. Managed, around $20/mo
KafkaAn event log you can rereadSeveral teams need to reread the same thing at their own paceThe dearest by far: managed and for real it runs into three figures a month

As with the other table, the prices move and the order does not. And if you are torn between two, take the one above: nobody ever goes down from Kafka to BullMQ, and going up from BullMQ to Kafka is an afternoon.

Three things that go in the same day as the queue, because without them the queue is an elegant way to lose work:

  • Retries with growing waits: 1 s, 4 s, 16 s. If the service next door is down, do not be the battering ram.
  • Idempotent jobs: running one twice has to give the same result, because it will happen. One key per job and done.
  • A separate queue for what always fails, with somebody watching it. If nobody watches it, that is where your customers' money goes.

8. STAGE 6 · +1M: distributed gateways and sharding

El orden antes de partir nada 1 Una máquina más grande un botón, y suele bastar 2 Archivar lo viejo nadie mira las filas de hace dos años 3 Particionar por fecha misma base, trozos manejables 4 Sacar la tabla ruidosa a su propio sitio 5 Ahora sí: sharding y se acabaron los JOIN entre partes Cada peldaño cuesta una tarde. El último cuesta un trimestre, y no se vuelve atrás.
The four rungs that come first, and the fifth only if there is no other way

Two names show up at a million, and the first thing is knowing they fix different problems. Distributed gateways are so your site is not slow on the other side of the world. Sharding is for when your database cannot take any more writes. A fair few need the first well before a million, and almost nobody ever needs the second.

A distributed gateway is section 5 repeated across continents: copies of your application in Europe, in America and in Asia, and something in front sending every user to the nearest one. A user in Tokyo hitting a server in Madrid loses around two hundred milliseconds on the round trip alone, and no index and no cache fixes that, because it is distance. So it is not decided by volume either: it is decided the day you look at where your people come from and see they are far away.

This is where the original list ends, and it is where most people get into trouble by following it literally. Sharding means splitting your data into several databases that do not talk to each other: you gain write capacity and lose nearly everything else, starting with JOINs across parts and transactions touching two of them.

And it may not be your job at all: some databases spread the data on their own from the first row and others never will. You decided that back in section 1, not when you reached a million users.

And if it is your job, there are four rungs first. Almost everybody stops on the first or the second:

  • A bigger machine. Boring, immediate and almost always enough. One button and a few minutes of downtime.
  • Archive the old rows. Nine out of ten rows in the table worrying you have not been looked at in a year. To a history table and out of the way.
  • Partition by date. Same database and same application, but every query only touches the slice of the month it asks about.
  • Move the noisy neighbour out. That events table writing a hundred times a second does not have to live with your orders.

If after those four you still cannot keep up, then yes, and by the key you always ask about: in an application with companies inside it, that key is the company. Although before that there is a shortcut, and it is paying: half the list in this article can be bought ready made, and the ones at the top scale on their own while the ones at the bottom make you open a panel and decide.

ToolHow it scalesWhat it costs
CloudflareOn its own: CDN, cache and per-IP limits includedFree. Pro is $20/mo and almost nobody needs it
VercelOn its own: adds copies of your app unaskedFree. Then $20/mo per person and traffic on top
NeonOn its own: rises and falls with load, then sleepsFree. After that you pay what it uses, no monthly minimum
UpstashOn its own: there is no server to sizeFree up to 500,000 commands a month. Then $0.20 per 100,000
RailwayBy hand: you raise CPU, RAM or the number of copies$5/mo with some usage included. Consumption on top
SupabaseBy hand: you size the machine; the pooler is thereFree. Pro is $25/mo and the big machine is an extra
MongoDB AtlasCopies by default; sharding you turn on yourselfFree to try. Sharding starts on the $57/mo tier

Out of that comes the rule to pick by: if you pay per use, scaling costs you nothing until the application works; if you pay for a reserved machine, scaling is your decision and it shows up on the first of the next month. Prices are the ones from September 2026 and they move, but the order of the table does not.

Summary

  • Indexes go in with the first table, and Mongo has them just like Postgres.
  • The database is picked on day one: Postgres unless you can say what the other does that it does not.
  • Users are not the unit: requests per second, concurrency and p95.
  • Up to a thousand, one machine. Overshooting costs more than falling short.
  • Cache in three layers, and removing it on time is half the work.
  • Connection pool before paying for a bigger database.
  • Horizontal only if your server keeps no sessions, files or tasks inside.
  • Replicas after the cache, with read-your-writes the same day.
  • Everything that can wait goes to a queue, with retries and idempotency.
  • Sharding almost never applies: four rungs first, and half the list can be bought.

The list all of this comes from is not wrong, it is out of order: it files day one under phase six and puts something that is not even up to you at the end. And the disorder is paid for, because every phase you jump ahead is more things to maintain that nobody asked for. Scaling well is boring: you do the least that returns performance, measure again and wait for the next thing to break.

How to know which phase you are in

Do not decide it from memory. Open your database panel and look at three numbers: requests per second at peak hour, p95 of your most used route and open connections against the limit. Start with the first of the three that does not add up, not with whatever you feel like building, because what actually applies is usually several phases below what you were in the mood to touch.

And if you are still at the stage of getting the application standing up, start with the full vibecoder stack, and before anyone comes in, with the 9 security skills.

If you scale something with this and it works, send me the number on Instagram. Those messages make my day.