Every time a team runs into performance trouble, someone in the room suggests breaking the monolith into microservices. In my experience working with teams across startups and large enterprises, this is rarely the right first move. Most monoliths that are struggling under load are not struggling because they are monoliths. They are struggling because nobody has applied the basic scaling techniques that have worked for decades: more resources, more instances, caching, and offloading work to background processes.
This article walks through the scaling techniques I reach for when a monolithic system starts showing strain, in roughly the order I try them. None of this requires rewriting your application into a dozen services. It requires understanding where your actual bottleneck is and applying the right fix for that bottleneck.
Understanding where the pain shows up
A monolith deploys as a single unit. That gives you real advantages: one build pipeline, one place to debug, and no network calls between modules that used to be simple function calls. The trade-off shows up as your system grows. Database queries that were instant with a few hundred rows start taking seconds with a few million. API endpoints that handled a hundred concurrent users start timing out at a thousand. Build times stretch out as the codebase grows.

These are not signs that the architecture is wrong. They are signs of success, and every system that survives long enough hits them. The question is not whether to scale, it is which lever to pull first.
Vertical scaling: the first lever, not the last resort
Vertical scaling means giving your existing machine more CPU, memory, or faster disks. It is the simplest option on the list, and teams underuse it because it feels unglamorous next to a redesign. Before you touch your architecture, check whether a bigger box solves the problem.

This works well when you can point at a specific bottleneck. If CPU utilization sits above 80 percent consistently, more cores help immediately. If your database is I/O bound, moving to faster storage or a larger memory footprint for caching often fixes the symptom the same day. On Azure, resizing an App Service plan or scaling up an Azure SQL tier is a configuration change, not a deployment. That is the appeal: no new failure modes, no new deployment pattern, your existing monitoring and debugging tools keep working exactly as before.
The limits are real too. Cloud providers cap instance sizes, and cost tends to rise faster than the resources you get once you pass the mid-range SKUs. Vertical scaling also does not give you redundancy. A single bigger machine is still a single point of failure, and a deployment still means a moment of downtime unless you pair it with something else.
Watch for a few signals that tell you it is time to move past this stage: costs climbing faster than your user base, a real need for fault tolerance that a single instance cannot provide, deployment downtime that is now visible to customers, or your largest practical instance size already running close to 70 percent utilization under normal load.
Horizontal scaling: multiple instances behind a load balancer
Horizontal scaling runs several copies of your application behind a load balancer. It is the natural next step once vertical scaling runs out of room, and it buys you two things vertical scaling cannot: fault tolerance, because one instance going down does not take the system with it, and close to linear capacity growth as you add more instances.

The catch is architectural, not infrastructural. Every instance needs to handle any request without depending on state stored only on that instance. In practice this means moving to token-based authentication such as JWTs instead of server-side sessions pinned to one machine, and moving any cached data into a distributed cache like Redis instead of an in-process cache. Teams that skip this step end up with sticky sessions and load balancers configured to always route a user back to the same instance, which quietly defeats half the point of scaling horizontally.
You will also need something in front of your instances to distribute traffic. nginx is a solid open source choice when you want full control over routing rules. YARP is Microsoft’s reverse proxy and fits naturally into a .NET stack if you want to stay in a familiar toolchain. If you are already on a cloud provider, their managed load balancer, such as Azure Application Gateway, AWS ALB, or Google Cloud Load Balancing, is usually the path of least resistance since it comes with health checks and autoscaling hooks built in.
Once this is in place, you get rolling deployments with no downtime, because you take instances out of rotation one at a time, and you get to scale down during quiet hours to control cost. The honest caveat: converting a genuinely stateful application into a stateless one is often a multi-sprint effort, not a configuration flag. Budget real time for it rather than treating it as an afterthought.
Database scaling: where most monoliths actually hit a wall
In my experience, the database is where scaling problems show up first and hurt the most, because unlike the application tier, you cannot just spin up an identical copy and expect it to behave the same way. There are three techniques worth knowing, each solving a different shape of problem.
Read replicas
A read replica is a copy of your primary database that serves read-only traffic, so your reporting queries and dashboard reads stop competing with write traffic on the primary. Data flows one way, from primary to replica, and there is always some lag between a write landing on the primary and showing up on the replica.
That lag is the trade-off you are accepting: eventual consistency in exchange for read throughput. It matters for any screen that must show data immediately after a write, such as a user updating their own profile and expecting to see the change on the next page load. Route that kind of read back to the primary and send everything else, like search results, listing pages, and reports, to the replica.

