How Much Traffic Can One Small EC2 Handle? Load Testing My Ticketing System
Why I built this
I got curious about this after I stumbled upon an article about the Taylor Swift Eras Tour presale in November 2022, when millions of fans showed up at once and crashed Ticketmaster's site. It made me wonder how I would handle that kind of demand if I had to build the system myself.
When a popular concert goes on sale, thousands of people hit "buy" in the same second. Engineers call that rush a thundering herd, and Ticketmaster's answer is a virtual waiting room. Everyone gets a place in line, but only a few hundred people are allowed to shop at a time.
I wanted to understand that pattern by building it myself. Along the way, I also got curious about a simpler question. How much traffic can one small EC2 instance actually handle, with the right architecture and a bit of tuning?
Based on my test results, quite a lot. A single instance with 2 CPUs and 2 GB of memory handled 10,000 people waiting in line at once, and 500 buyers competing for 1,000 seats never got the same seat sold twice. Here's how I built it, how I tested it, and what I learned along the way.
What I built
I built a bare-bones event ticketing system with just enough features to test my ideas. Buying a ticket works like this.
Join the queue. You get an anonymous place in line.
Wait. Your browser checks your position every few seconds.
Get admitted. Every 5 seconds, up to 50 people are let in, as long as fewer than 500 are already shopping.
Hold seats. The system reserves a block of seats next to each other for you, for the rest of your shopping window.
Pay. Stripe handles the payment and tells the system when it's done.
Behind the scenes it's a Spring Boot backend on EC2, a React frontend, and three databases. Redis runs the waiting room, DynamoDB keeps track of seats, and MySQL stores events and orders. Everything is set up on AWS with Terraform.

To keep the focus on what I wanted to learn, I kept the scope as small as possible. There are no seat maps and no picking specific seats. It's a small, focused version of the real thing, built to see how it behaves under pressure.
How I tested it
To put it under pressure, I used k6, a tool that simulates lots of users at once. It ran from a second EC2 instance inside the same network as the app, so I was measuring the app itself rather than my home internet.
I ran three tests, and each one answered a different question.
Queue surge. Can the waiting room handle 10,000 people joining within a minute?
Seat contention. When 500 shoppers fight over 1,000 seats, does every seat get sold exactly once?
Breaking point. How many new visitors per second can it take before things fall apart?
Two numbers come up a lot in the results. The median is the typical response time, what most users experienced. The p95 means 95% of requests finished faster than this, so it shows how the slowest few users fared. A healthy system keeps both low, and p95 is usually the first to suffer under load.
Small tweaks, big gains
My first run of the queue test went badly. Each problem I found was solved with a small change to the configuration or the code. It also showed why load testing before going live matters, since none of these bottlenecks would have shown up with just a handful of users.
Run | What changed | Failed joins | Join time (p95) | Join time (median) |
|---|---|---|---|---|
1 | Nothing yet | 1,622 out of 10,000 | 1.39 s | 272 ms |
2 | Raised the web server's connection limit (2 lines of config) | 0 | 899 ms | 72 ms |
3 | Cached one database lookup for 5 seconds (about 10 lines of code) | 0 | 47 ms | 9.6 ms |
Altogether, the slowest joins went from 1.39 seconds to 47 milliseconds, roughly 30 times faster, on the exact same machine. Here's how each fix came about.
Fix 1, the connection limit. In that first run, 1,622 people couldn't even get into the queue. Their requests simply timed out, yet my app's logs showed no errors at all, which was confusing at first. The clue turned out to be how many people did get in, about 8,378. Tomcat, the web server built into Spring Boot, accepts at most 8,192 connections by default, plus a short waiting line of 100. Each simulated user kept one connection open while checking their place in line, so once about 8,300 were connected, there was simply no room for anyone else. Raising that limit brought the failures down to zero.
Fix 2, a repeated database lookup. With everyone now getting in, a new problem showed up. The server's CPU was maxed out whenever a wave of people joined. Looking at the code, every single join went to MySQL to load the same event details, around 170 times a second during the rush, even though those details almost never change. So I kept a copy in memory and refreshed it every 5 seconds. That one change made the slowest joins about 19 times faster. The only trade-off is that if an event gets cancelled, people can still join its line for up to 5 seconds, which is fine because buying a ticket still checks the database directly.
Fix 3, reusing connections. The last problem only showed up in the shopping test. Every time the server talked to DynamoDB it needed a secure connection, but the library I used only kept about 5 of them around to reuse. Under load, it kept opening new connections and throwing them away, hundreds of times per test. Switching to a library that keeps a proper pool of open connections almost completely fixed that. The slowest seat holds got about 40% faster. Typical requests didn't get faster in that run, and were even a little slower, which may just be run-to-run variation. So the fix mainly removed the occasional long delay caused by setting up a fresh connection.
Zero double bookings
Making things fast was only half the job, though. Speed means nothing if you sell the same seat to two people. So the next test put 500 shoppers against 1,000 seats, with each person wanting between 1 and 4 seats. On average that's about 1,250 seats wanted, and up to 2,000 if everyone wanted 4, for only 1,000 available. Either way, some people had to miss out.
The protection comes from how DynamoDB handles writes. Reserving seats is a single all-or-nothing step that only succeeds if every one of those seats is still free. If two people go for overlapping seats at the same moment, DynamoDB lets exactly one of them win, and the other simply tries a different block.
I didn't want to take the testing tool's word for it, so I wrote a small script that checks both databases after each test. It makes sure every sold seat belongs to exactly one paid order. The result was zero double bookings in every run.
The most interesting moment came from the very first shopping run, which was much slower than the later ones. In that run, 2 orders ended up refunded instead of paid. Nobody was double-booked, and those 2 buyers got their money back, but they should have gotten their seats. It didn't happen again in the two later runs I checked, which were much faster. I haven't pinned down the exact cause yet, but I suspect it has to do with how my code handles DynamoDB transactions that collide under heavy load.
The testing tool reported 929 seats sold, while the databases showed 924 sold and 2 refunded orders. Only the checking script caught that difference, which is exactly why I wrote it.
How far one EC2 went
With correctness checked, I wanted to see how far the single server could go. After the fixes, the 10,000-person queue test passed every target with zero errors. Joining took 47 ms in the slow case, checking your place in line took 25 ms, and the server was handling about 2,400 requests per second.
Then I kept raising the number of new visitors arriving every second, from 50 up to 800. At its peak the server was dealing with about 12,000 people at once, which works out to roughly 3,700 requests per second, with zero errors across more than 850,000 requests. Near the top the CPU was close to its limit and responses slowed toward one second, but nothing actually failed. It got slower instead of breaking, which is exactly what you want.
These results come with a few honest caveats.
I never found the exact point where it breaks. The testing machine hit its own limit first, because my AWS account's service quota only allowed a small amount of computing power.
Only 500 people can shop at once by design, so the 10,000 figure is about the waiting room, not the checkout.
All the traffic came from inside AWS, not from real people's browsers around the world.
I ran each setup only once or twice, so the numbers have some natural variation.
It’s a single server with no backup, so if it goes down, the whole sale goes down. That’s fine for an experiment, but not something I’d run a real sale on.
The right database for the job, and what it cost
Part of why one small server got this far is that each kind of data lives in a database built for it.
Database | What it does here | Why it fits |
|---|---|---|
Redis | Runs the waiting room | Handles thousands of tiny, short-lived updates every second |
DynamoDB | Keeps track of seats | Guarantees a seat is sold only once, even when two people race for it |
MySQL | Stores events and orders | Good at related data and keeping records consistent |
That choice comes at a price, though. Three databases meant three things to set up, secure, monitor and keep in sync. A single purchase touches two of them, so I had to plan for a crash happening between the two writes, and I ended up writing a checking script just to prove they agree.
Honestly, at this traffic level MySQL alone could probably have handled the seats too. I picked DynamoDB to learn how it prevents overselling at a much larger scale. Using the right tool pays off as you grow, but every extra database is complexity you carry from day one.
A lesson about measuring
One thing caught me off guard. The very same code went from 6.2 seconds to 525 ms for the slowest checkouts between the first and second shopping runs. Nothing changed in between. The most likely reason is that the first run was the first time the shopping code had run since the server started, so nothing was warmed up yet. Since then, I always do a warm-up run before I measure anything.
What's next
I'm honestly not sure yet whether I'll keep building on this. If I do, the next step for much bigger crowds would probably be running several servers behind a load balancer.
For now, the code, the test scripts and all the raw results are on GitHub. If you've built something similar or spot something I missed, I'd love to hear about it.