Scaling a system from zero to a million users is a rite of passage for engineering teams. I've been through it a few times now, and every time I'm reminded that most scaling advice is wrong in one crucial way: it focuses on technology choices when the real challenge is architectural decisions made months or years earlier.
The Database Decision Matters More Than Anything Else
The single hardest thing to change in a growing system is the data layer. I've seen teams choose MongoDB because it's easy to get started, then spend six months migrating to PostgreSQL when their data became relational. I've seen teams pick PostgreSQL for everything, then struggle with write throughput when they hit 50,000 writes per second. There is no perfect database, but there are wrong choices for your specific use case.
My recommendation: start with PostgreSQL for almost everything. It handles relational data, JSON, full-text search and geospatial queries well enough that you won't need to add another database for a long time. Add Redis for caching and session management when you need it. Add a dedicated message queue like RabbitMQ or Kafka when you have async workloads. Add a document store or search engine only when you have a concrete need that PostgreSQL can't meet. Every additional infrastructure component adds operational complexity. Don't adopt it before you need it.
Caching Is Not Optional
I've never seen a system scale to a million users without caching, but I've seen many teams implement caching poorly. The most common mistake is caching too aggressively and serving stale data. The second most common mistake is using cache invalidation strategies that are more complex than the problem they solve.
A pragmatic approach: cache at the HTTP level first (CDN for static assets, reverse proxy for API responses). Then add application-level caching for expensive computations and database queries. Use Redis or Memcached with a TTL-based strategy. If you need cache invalidation, keep it simple: invalidate by key pattern when data changes, not by trying to track every dependency.
Observability Is What Saves You at 2 AM
When your system is handling requests from a million users, things will break. Not if, but when. The difference between a bad outage and a manageable incident is whether you can answer three questions quickly: what broke, who is affected and what caused it.
You need three things: structured logging with correlation IDs so you can trace a request across services, metrics that track latency, error rates and throughput for every service, and distributed tracing so you can see where time is spent in a request path. Start with these from day one. Retrofitting observability into a production system is painful and expensive.
One concrete recommendation: use OpenTelemetry. It's becoming the industry standard and works across languages and frameworks. Instrument your code from the start, even if you don't have a backend to receive the traces yet. It's much easier to add OTel spans when writing code than to go back and add them later.
The Team Scaling Problem
Scaling the team is harder than scaling the technology. When you go from 3 engineers to 30, communication overhead grows quadratically. The architecture that made sense with a small team, where everyone can change anything, monorepo, shared database, becomes a liability.
The pattern that works: organize around bounded contexts. Each team owns a set of services, databases and APIs. Teams communicate through well-defined interfaces, not through shared code or direct database access. This isn't about microservices specifically; it's about clear ownership boundaries. A well-organized monolith with clean module boundaries can scale better than a poorly organized microservice architecture.
You don't need to predict the future. You need to design a system that can be changed. Loose coupling, good test coverage, clear APIs and comprehensive observability give you the ability to refactor and reorganize as you learn what your system actually needs to be.
