You finally finish your project and everything works perfectly on 'localhost:3000' and everything feels smooth and fast. Then you move on to production and want to deploy it.
Before deploying you have to ask yourself if the website is really ready to handle all the real traffic at once. What happens when 1000 people use it concurrently?
Of course it's not as simple as needing a bigger server as different people using different part of your websites create vastly different workloads on the website.
In this blog I'm going to use a fictional e-commerce app built with next.js and PostgreSQL to see which part changes when scaling.
Localhost Hides Problems You Haven't Created Yet
When you are testing alone, there is a low chance that operations rarely overlap each other. For example you would test browsing a product on the homepage, adding it to your cart and place an order separately. So here nothing really competes for database connections or trying to change the inventory record.
Contrasting that, in production requests arrive independently, sometimes in burst. Services have certain capacity limits and users might retry checkout because their connection dropped even though the first request they made actually went through.
So these three questions become important:
- Capacity: Can the system process the incoming workload?
- Latency: How long do customers wait?
- Correctness: Do simultaneous actions leave the data in a valid state?
Also, a quick tip is don't benchmark your app using 'npm run dev' as next.js dev mode performs extra work and behaves differently from the production build. Test the deployed config or a realistic production build.
1,000 Concurrent Users ≠ 1,000 Simultaneous Requests
1000 people having the storefront open doesn't mean all of them are sending requests every millisecond, maybe they are just browsing or reading descriptions.
If each person makes one server request every 30 seconds, evenly distributed, that's roughly:
1,000 / 30 ≈ 33 requests per second (RPS)
Now if the average request takes 200 milliseconds. Under normal conditions, roughly seven requests are in progress at any moment (33 × 0.2). If response time rises to two seconds, that becomes about 66 requests in progress, even though traffic hasn't increased.
From that we can understand that requests in flight ≈ arrival rate × average response time. It describes how many requests are underway, and not necessarily how many CPU cores you need.
And also one thing to note is that averages hide bursts. A flash sale might send 500 checkout requests within seconds. Serving 33 cached pages per second is also nothing like processing 33 payments per second.
Follow One Product-Page Request
A simplified architecture might look like this:
Browser
|
CDN / Edge Cache
| (Cache miss or dynamic request)
Next.js Application
|
Connection Pool
|
PostgreSQL
Not every request reaches the database. A CDN can serve cacheable images and assets directly. A cached product page or data response might avoid repeated server work, depending on how you've configured Next.js.
For example, take a $50 keyboard. Its image and description may remain unchanged for weeks, while its stock count changes during a sale.
Caching both for the same duration would be a mistake. A stale description is usually harmless but a stale availability indicator can mislead customers. Checkout must verify inventory against authoritative server-side data regardless of what's displayed on the page.
So when working with caching, you must think about which information can be reused and when must it be fresh. This is also why personalized carts and account data must never be exposed through a shared public cache.
For framework-specific strategies, see the Next.js production checklist.
Where Does the System Actually Slow Down?
Before scaling anything, try your best to locate the bottleneck. Here are three common ones.
Database Connections
Every application instance needs connections to PostgreSQL. If you're deploying ten instances, each configured with a pool of 20 connections. That's potentially 200 application-side connections competing for database capacity.
A connection pool reuses connections and limits how many operations can hold one at once. In serverless deployments, an external pooler can help manage connections across short-lived instances.
But pooling isn't extra database horsepower. Once the pool is busy, new operations wait. If queries are slow, the queue still grows. Prisma's pooling guide explains the trade-offs.
Slow or Repeated Queries
If your search endpoint scans the full products table for every keystroke. With ten users, you might not notice but with hundreds or even more, that repeated work can dominate response time.
Look at real queries using PostgreSQL's EXPLAIN ANALYZE. An appropriate index, pagination, or fetching fewer columns may help. Still, An index isn't free: it uses storage and adds overhead to writes.
Measure the query before optimizing it, don't add indexes simply because a table looks large.
Too Much Work Inside a Request
Does checkout generate an invoice PDF, call an external email service, and wait for analytics before responding?
The customer probably needs to know whether the order was accepted, and not wait for every follow-up task. Move nonessential work into a background job, with retries and failure handling. Keep essential operations, such as confirming inventory and creating the order, in the critical path.
What Does Adding Servers Solve?
You have two broad options:
- Vertical scaling: Give one machine more CPU or memory.
- Horizontal scaling: Run more application instances behind a load balancer.
Managed platforms may handle much of the application scaling automatically. For example, Vercel Functions scale execution capacity within platform limits.
But extra app instances have a catch, they can't assume their local memory is shared. If Server 1 stores a session in a variable, Server 3 won't automatically see it. Shared sessions and persistent data need an appropriate external store or other coordinated mechanism.
Even worse if all three instances may still depend on the same overloaded database. Adding servers can increase pressure on it.
So, only scale the parts that's limiting you. And don't add Kubernetes or Redis just because "production architecture" diagrams tend to have them.
The Last Stock: A Concurrency Problem
Here's a common failure that won't be fixed by faster or more servers.
Our store has one keyboard left and two people press buy almost simultaneously:
A reads stock: 1
B reads stock: 1
A creates an order
B creates an order
If our code checks inventory and later updates it without protection, we could oversell.
One safer building block is an atomic, conditional update in PostgreSQL:
UPDATE products
SET stock = stock - 1
WHERE id = 42 AND stock > 0
RETURNING id;
Only a request that successfully updates the row reserves that unit. If no row is returned, no stock was changed. PostgreSQL manages conflicting updates to the same row.
Still, that's not a complete checkout system. We'd also manage inventory changes and order creation in a database transaction, then handle payment outcomes, timeouts, and retries. An idempotency key can prevent a repeated checkout request from unintentionally creating a second purchase.
I encountered a similar kind of problem in a sport booking project, that displaying available slots was straightforward but preventing overlapping successful reservations required more thinking.
The lesson here is that your app can respond quickly and still return the wrong result. So scaling has to protect data correctness as well as maintain speed.
See PostgreSQL's transaction isolation documentation.
Always test it rather than guessing
So can our app support 1,000 concurrent users?
You can use tools such as Grafana k6 to generate controlled traffic in an environment you own or are authorized to test.
Create a scenario resembling your real store. For example:
- 80% product browsing
- 15% search requests
- 5% checkout attempts using test orders
Those proportions are illustrative. Use real traffic patterns when available. Increase the load gradually and watch:
| Metric | What it reveals |
|---|---|
| RPS | Traffic the app actually handles |
| p95 latency | Time within which 95% of measured requests complete |
| Error rate | How often requests fail |
| CPU, memory, DB connections | Which resources are under pressure |
| Order and inventory checks | Whether concurrent operations remain correct |
So dont stop when the homepage looks fast or smooth. Test checkout bursts, failed dependencies, and the last-item-in-stock case. A successful HTTP response doesn't prove that an order was recorded correctly.
Then you can try to make one targeted improvement and run the same test again. Using data from each change, try to create the most suitable tradeoffs between each metrics and craft the best user experience.
So What Actually Changes?
Going from your own machine to 1000 concurrent users doesn't necessarily mean a changing to a new tech stack.
What changes is that requests overlap, limits become visible, and mistakes have real consequence on real customers. A CDN may remove unnecessary requests. Better queries can relieve the database. More application instances can add capacity. Transactions protect shared data. Load tests reveal whether any of it is enough.
Start with a simple architecture you understand, and measure it with a realistic workload. Then fix what actually breaks.
Keep building and happy building!

Top comments (0)