DEV Community

Timevolt
Timevolt

Posted on

Scaling Your App: Horizontal vs Vertical — Lessons from *The Matrix*

The Quest Begins (The "Why")

I remember the first time our side‑project went from a quiet weekend hobby to something people actually used. We launched a tiny Node.js API that stored user scores in an in‑memory Map. Life was good — until a popular streamer shouted our URL on stream and traffic jumped from 10 requests per second to 2,000. Suddenly our single EC2 instance started sounding like a dying robot: CPU hit 99%, latency spiked to seconds, and the logs filled with “RHEL: out of memory” messages. I felt like Neo in the construct, dodging bullets that were actually HTTP 503s, wondering if there was a secret cheat code to make the server stop glitching.

That moment forced me to ask: How do we make our app survive when the world decides to notice it? The answer wasn’t just “buy a bigger box.” It was about rethinking scaling itself.

The Revelation (The Insight)

Scaling isn’t a one‑size‑fits‑all power‑up. There are two main flavors:

  • Vertical scaling (scale‑up) – throw more CPU, RAM, or SSD at the same machine. Think of it as upgrading your character’s stats in an RPG. It’s simple: you stop the instance, resize it, start it back up. The upside? No code changes, instant boost. The downside? You hit a hard ceiling — cloud providers only go so big, cost rises exponentially, and you still have a single point of failure. If that machine dies, your whole game ends.

  • Horizontal scaling (scale‑out) – add more identical instances behind a load balancer. Each node runs the same code, shares nothing (or shares only via external stores like Redis or a DB). It’s like cloning Neo so you have a whole team of “The One” agents fighting the agents of traffic. You can add or remove nodes on demand, survive node failures, and often pay less for the same throughput because you’re using many modest machines instead of one monster.

The real insight hit me when I realized our app was stateful in the worst way: it kept user sessions in memory, cached data locally, and even wrote temporary files to the instance’s disk. That made horizontal scaling impossible — any new node would have its own isolated memory, and users would be logged out the moment the load balancer sent them elsewhere. The revelation? Make the app stateless, push state out to external services, and then you can scale out like a boss.

Wielding the Power (Code & Examples)

The Struggle: A Stateful, Single‑Node Server

// server.js – before (the painful version)
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

// ❌ In‑memory session store – dies with the process
const sessions = new Map();

app.use(express.json());

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  // fake auth
  if (username === 'admin' && password === 'secret') {
    const token = Math.random().toString(36).substring(2, 15);
    sessions.set(token, { username });
    return res.json({ token });
  }
  res.status(401).send('Bad credentials');
});

app.get('/profile', (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  const user = sessions.get(token);
  if (!user) return res.status(401).send('Unauthenticated');
  res.json(user);
});

// ❌ Listening on a fixed port – no graceful shutdown handling
app.listen(PORT, () => console.log(`🚀 Server listening on ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

What’s wrong?

  • Sessions live in sessions Map → lost on restart or when a new instance starts.
  • No external DB or cache → can’t share state.
  • No health checks, no SIGTERM handling → Kubernetes would kill the pod ungracefully.
  • Hard‑coded port makes it tricky to run multiple copies behind a load balancer.

The Victory: Stateless, Horizontally Scalable Service

// server.js – after (the scalable version)
const express = require('express');
const redis = require('redis');
const app = express();
const PORT = process.env.PORT || 3000;

// ✅ External session store – Redis (or any shared DB)
const redisClient = redis.createClient({
  url: process.env.REDIS_URL // e.g. redis://redis:6379
});
redisClient.on('error', err => console.error('Redis error:', err));
redisClient.connect();

app.use(express.json());

// Helper to store/retrieve sessions from Redis
async function setSession(token, data) {
  await redisClient.set(`sess:${token}`, JSON.stringify(data), { EX: 60 * 60 }); // 1h TTL
}
async function getSession(token) {
  const raw = await redisClient.get(`sess:${token}`);
  return raw ? JSON.parse(raw) : null;
}

app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  if (username === 'admin' && password === 'secret') {
    const token = Math.random().toString(36).substring(2, 15);
    await setSession(token, { username, loggedInAt: Date.now() });
    return res.json({ token });
  }
  res.status(401).send('Bad credentials');
});

app.get('/profile', async (req, res) => {
  const token = req.headers.authorization?.split(' ')[1];
  const user = await getSession(token);
  if (!user) return res.status(401).send('Unauthenticated');
  res.json(user);
});

// ✅ Graceful shutdown – lets LB drain connections
function shutdown() {
  console.log('🛑 Received shutdown signal, closing Redis…');
  redisClient.quit().then(() => process.exit(0));
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);

app.listen(PORT, () => console.log(`🚀 Server listening on ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

What changed?

  1. External state – Sessions live in Redis, accessible by any instance.
  2. Stateless code – The server itself holds no sticky data; you can spin up 1, 10, or 100 copies.
  3. Health & shutdown – Responds to SIGTERM/SIGINT, letting orchestrators (K8s, ECS, etc.) replace pods without dropping requests.
  4. Config via env – Port, Redis URL, etc., come from the environment, making the same Docker image run anywhere.

Common traps to avoid (the “boss fight” pitfalls):

  • Sticky sessions – Don’t rely on IP‑based affinity; it defeats the purpose of horizontal scaling and creates uneven load.
  • Local file writes – If your app writes uploads or logs to disk, use a shared volume (S3, EFS, or a dedicated storage service) or stream them to a central logging system.
  • Ignoring observability – Add request IDs, structured logs, and metrics (Prometheus) early; otherwise scaling blind feels like fighting a boss with no health bar.

Why This New Power Matters

Once we made the service stateless and shoved state into Redis, the magic happened. We deployed the same Docker image to a Kubernetes cluster with a simple HorizontalPodAutoscaler that watched CPU. When our streamer’s audience surged, the autoscaler spun up extra pods in seconds, the load balancer spread the traffic, and latency stayed under 100 ms. No more frantic midnight SSH calls to resize a single box — our infrastructure now reacted to demand.

Horizontal scaling also gave us zero‑downtime deploys: roll out a new version, the LB gradually shifts traffic to fresh pods, and old ones terminate after finishing in‑flight requests. It felt like finally seeing the Matrix code — green glyphs flowing, understanding exactly how each request moved through the system.

Most importantly, the cost curve flattened. Instead of paying for an over‑provisioned beast that sat idle 90 % of the time, we paid for just enough compute to handle the actual load, scaling down during quiet periods. It’s the difference between buying a sports car you only drive to the grocery store and having a fleet of efficient rides that show up when you need them.

Your Turn: Embark on the Quest

Here’s a challenge to level up your own app: Take a small service you’ve got running on a single VM or bare metal, containerize it, push the image to a registry, and deploy it to a free tier Kubernetes offering (like Google Cloud’s Autopilot or Azure’s AKS with burstable nodes). Add a Redis instance (managed or a simple Docker‑compose for local testing) and move any session or cache state out of the process. Then hammer it with a tool like hey or k6 and watch the autoscaler kick in.

When you see those new pods appear and the latency stay flat, you’ll know you’ve leveled up — just like Neo finally dodging those bullets and seeing the world for what it really is. Happy scaling! 🚀

Top comments (0)