Technology

Building scalable web applications

Scalability is usually the thing nobody talks about until the app is slow on a Monday morning. I have learned that a few early decisions cost almost nothing on day one and save a painful rewrite later. This is how I approach it.

What scalable actually means

A scalable app keeps behaving the same way as users, data and traffic grow. Response times stay steady, errors stay rare, and adding capacity is a config change rather than a refactor. It is not about handling millions of users today. It is about not painting yourself into a corner.

Scale out, not just up

There are two ways to add capacity. You can buy a bigger server, which is quick but has a ceiling and leaves you with one machine that can fail. Or you can run more servers behind a load balancer, which takes a bit more setup but keeps growing with you. I design for the second option even when the first deployment is a single box.

Keep servers stateless

Horizontal scaling only works if any server can answer any request. That means nothing important lives in a server's memory.

  • Store sessions in a shared store such as Redis, or use signed tokens.
  • Put uploaded files in object storage, never on the local disk.
  • Make every request carry what it needs, so a restart or a new instance changes nothing.

Give the database room to breathe

In most apps I have worked on, the database becomes the bottleneck long before the application code does. A few habits go a long way.

  • Index the columns you filter and sort by, and check query plans for anything slow.
  • Use connection pooling so a traffic spike does not exhaust connections.
  • Avoid N+1 queries by loading related data in one go.
  • Add read replicas when reads clearly outnumber writes.

Cache in layers

The fastest request is the one that never reaches your server. I think of caching as a stack, each layer catching what the one above missed.

  • Browser: proper cache headers for static assets.
  • CDN: pages and files served close to the user.
  • Application: expensive results kept in Redis with a sensible expiry.
  • Database: query results reused for data that rarely changes.

The hard part is invalidation, so I keep cache keys simple and expiry times honest rather than clever.

Move slow work to the background

Sending emails, generating reports and calling third party APIs should not hold a user's request open. Push them onto a queue and let workers handle them.

// In the request handler
await queue.add("send_welcome_email", { userId: user.id });
return res.status(201).json(user);

// In a separate worker process
worker.process("send_welcome_email", async (job) => {
  const user = await db.user.findById(job.data.userId);
  await mailer.sendWelcome(user);
});

The user gets a fast response, and if the email provider is down, the job simply retries later.

Measure before you optimise

You cannot scale what you cannot see. I track response times, error rates, slow queries and server resources from the start, even with basic tooling. When something needs fixing, the numbers point to it.

Wrapping up

None of this means building for a million users on day one. Start simple, keep servers stateless, watch the metrics, and scale the part that is actually under pressure. The goal is an app that can grow, not one that already has.

Back to Thoughts

Contact me

Send me an email or message me on LinkedIn if you're looking for someone who builds without the fluff.

Open to work
Please wait...