Skip to content
ExponaBLOG
ENGINEERING CRAFT · DAY 005 / 100

Scale up or scale out: a practical guide for developers

Bigger machines or more machines — and when each wins.

Expona Admin7 min read

You've built a service that works perfectly for your team, but now 50,000 users are hitting it daily. Your options narrow to two: throw bigger hardware at the problem, or split the load across multiple machines. Scale up or scale out: a practical guide for developers means understanding when each strategy wins, and more importantly, when you've already chosen wrong.

Scaling up is simplest but has a hard ceiling

Vertical scaling—moving to a larger machine—is the path of least resistance. You increase CPU cores, add more RAM, boost disk throughput, and your application suddenly feels fast again. No code changes. No distributed systems headaches. Your single-server deployment keeps running exactly as it did on day one.

This works until it doesn't. Cloud providers have ceilings: AWS tops out at c7i.metal instances with 128 vCPUs and 1 TB of RAM. Physical hardware has physics. You cannot make a single machine arbitrarily powerful, and the cost per unit of compute becomes prohibitive as you approach the maximum. A machine with 256 GB of RAM costs roughly three times what a machine with 64 GB costs, but you don't get three times the throughput because bottlenecks shift: network cards saturate, OS scheduling overhead increases, cache coherency becomes a factor.

Real scenario: a fintech platform processing trade confirmations started on a t3.large. At 10,000 trades per minute, they upgraded to a c6i.4xlarge (16 vCPUs, 32 GB). At 25,000 trades per minute, they moved to a c6i.metal. Beyond that point, a single machine couldn't keep up, and buying two metals wouldn't help because their code assumed one database connection pool, one in-memory cache, one set of state.

Scale up when: you're still within the hardware ceiling, you need time to architect for distribution, or your workload genuinely doesn't benefit from horizontal distribution (though this is rare). Scale up until it hurts—but know where the ceiling is before you paint yourself into it.

Scaling out needs stateless apps and a load balancer

Horizontal scaling means running your application on many machines and distributing traffic across them. This breaks the simplicity of the single-server model but offers an escape hatch from hard limits. Instead of one c6i.metal handling all traffic, you run ten c6i.large instances behind a load balancer.

The prerequisite: your application must be stateless, or at least treat state as ephemeral. If requests depend on server-local memory, sticky sessions, or in-process locks, you cannot transparently add more instances. Every HTTP request must be answerable by any instance.

Here's a stateful mistake: caching user sessions in memory and relying on session affinity.

typescript
// ❌ Stateful: won't scale out
const sessionCache = new Map();

app.post('/login', (req, res) => {
  const user = authenticateUser(req.body.email, req.body.password);
  const sessionId = generateId();
  sessionCache.set(sessionId, { userId: user.id, loginTime: Date.now() });
  res.cookie('sessionId', sessionId);
  res.send({ ok: true });
});

app.get('/profile', (req, res) => {
  const session = sessionCache.get(req.cookies.sessionId); // Fails on different instance
  if (!session) res.status(401).send('Not logged in');
  res.send({ userId: session.userId });
});

If a user logs in on instance A and then their next request lands on instance B, instance B has no record of their session. The stateless version moves state to an external store:

typescript
// ✓ Stateless: scales out
const redis = new Redis({ host: 'redis.internal' });

app.post('/login', async (req, res) => {
  const user = authenticateUser(req.body.email, req.body.password);
  const sessionId = generateId();
  await redis.setex(`session:${sessionId}`, 86400, JSON.stringify({ userId: user.id }));
  res.cookie('sessionId', sessionId);
  res.send({ ok: true });
});

app.get('/profile', async (req, res) => {
  const session = await redis.get(`session:${req.cookies.sessionId}`);
  if (!session) res.status(401).send('Not logged in');
  res.send({ userId: JSON.parse(session).userId });
});

Now any instance can service any request. Add or remove instances as load dictates. A load balancer (AWS ELB, nginx, HAProxy) distributes traffic round-robin or by connection count.

Real scenario: a ride-hailing platform started with three identical NestJS instances behind an AWS Application Load Balancer. Session state lives in Redis; cache invalidation spreads via Redis Pub/Sub. When driver demand spikes on Friday nights, they auto-scale to eight instances. Monday mornings, it shrinks back to three. Total cost drops 40% compared to provisioning for peak capacity 24/7.

The hidden cost: you now own Redis availability, load balancer configuration, health check logic, and instance orchestration. Complexity rises. But you've broken through the hardware ceiling.

The database is usually the real bottleneck

Here's where most scaling strategies collapse: developers scale the application tier horizontally, relying on stateless code and load balancers, only to watch the database become the chokepoint. Five instances hitting a single Postgres machine means five times the connection overhead, five times the lock contention, five times the concurrent queries.

Scale out your application all you want. If the database can't keep up, you've moved the bottleneck, not solved it.

Strategies for database scaling:

  • Read replicas: Route read-heavy queries to replicas while keeping writes on the primary. Works well for e-commerce product browsing or SaaS dashboards where reads outnumber writes by 10:1.
  • Caching layer (Redis/Memcached): Store hot data in memory. Reduces database queries. Adds complexity around cache invalidation.
  • Sharding: Partition data by key (user ID, tenant ID) and distribute across multiple database instances. Queries stay local; joins across shards require application logic.
  • Denormalization: Pre-compute and store aggregations. Trade consistency for speed. Requires async update pipelines.
  • Move to a different database: Sometimes Postgres isn't the right fit. ElasticSearch for full-text search, DynamoDB for high-throughput key-value, MongoDB for flexible schemas.

Real scenario: a travel booking platform using Postgres noticed that fetching the user's past bookings took 800ms because the query scanned five joined tables. Horizontal scaling of the app tier didn't help. They added Redis caching for user bookings (invalidated on purchase), cutting latency to 20ms. That single change let them reduce database instances from two to one for six months.

Most of the time, database scaling is where your real investment lives. Application scaling is the easy part.

Scale up until it hurts; design to scale out from day one

The practical approach: start vertical. A single machine is simpler to reason about, easier to debug, and costs less than two machines. Use it until you hit limits—CPU pegged, RAM full, or disk throughput maxed. Then move to horizontal scaling.

But "design to scale out from day one" means building the foundations now, even if you don't need them yet. This means:

  • Keep application state external (Redis, database, or stateless). Don't build in-memory session stores that will haunt you later.
  • Use connection pooling from the start. A single connection per request works on a dev machine but collapses at scale.
  • Write idempotent endpoints. If a request is retried or load-balanced across instances, ensure the outcome is the same.
  • Instrument logging and tracing. When you scale to ten instances, you can't SSH in and read logs. Use structured logging (JSON) and a log aggregator.
  • Separate read and write paths at the database layer (even if using a single physical database). This makes adding read replicas trivial later.

You don't need a message queue, Kubernetes, or distributed tracing on day one. But you should have the architecture that makes adding them painless. This is the "design to scale out from day one" principle: not overengineering, but avoiding architectural traps.

Common mistakes

  • Scaling out without statelessness: Adding load balancers and instances while keeping server-side state in memory. Sessions disappear on failover. Caches become inconsistent. Debugging becomes nightmare territory.
  • Ignoring the database until it's too late: Application layer scales smoothly to ten instances, then the database becomes the wall. By then, you're committed to many services. Identify database limits early.
  • Premature horizontal scaling: Running five small instances when one medium instance would be cheaper and simpler. Complexity tax is real. Stay vertical until the pain is obvious.
  • No monitoring of per-instance metrics: When you scale out, you lose visibility. One instance might be handling 90% of traffic while others idle. Uneven distribution wastes money and masks real bottlenecks.
  • Assuming load balancer handles retries automatically: A crashed instance doesn't always drain connections gracefully. Requests can fail mid-processing. Design idempotence at the application level.

Key takeaways

  • Vertical scaling (bigger machines) is the simplest starting point but hits hardware ceilings quickly. Cost per compute becomes prohibitive at the top end.
  • Horizontal scaling (more machines) requires stateless applications and external state storage. It breaks the simplicity ceiling but introduces operational complexity.
  • The database is almost always the real bottleneck. Scale the application tier all you want; you're just moving the problem downstream until you address database limits.
  • Start vertical and scale up until you feel the pain. But design to scale out from day one by keeping state external, using connection pooling, and writing idempotent endpoints.
  • Monitoring per-instance metrics is critical at scale. Uneven load distribution hides real problems and wastes infrastructure spend.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles