Iceberg diagram: an app that passed every test, with CPU steal time, disk IOPS, network limits, memory pressure, autoscaling and dependency failures hidden below the waterline

Load Testing the Server Before Deploying Your Critical App

A presales war story about CPU steal time — the metric almost nobody checks before go-live — and the full stress-test checklist I now run before any app touches production.

A customer once told me, with total confidence:

“We’re ready. Load testing passed, QA signed off, we’re deploying Friday.”

I asked one question: “What VM size are you deploying on, and have you checked CPU steal time under load?”

Silence. Then: “What’s steal time?”

That Friday deployment went live. By Monday, response times had tripled during peak hours, support tickets were piling up, and nobody could explain why — CPU usage on the app server looked fine. Not maxed out. Not even close.

That’s the trap. CPU steal time doesn’t show up as high CPU usage. It hides in plain sight.

What CPU Steal Time Actually Is

If your app runs on a cloud VM — AWS, Azure, GCP, or any shared hypervisor host — your VM is not the only thing running on that physical server. Other customers’ VMs share the same physical CPU cores. The hypervisor decides, moment to moment, which VM gets CPU time.

Steal time is the percentage of time your VM wanted to run on the CPU but couldn’t, because the hypervisor gave that time to a neighbouring VM instead.

Here’s the part that trips people up: your VM’s own CPU usage metric only measures what happens inside your VM. It has no visibility into the time it spent waiting outside, in the hypervisor’s queue. So you can have an app server reporting 40% CPU usage, technically “healthy,” while it’s actually being starved of the CPU cycles it needs — and every request is slower because of it.

Diagram showing a physical CPU shared between your VM and two neighbouring VMs, with the 40 percent gap marked as steal time invisible to the VM
Your VM only measures what happens inside itself. The time the hypervisor hands to neighbouring VMs never appears in your CPU usage graph.

This is the classic “noisy neighbour” problem. It’s most common on:

  • Burstable instance types (AWS T-series, Azure B-series) that are designed to be oversubscribed
  • Cheap shared-core VPS plans
  • Any host running close to capacity with many tenants

You can check it right now on a Linux box with:

top

Look at the %st value in the CPU line. Anything consistently above 10% means your app is losing real CPU time to other tenants — and that number gets worse exactly when you need performance most: during your own traffic spikes, because that’s usually when everyone else’s spikes too.

Linux top command output with the %st steal time value of 23.4 circled in the CPU line
23.4 %st in top — nearly a quarter of this box’s CPU time is going to other tenants on the same host.

Why This Never Shows Up in Normal Testing

Here’s why so many teams miss it. Load testing usually runs in a staging environment. Staging is often on a quieter host, provisioned separately, with little or no other tenant load. The app performs beautifully. Everyone signs off.

Production is a different host, possibly a cheaper tier, possibly shared with far more noisy tenants — and the first time anyone discovers the steal time problem is when real customers are already inside the app, complaining.

The fix isn’t complicated once you know to look for it:

  • Check %st on the actual production instance type, not staging, under real load — not just idle.
  • Move to dedicated-core instances (e.g. AWS m/c/r families instead of the t family) for anything customer-facing and latency-sensitive.
  • Alert on steal time, not just CPU usage. Set a CloudWatch or Azure Monitor alarm on it. Most teams alert on CPU% and never even collect steal time as a metric.
  • If you must use burstable instances, understand your CPU credit balance and what happens when it runs out — that’s a related but separate trap.

That customer from the story above moved their app server from a t3.medium to an m6i.large two weeks after launch. Steal time went from spiking above 30% during peak hours to a flat 0%. No code changed. The app was never the problem.

The Bigger Lesson: Steal Time Is Just One Item on a Longer List

CPU steal time is the one nobody checks. But it’s not the only thing that passes fine in staging and breaks in production. Here’s the full checklist I now walk through with every customer before a go-live date gets locked in.

1. CPU steal time (covered above). Test on the actual production instance family, under real concurrent load, not idle.

2. Check where your CDN edge actually is. If your site sits behind Cloudflare, the edge serving your visitors is not always in your country. When the edge sits in a different region from both your users and your origin, every request takes an international round trip, and page loads can slip by whole seconds. Open https://yourdomain.com/cdn-cgi/trace and compare colo= (the Cloudflare edge serving you) against loc= (your own location). If those two are far apart, you are paying that latency on every single asset.

3. Disk IOPS and latency. Cloud disks (EBS gp3, Azure managed disks, and so on) have provisioned IOPS and throughput caps. An app that writes logs, session data, or database records can silently queue up once it hits the disk’s IOPS ceiling — and the symptom looks like “the app is slow,” not “the disk is throttled.” Check your disk’s IOPS and throughput limit against what your app actually generates at peak load, not average load.

sudo fio --name=iops-test --directory=/root --size=1G --numjobs=4 --rw=randwrite --bs=4k --iodepth=32 --ioengine=libaio --runtime=60 --time_based --group_reporting

In plain words: this starts 4 parallel jobs, each writing its own 1 GB file into /root — 4 GB in total — in 4 KB blocks, and it keeps writing for 60 seconds. At the end it gives you a reading. Two numbers matter:

  • IOPS — writes per second. You will see something like iops: min=4749, max=448901, avg=31436.63. Judge the disk on avg — about 31,000 writes per second here. The min of 4,749 is the single worst sample: useful as a warning about how bad the dips get, but it is not the disk’s normal speed.
  • Latency — how long one write takes to finish. The value to read is clat, completion latency: clat (usec): min=18, max=18354k, avg=5319.06. That max is 18,354,000 microseconds — over 18 seconds for a single write. An average of 5 ms is healthy; an 18-second outlier is something your app will feel.

4. Network bandwidth and connection limits. Cloud instances have a documented per-instance network throughput cap, and it usually scales with instance size — look it up on your provider’s spec sheet for the exact instance type going to production. To see what the box actually gets, install a speed test on it:

sudo snap install fast

Then just type fast and hit enter. That gives you real download throughput from the server, which is enough to catch a box performing nowhere near its rated bandwidth. It will not tell you your concurrent connection ceiling, though — for that, check the limits on whatever sits in front of the app (load balancer, nginx worker_connections, database max_connections) against your real peak.

5. 502 / 504 gateway errors and the OOM killer. When the box runs out of RAM, or a sudden spike hits, Linux has no choice but to kill a process — and it goes for the one using the most memory, which is very often the database. The DB dies and restarts, and while it is coming back your workers and agents cannot open a connection to it. What you see at the front is a 502 or 504, which looks like a web server problem and is not.

dmesg -T | grep -i 'killed process\|out of memory'

6. Autoscaling trigger behaviour. Don’t just confirm autoscaling is “enabled.” Test it. Push real load and watch the scale-out actually happen. Many teams discover, only during a real incident, that their scaling policy’s cooldown period is too long, or that the metric it scales on (CPU%) doesn’t reflect the real bottleneck — which, as above, might be steal time, disk, or connections instead.

7. Failure and degradation paths, not just the happy path. Kill a dependency mid-test. Cut off the database. Throttle the cache. A stress test that only proves the app is fast when everything works tells you nothing about what happens the moment one thing doesn’t — and in production, something always eventually doesn’t.

8. Third-party API rate limits. If your app calls out to a payment gateway, SMS provider, or any external API, confirm their rate limit against your real peak concurrent call volume. This is invisible in staging if staging uses a sandbox key with a different (often higher) limit than production.

What I Tell Every Customer Now

Before I sign off on any go-live date, I ask one question that covers most of this list in one shot:

“Have you load tested on the exact production instance type, at 2x your forecasted peak concurrency, while deliberately breaking one dependency at a time?”

If the answer is yes, they’re in good shape. If the answer is “we tested on staging and it was fine,” we’re not done yet — and now you know exactly what to go check first.

I write about the questions I ask in real customer rooms — the ones that change architectures and surface the problems no one else noticed. If you found this useful, drop your email below and I’ll send the next one when it’s ready.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *