Skip to main content
Back to AI Commerce Lab
Performance·August 2024·9 min read

Scaling Up: Strategies for Ensuring Your Website Can Handle Growth

Scaling for sustained growth is a capacity and architecture problem: stateless horizontal scaling, database read replicas, a CDN strategy that holds up at higher steady-state volume, and load tests run before traffic forces the issue. That's different from surviving a flash sale or a viral spike, which is a queueing and degradation problem covered in our companion piece on sudden traffic spikes.

Sustained growth versus a spike

A growing business adds users steadily over months. Capacity planning for that curve is about right-sizing infrastructure ahead of a trend you can see coming, load testing to confirm headroom, and building an architecture that adds capacity linearly as demand grows.

A spike is a different shape entirely: a five-minute burst from a flash sale, a viral post, or a Black Friday drop. That needs queueing, cache stampede protection, and graceful degradation, which we cover in detail in the sibling post on handling sudden traffic spikes. Conflating the two leads teams to over-provision for spikes they rarely see, while under-investing in the database and caching work that sustained growth actually requires.

Vertical scaling has a ceiling horizontal scaling doesn't

Vertical scaling, moving to a bigger single server, is the fastest first fix and the wrong long-term strategy. It has a hard ceiling set by the largest instance type available, a single point of failure, and no relief if that one machine goes down. Horizontal scaling trades that simplicity for elasticity: more, smaller instances that can be added or removed independently, with no single instance being a failure domain for the whole app tier.

DimensionVertical scalingHorizontal scaling
Implementation effortLow, resize an existing instanceHigher, requires statelessness and a load balancer
CeilingHard limit at the largest available instance sizeEffectively open-ended
Failure domainSingle instance; its failure takes the app downDistributed; one instance failing degrades capacity, not availability
Cost curveNon-linear, larger instances cost disproportionately moreRoughly linear with instance count

Horizontal scaling: the app tier

Elastic Load Balancing distributes incoming traffic across multiple targets, running health checks on a configurable cadence and routing only to healthy instances (AWS — What is Elastic Load Balancing?). That's the mechanism that lets you add app instances as load grows instead of buying a bigger single server.

CLIENT CDN EDGE cached HTML + static assets LOAD BALANCER APP TIER horizontal scale-out instance instance instance CACHE hot reads, sessions DB PRIMARY writes async replication READ REPLICAS read traffic fan-out

Growth-stage architecture: a stateless, horizontally scaled app tier sitting between a CDN and a data layer split into cache, a write-only primary, and read replicas.

Statelessness is the actual requirement

Adding instances behind a load balancer only works if any instance can serve any request. Session data, uploaded files, and in-memory caches need to live outside the app process, in a shared cache or object store, or the load balancer starts routing users to the one instance that happens to hold their state.

Sticky sessions, where a load balancer pins a user to one instance for the life of their session, look like a quick fix for this. They're an anti-pattern for growth: they defeat the point of horizontal scaling by concentrating load unevenly, and they turn a single instance restart into a mass logout for everyone pinned to it.

More app instances means more database connections, not fewer problems

Every new app instance typically opens its own pool of database connections. Doubling instance count without revisiting pool size can exhaust the database's connection limit well before CPU or memory becomes the constraint, a failure mode that looks like a database problem but is actually an app-tier scaling side effect. A connection pooler sitting in front of the database, decoupling the number of app-side connections from the number the database actually has to manage, is the standard fix once instance count grows past a handful.

Horizontal scaling isn't "add more servers." It's "make sure any server can answer any request," which is a statelessness requirement most teams don't confront until the second or third instance goes live.

Database scaling: read replicas

Amazon RDS read replicas serve high-volume read traffic by replicating a primary database asynchronously; the primary handles writes while replicas absorb read-only queries (AWS — Working with DB instance read replicas). PostgreSQL's own streaming replication does the same thing at the database engine level, independent of any specific cloud vendor (PostgreSQL Documentation — Log-Shipping Standby Servers).

The pattern only helps if your application's query pattern is actually read-heavy, and if your code can route read queries to replicas while keeping writes on the primary. Retrofitting that routing logic into an application that assumes a single database connection is real engineering work, not a configuration flag.

A read replica is not a performance switch you flip. It only pays off once your application can actually route reads and writes to different connections, which is a code change, not an infrastructure change.

What replicas don't fix

Replication is asynchronous, so a replica can lag behind the primary by a small, variable amount. Any code path that writes data and immediately reads it back, an order confirmation page, for example, needs to read from the primary or handle a stale read explicitly. Read replicas also don't help write throughput at all; that's a sharding or partitioning problem, a different and harder one.

Backups aren't the same thing as replicas, and growth makes the distinction matter

