Skip to main content
Performance

Cloud Performance Tuning When Your Workload Outgrows Its First Architecture

The tuning playbook for applications that worked fine at 100 users and are suffering at 10,000 — without the marketing fluff.

John Lane 2023-04-21 6 min read
Cloud Performance Tuning When Your Workload Outgrows Its First Architecture

Every successful application has a moment where the architecture that got it launched is no longer the architecture that will keep it running. The team that built it is now operating it, the load has grown 50x, and the thing that worked on Friday now pages someone at 2 am on Tuesday. This is a good problem. It's also one that's surprisingly formulaic to fix.

Here's the pattern we've seen work across dozens of customers — B2B SaaS, healthcare portals, K-12 student information systems, everything from 50-user apps to ones serving six-figure daily active users.

1. Find the Bottleneck Before You Scale Anything

The instinct when performance degrades is to throw money at it. Bigger VMs, more replicas, higher database tiers. Sometimes this works by accident. More often it moves the bottleneck somewhere else and makes it harder to find.

Before you scale, answer this question with actual data: where is the request spending its time? If you can't break a p99 request into "X ms in the app tier, Y ms in the database, Z ms in external calls," stop scaling and go fix your tracing. Distributed tracing with OpenTelemetry is the prerequisite for every other conversation in this article.

The common answer when teams finally get tracing: 70% of the latency is in one database query. The second most common answer: a serialized call to a third-party API that could be parallelized or cached. You almost never find the answer you expected.

2. Database First, Everything Else Second

For 8 out of 10 "the app is slow" cases we investigate, the root cause is the database. Specifically, one of these:

  • Missing index. The query that was fine with 10,000 rows is a full scan on 10 million rows. Run the query plan. Add the index.
  • N+1 pattern. ORM is making one query per parent record. Lazy loading is a trap. Fix with eager loading, include, JOIN, or DataLoader-style batching.
  • Locking. Long-running transactions holding row locks that block everything else. Usually a bad SELECT ... FOR UPDATE in a code path that's now high-traffic.
  • Connection pool exhaustion. The app can't get a connection because too many are stuck in long queries. Symptom looks like app tier slowness.

The fix is almost always in the application code or in a migration, not in buying a bigger database. We've moved customers from "our RDS instance is on fire, please help us upgrade to the next tier" to "actually we could run this on half the size" by fixing three queries.

3. Cache Aggressively, Invalidate Honestly

Caching is the second biggest lever after database tuning. The rules we follow:

  • Cache the expensive thing, not the cheap thing. Caching a 2 ms query adds complexity for no benefit. Caching a 400 ms aggregation that runs 1,000 times per minute is transformative.
  • Short TTLs beat clever invalidation. A 30-second TTL with stampede protection is more reliable than a complex invalidation scheme that misses edge cases. If 30 seconds of staleness is acceptable, take it.
  • Cache closest to the consumer. CDN > edge cache > app-level cache > database query cache. Each layer you move earlier in the path is 10x cheaper to serve.
  • Measure the hit rate. Cache hit rate below 80% on a hot path means your cache key is wrong. Fix it or remove the cache.

Redis and Memcached have been solved problems for a decade. Use the managed one (ElastiCache, Azure Cache for Redis, Memorystore) unless you have a specific reason not to.

4. Scale Horizontally, But Pick the Right Tier

The "add more pods" instinct is fine for stateless web tiers. It's useless or actively harmful for stateful services. When we audit scaling strategies, here's the pattern we push customers toward:

  • Web / API tier: Horizontal. Add replicas, use a load balancer, make sure they're truly stateless. No session affinity beyond what's strictly necessary.
  • Background workers: Horizontal, partitioned by work queue. Multiple queues for different priority classes. Don't mix a 50ms task and a 10-minute batch job in the same pool.
  • Database: Vertical first (bigger instance), then read replicas for read-heavy workloads, then sharding only if you genuinely need it. Sharding is a one-way door — don't take it without reading the full architecture guide twice.
  • Cache: Cluster with consistent hashing. Managed services handle this.

The mistake we see: teams sharding databases that should just be running on a bigger box. An r6i.8xlarge with modern PostgreSQL can handle enormous workloads. Exhaust that before you take on the operational complexity of sharding.

5. Asynchronous Is Almost Always the Answer

If a user-facing request does work the user doesn't need to wait for, that work belongs in a queue. Email sends, webhook deliveries, audit logging, search indexing, file processing, analytics events — none of these should be in the critical path of an HTTP response.

The architecture change is small:

  • Synchronous: request → do work → respond (slow, fragile)
  • Asynchronous: request → enqueue → respond (fast, resilient) + worker → do work → update state

Managed queues — SQS, Service Bus, Cloud Tasks — are cheap and reliable. Workers consuming from them are easy to scale and retry. The biggest performance wins we've delivered in the last five years have come from moving synchronous work behind a queue, not from tuning the synchronous path.

6. Pre-Compute What You Can

When a user hits your dashboard and you run a 2-second aggregation across 50 million rows every time, you have a design problem, not a performance problem. Pre-compute it.

Options, in order of complexity:

  • Materialized views in PostgreSQL or SQL Server, refreshed on a schedule.
  • Summary tables maintained by triggers or application code.
  • Event-sourced projections where a worker subscribes to a change feed and maintains a read model optimized for the query.
  • A separate analytics database (ClickHouse, BigQuery, Redshift) for workloads that can't be satisfied by OLTP.

The trap here is over-engineering. Start with a materialized view. Don't build a Kafka event-sourcing pipeline for a dashboard that 40 people look at once a day.

What This Looks Like in a Real Migration

A recent customer came to us with a B2B application that had grown from 200 to 4,000 active users in 18 months. Response times had tripled. Their instinct was to move to a bigger database tier and add more application servers. Before we did any of that, we:

  • Added distributed tracing (one week)
  • Found that 60% of p99 latency came from three unindexed queries (one day to identify, one afternoon to fix)
  • Identified a third-party API call in the signup flow that was synchronous and unreliable — moved it to a queue (one sprint)
  • Added Redis caching in front of a permissions lookup that was running 12 times per request (two days)

Total cost: about 60 hours of engineering. Result: p99 cut by 70%, cloud bill reduced by 15% because they didn't need the bigger tier after all. No architecture changes beyond what a normal team could review in an afternoon.

This is the boring answer. It's also the answer that works.

Three Takeaways

  1. Measure first, scale second. The bottleneck is almost never where your instinct says it is, and scaling the wrong thing makes the problem worse.
  2. Most performance problems are database problems. Indexes, N+1 queries, and connection pools fix more outages than infrastructure changes.
  3. Async by default for anything the user doesn't need to wait for. This is the single biggest architectural lever once the obvious bugs are out of the way.

Talk with us about your infrastructure

Schedule a consultation with a solutions architect.

Schedule a Consultation
Talk to an expert →