How Much Traffic Can a $12 Server Really Handle?
I tested Node.js, Rails and Spring Boot with caching, traffic spikes and one shared CPU.
Post 6 of the series.
In the earlier posts, I tested and tuned each backend separately. This time, I put all three through the same workload on a small cloud server.
The server was a DigitalOcean Basic droplet: one shared vCPU and 2 GB of memory, on the plan listed at $12 a month in this project. The application, Postgres and Nginx shared that budget. A separate droplet generated the traffic and stored the monitoring data; its cost is separate from the $12 test server.
I wanted to answer a practical question: how much traffic could each setup serve while responses stayed fast, and what happened when demand rose beyond its confirmed rate?
In 30 seconds
The latest batch ran on 11–12 November 2026: 11 configurations, three attempts each, interleaved across 33 runs.
These are the tuned configurations’ medians of three confirmed runs. Requests per second, shortened to req/s, is the amount of traffic actually served.
Spring led with the Nginx cache. Node’s tuned no-cache and Nginx-cache setups passed every recovery check. Rails passed every recovery check without a cache and with the app cache, but only one of three with the Nginx cache.
One Spring app-cache attempt established no confirmed rate, so that setup has no complete three-run median.
The takeaway: compare capacity, consistency and recovery together. The largest throughput number answers only one of those questions.
Caption: Every repeated configuration on the droplet. Bars show three-run medians and dots show individual results. Spring app cache has two confirmed observations, about 589 and 591 req/s, and one attempt without a confirmed rate. It has no complete batch median.
What I ran
All three backends implement the same small social-feed API. A visitor loads the feed, pauses, and may like a post or write one. About 70% of requests are reads; the rest are writes.
The implementations use Node.js with Fastify and Prisma, Rails with Puma, and Spring Boot with Spring MVC and JPA. I used the existing untuned and tuned versions from the project. This batch tests those configurations; it does not claim they are the best possible configurations for this droplet.
There are three cache setups:
- No cache: every feed request reaches the application and database.
- App cache: the application briefly reuses its feed response.
- Nginx cache: a cache in front of the application serves the feed. Writes still reach the backend.
A rate must hold for five minutes while meeting four rules:
- 95 out of 100 requests finish within 300 ms.
- 99 out of 100 finish within one second.
- Fewer than 1% of requests fail.
- Fewer than 1% of planned visitor sessions are dropped by the load generator.
A rate that passes these rules is a confirmed rate. The runner then raises demand to 1.5 times that rate for two minutes and returns to the confirmed rate for two more.
The higher load does not always exceed the server’s capacity. The recovery check records whether responses meet the rules after demand returns to normal.
Each configuration gets one turn per repeat, with a cool-down between runs. Interleaving helps spread the tests over time, but does not guarantee equal exposure to contention on a shared host.
Step 1: without a cache, the tuned results were close for Node and Spring
Tuned Node held a median 100 req/s, with individual runs from 100 to 139. Tuned Spring held 99, ranging from 92 to 175. Their ranges overlap substantially. Rails held 67, ranging from 52 to 78.
The untuned results are worth keeping visible too. Untuned Node’s median was 99 req/s, almost unchanged from tuned Node. Untuned Spring’s median was 175 req/s, above its tuned median in this batch.
That Spring result is an observation, not proof that tuning made it slower. Three attempts on a variable shared server cannot isolate the effect of the configuration changes. It is a reason to avoid declaring a tuning win from a single number.
Rails has one no-cache configuration here because its untuned and tuned tags have the same request handling: none of the earlier tuning candidates was kept.
Step 2: caching raised capacity, but the cache setups behaved differently
The workload reads the feed far more often than it writes. Reusing the feed response removes repeated work. It does not remove the writes that still need the application and database.
All figures are requests per second. We report the middle result only when all three attempts establish a confirmed rate. Spring’s app-cache setup confirmed a rate twice, so it has no complete three-run median.
How to read the flow charts
Each column is one cache setup. Read down through traffic served, response time, failed requests and CPU use. Green marks the confirmation test; red marks raised demand; blue marks the return to the confirmed rate. The grey interval includes the pause and ramp-up before raised demand.
The CPU row separates the app, Postgres and Nginx, and adds a dashed line for CPU steal. These are separate measurements, not a complete stacked accounting of the host’s core.
The curves are rolling monitoring measurements. The headline rates come from the runner’s sustained confirmation checks, rather than a peak in a chart. Request-rate axes match within each stack’s chart; use the overview to compare stacks on one shared scale.
The latency lines show p95 and p99: the response times within which 95% and 99% of requests finished.
Node.js
Node’s app-cache runs ranged from 224 to 395 req/s, with a median of 394. Its Nginx-cache runs ranged from 240 to 335, with a median of 334.
Get Raju Methuku’s stories in your inbox
Join Medium for free to get updates from this writer.
The app cache has the higher median in this batch, but the ranges overlap. Recovery adds another distinction: the app cache passed in two of three runs; the Nginx cache passed in all three.
Caption: One Node run for each cache setup, showing request rate, latency, errors and CPU. Every cloud flow chart uses the second repeat, selected consistently rather than for the best result. The result tables include all three attempts.
Rails
Rails’s median rose from 67 req/s without a cache to 175 with the app cache and 240 with the Nginx cache.
The Nginx cache gave it the highest throughput, but only one of its three runs passed the recovery check. The no-cache and app-cache setups each passed all three. More capacity did not automatically mean more dependable recovery.
Caption: Rails in the same layout and with the same cache order. The latency row uses a logarithmic scale so both fast responses and long waits remain visible. Solid blue is p95; dashed dark blue is p99.
Spring Boot
Spring’s app-cache setup confirmed about 589 and 591 requests per second in two runs. The third attempt failed the latency checks during confirmation. Both successful results are shown, but we have not reported a three-run median.
The app-cache result needs more care. Two attempts confirmed about 589 and 591 req/s. The third failed its two confirmation attempts: one on p95 latency and one on p99 latency. It had served traffic during the ramp, but the runner could not establish a sustained passing rate.
That result is neither zero capacity nor an out-of-memory crash. It is an unconfirmed attempt, which stays visible beside the two successful observations.
Caption: Spring’s second-repeat traces. The app-cache chart shows a successful confirmation from that repeat; it does not represent all three attempts. The unconfirmed third attempt is retained in the overview and raw results.
Step 3: check what happens after the spike
Tuned Node passed the recovery check in all three no-cache runs and all three Nginx-cache runs. Its app-cache setup passed two of three. Untuned Node, shown in the overview, passed two of three without a cache.
Rails passed all three without a cache and with the app cache, but only one of three with the Nginx cache.
Tuned Spring passed two of three without a cache and two of three with the Nginx cache. The app-cache setup passed one of its two recovery tests; the third attempt never established a confirmed rate and did not reach that test.
Caption: Capacity and recovery across the tuned setups. Counts include every attempt. An unconfirmed rate is shown separately from a failed recovery check.
There is one important qualification. The raised-load test uses each run’s own confirmed rate. In some runs, even 1.5 times that rate still met the latency rules. Passing the recovery phase therefore does not always mean a server recovered from actual overload.
These are recovery checks at the rates the runner established, not a promise about every possible traffic spike.
Step 4: the shared CPU was not identical from run to run
A shared vCPU can be ready to run while the physical host schedules other work instead. Linux records that wait as CPU steal.
Across the latest batch, average steal ranged from 0.8% to 10.1% per run. The worst one-minute sample reached 21.9%.
Caption: Average and worst-minute steal for all 33 runs, followed by the host CPU breakdown during selected no-cache confirmation windows. The bottom panel uses the final four minutes of the second-repeat confirmation for each stack.
Steal is real time the guest did not receive. But these measurements do not establish how much of each throughput difference it caused. The workload, queues and changing conditions also matter. I have not adjusted the results by dividing out steal or selected only runs with low steal.
The bottom panel also shows why the application’s CPU graph is only part of the picture. Programs share the core with kernel work. The host counters record that work, storage waiting, steal and idle time separately.
For example, in the selected Node confirmation window, about 21% of the core went to system and softirq work, and about 7% was steal. About 35% was idle. This was a passing hold below the failing load, so it should not be read as a continuously saturated server.
The earlier batch also showed substantial variation: tuned Spring without a cache ranged from 44 to 138 req/s. In the newer batch it ranged from 92 to 175. Repeating tests revealed the spread; it did not remove it.
What this means for you
For this workload, caching increased confirmed throughput. Spring led with the Nginx cache, while some lower-throughput setups passed recovery checks more consistently.
If you are choosing a setup, keep the spread and recovery outcome beside its median. Those answer different questions: how much traffic passed, how repeatable it was, and what happened when demand changed.
Save the monitoring data as well as the final number. The first cloud batch did not preserve the full time series needed for these flow charts. This batch exported them before the droplets were deleted, so the charts and headline results come from the same runs.
The limits
This is one API, one shared-droplet setup and three attempts per configuration. The confirmation lasts five minutes; it is not a day-long capacity guarantee.
The runner searches in discrete steps. A confirmed rate is a tested passing rate, not an exact boundary. Changing conditions can also affect the ramp, confirmation and later raised-load phase differently.
The newer batch is used consistently for the main results and charts. The earlier batch remains a separate data point. Neither is a universal ranking of these frameworks.
Think you can improve it?
The implementations, configurations and raw results are public. Open an issue or pull request with a concrete change and explain what you expect it to improve: throughput, recovery or memory use. The shared workload and checks make the proposal testable.
Next up
How did these results differ from the same project running in a small VM on my Mac? That comparison deserves its own post: what changed in capacity, which behaviours carried over, and what the measurements can explain.