A read replica gives you read scale and, in a failover, a promotable standby. It isn't a substitute for point-in-time backups, since a replica faithfully replicates a bad write or a corrupted row just as fast as a good one. As data volume grows, verify backup and restore procedures against a realistic dataset size on a recurring schedule, not only once at initial setup.

CDN strategy at steady-state scale

At growth-stage traffic volumes, a CDN's job shifts from "handle bursts" to "keep origin load flat as baseline traffic climbs." Tiered caching architectures route a cache miss through an upper-tier data center before it reaches the origin, which concentrates origin connections into a small, predictable set instead of every edge location hitting the origin independently (Cloudflare — Tiered Cache).

That matters more as your user base grows and spreads geographically. More edge locations serving more regions means more potential origin connections if tiering isn't configured, even before accounting for a single traffic spike.

Cache hit ratio is the metric that tells you if the CDN is actually working

A CDN that's misconfigured, wrong cache headers, overly short TTLs, cache keys that vary unnecessarily by query parameter, quietly forwards most requests to origin anyway while still billing for edge traffic. Tracking cache hit ratio over time, and investigating any downward trend as your catalog or traffic grows, catches this before it shows up as unexplained origin load during a normal week, let alone a busy one.

Load testing before growth forces the issue

k6 is an open-source load testing tool that runs scripted virtual users against your application and reports latency, error rate, and throughput under load (Grafana k6 documentation). The tool defines several distinct test types, each answering a different capacity question (Grafana k6 — API load testing):

Test typeQuestion it answersWhen to run it
Smoke testDoes the system function at all under minimal load?Every deploy, as a sanity check
Load testHow does the system perform under expected, normal traffic?Before and after infrastructure changes
Stress testHow does performance degrade beyond normal peak?Quarterly, ahead of known high-traffic periods
Breakpoint testWhere exactly does the system fail as load ramps up?Once per major architecture change
Soak testDoes performance degrade over hours under sustained load?Before launches expected to run at elevated traffic for days
import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 50 },
    { duration: '5m', target: 50 },
    { duration: '2m', target: 0 },
  ],
};

export default function () {
  http.get('https://staging.example.com/product/123');
  sleep(1);
}

That script ramps to 50 virtual users, holds for 5 minutes, and ramps down, a basic load test shape. Point it at staging with production-representative data volumes, not an empty database, or the results won't reflect what actually happens once real growth arrives.

Read the tail latency, not only the average

An average response time hides the users having the worst experience. A load test that reports p50, p95, and p99 latency separately shows whether 1 request in 100 is timing out even while the median looks healthy, which is exactly the pattern a growing, unevenly loaded system tends to produce. Chasing the average down while ignoring a growing tail is a common way teams declare a system scaled when a meaningful slice of real users are still having a bad time.

What to add at each growth stage

  1. Early stage: a single app server plus managed database is usually fine. Add a CDN for static assets early; it's cheap and removes a whole class of later work.
  2. Second stage: move to a stateless app tier behind a load balancer. This is also when session storage needs to move out of process memory.
  3. Third stage: add read replicas once read query volume is measurably the bottleneck, not before. Premature replication adds operational complexity without payoff.
  4. Fourth stage: run scheduled load and stress tests against staging, sized to your actual growth trajectory, not a guess.
  • Confirm session and file storage are external to the app process before adding a second instance.
  • Confirm your ORM or query layer can route reads to a replica pool distinct from the write connection.
  • Confirm CDN tiered caching is enabled before traffic spreads across more edge regions.
  • Run a load test against a realistic data volume before every major release, not only before an anticipated peak.

FAQ

How is scaling for growth different from handling a traffic spike?

Growth is a steady, predictable curve you architect for ahead of time: horizontal scaling, read replicas, CDN tiering. A spike is a short, sharp burst that needs queueing and graceful degradation, covered in our companion post.

When should I add database read replicas?

When read query volume is the measured bottleneck on your primary database, not preemptively. Replication adds real operational complexity, including handling replication lag in your application logic.

Do I need Kubernetes to scale horizontally?

No. A load balancer plus stateless app instances is the core requirement; container orchestration is one way to manage that fleet, not a prerequisite for horizontal scaling itself.

What's the minimum load test I should run before a growth milestone?

A load test at your expected peak concurrency, against staging with production-representative data, is the minimum. Add a stress test if you want to know the actual breaking point, not only whether the expected peak is safe.

Does a CDN replace the need for backend scaling work?

No. A CDN offloads static assets and cacheable HTML, which reduces origin load, but dynamic, personalized, or write-heavy requests still hit your app and database tier directly.

What's the most common scaling mistake as an app tier grows?

Not revisiting database connection pool sizing as instance count increases. Each new app instance typically opens its own connection pool, and that can exhaust a database's connection limit long before CPU or memory becomes the bottleneck.

References

From the Destm engineering archive. For current work on this topic, start at Solutions or the blog.