Azure SQL, AWS RDS, and most managed database services set up replication, monitoring, and failover for you, which is why this is usually the first database scaling move teams make. Keep an eye on replication lag under peak write load, on how many replicas your write volume can realistically support before replication itself becomes the bottleneck, and on cost, since every replica is a full additional database instance on your bill.
Materialized views
Read replicas help when the problem is read volume. They do not help when the problem is query complexity, such as an analytical query that joins six tables and aggregates millions of rows, and stays slow even on a replica with no write contention at all. That is the job for a materialized view.
A materialized view is a precomputed result set stored as if it were a table, unlike a regular view that recalculates its result on every query. You get very fast reads because the expensive computation already happened, but you take on the job of deciding when to refresh it. Materialized views work well for dashboards that update on a schedule, monthly aggregations, and denormalized read models built for one specific screen. Refresh too often and you are back to putting load on the database. Refresh too rarely and users start looking at stale numbers, so this decision needs to be made deliberately for each view rather than left at a default.
Database sharding
Sharding is the last database technique to reach for, and for good reason: it is the most invasive. You split your data across multiple independent database instances, each holding a distinct slice of the data, and your application needs to know which shard to query for any given piece of data.
Range-based sharding splits data by ranges of a key, for example customers A through M on one shard and N through Z on another. It is intuitive and works well for naturally ordered data like dates, but it creates hotspots when activity is not evenly spread across the ranges. Hash-based sharding runs a sharding key through a hash function to pick a shard, which distributes load more evenly, but makes range queries across shards awkward since related data is scattered by design. Tenant-based sharding gives each customer or tenant their own database, which is the cleanest option for multi-tenant SaaS products because it gives you natural data isolation, at the cost of making any cross-tenant reporting query genuinely harder to write.

Do not reach for sharding before you need it. It removes the ability to run a simple join or a single transaction across what used to be one database, and every query pattern in your application needs to account for shard routing from that point on. Exhaust read replicas and materialized views first.
Caching: the highest return for the least effort
Of everything in this article, a good caching strategy usually gives you the best improvement for the least architectural disruption. Caching happens at several layers at once: browser caching cuts down repeat network requests, a CDN puts static content physically closer to users, an application-level cache like Redis holds frequently read data in memory, and query caching avoids repeating expensive computations.
The technique that matters most is picking what to cache. Data that is read often and changes rarely, such as product catalog entries or configuration values, is the ideal candidate and will give you sub-millisecond reads instead of a database round trip. Data that changes on every write is a poor candidate, because you will spend more effort on cache invalidation than you save on reads. Azure Cache for Redis, AWS ElastiCache, and similar managed offerings remove the operational burden of running Redis yourself, which is worth it once your caching layer becomes load-bearing rather than a nice-to-have.
Message queues for anything that does not need to happen right now
Message queues let you defer work instead of doing it inline in the request. A user uploads a file, you acknowledge the upload immediately, and a background worker processes it a moment later. This keeps your API responsive under load and gives you a natural buffer during traffic spikes.
This pattern is a good fit for processing uploaded files, sending emails and notifications, generating reports, updating search indexes, and running batch jobs. None of these need to block the response the user is waiting for. The real value shows up during a spike: instead of your API falling over trying to process everything synchronously, the queue absorbs the burst and your workers drain it at a pace your infrastructure can sustain.
Putting it together
None of these techniques compete with each other. Scaling a monolith successfully is about applying the right one at the right time, not about picking a single strategy and betting the whole system on it. A practical order to work through looks like this in most systems I have seen:
- Optimize your code and database queries first, before adding infrastructure
- Add caching where the access pattern actually supports it
- Scale vertically until it stops being cost-effective
- Move to horizontal scaling once you need redundancy, not just capacity
- Introduce message queues to take background work off the request path
- Consider database sharding only when data size genuinely demands it
A well-designed monolith, with a subset of these techniques applied deliberately, can carry far more load than most teams expect. The teams that struggle are usually the ones that jumped to microservices before applying any of this, and ended up with the operational complexity of a distributed system without first solving the problems that made them want to scale in the first place.
Do not let the search for a perfect architecture stop you from shipping the simple fix that solves today’s bottleneck. Start simple, measure what is actually slow, and add complexity only when the data tells you to.
Leave a Reply