What scaling a monolith means and why the database is usually the bottleneck

Scaling a monolith means finding the specific resource that runs out first under load and removing that limit. The resource might be CPU, memory, database connections, disk I/O, or lock contention. Changing the architecture is not the goal. A single deployable application can serve a lot of traffic if you keep removing these limits one at a time.

The limit is usually the primary database, not the application code. Application servers hold little shared state, so you can run more copies of them. The database is different. It holds the shared mutable state, it accepts every write, and it enforces consistency through locks and transactions. It also has a finite connection limit that every new app instance draws from. Adding app servers often makes database pressure worse.

Teams often split into microservices too early because slow requests look like an architecture problem. If several services still read and write the same tables, the split adds network hops and deployment overhead. The database bottleneck is still there.

This guide is for backend and platform engineers running one deployable application against one primary relational database. It follows a rough order, with cheaper and more reversible steps first:

  1. Measure to find the saturated resource.
  2. Fix cheap problems such as missing indexes and N+1 queries.
  3. Scale vertically.
  4. Make the app stateless and scale horizontally.
  5. Add caching.
  6. Add read replicas.
  7. Move work into background jobs.
  8. Split services only after these steps stop working.

Who should use this approach

This approach fits teams that already run a working monolith and see latency, error rates or cost rising with traffic. It suits you if most of the following are true:

  • One deployable and one primary relational database. The techniques here assume shared tables, a single write path and an ORM or query layer you control.
  • A small or medium engineering organization. If a few teams share one codebase and can still coordinate releases, the operational cost of many services is hard to justify.
  • Domain boundaries that are still moving. If you don't yet know where clean service lines fall, splitting now fixes guesses into network contracts. Scaling the monolith buys time to learn the boundaries.
  • Load that is read-heavy or concentrated in a few hot paths. Indexes, caching, replicas and background jobs address exactly this shape of load.
  • Limited operations capacity. Teams without dedicated platform staff get more from a bigger instance and a connection pooler than from a fleet of services, each with its own deploy pipeline, monitoring and on-call.

It also suits teams that have already decided to split later. Each step here either removes a bottleneck or makes the code easier to extract, so none of the work is wasted.

How it works: the measure–fix–remeasure loop

The method is a loop, not a migration plan. Each pass looks like this:

  1. Measure under realistic load. Collect per-endpoint latency percentiles, CPU and memory per host, database connection counts, pool wait time, lock waits, slow queries and replication lag.
  2. Identify the single saturated resource. Pick the one that hits its limit first. Fixing anything else won't change the user-visible result yet.
  3. Apply the cheapest fix that removes that limit. A missing index comes before a bigger instance, a bigger instance before horizontal scaling, and horizontal scaling before a cache or replica.
  4. Measure again. The fix either moves the bottleneck somewhere else or shows that the diagnosis was wrong. Either result tells you what to do next.

Each technique works by taking load off the scarcest resource:

  • Indexes and query fixes cut the work per request, so the database does less for the same traffic.
  • Vertical scaling raises the ceiling of one host without changing the topology.
  • Stateless horizontal scaling spreads application work across interchangeable instances behind a load balancer.
  • A connection pooler keeps the number of database server connections fixed while application instances multiply.
  • Caching answers repeated reads without reaching the database.
  • Read replicas move reads that tolerate some staleness off the primary.
  • Background jobs take slow, non-interactive work out of the request path and let it run at a controlled rate.

The loop stops in one of two places. Either the system meets its latency and cost targets, or the remaining bottleneck is one these techniques can't remove, such as write volume on the primary or an organizational constraint. That second outcome is the evidence for a split, covered at the end of this guide.

Measure first: find the saturated resource

Before buying hardware or redrawing service boundaries, find out which resource is actually saturated. Averages hide the problem, so track p50, p95 and p99 latency per endpoint. A p99 that climbs while CPU stays flat usually means the app is waiting on something: database connections, row locks or disk I/O. If CPU is pinned and latency rises evenly across endpoints, compute is the constraint. Check connection pool wait time, lock waits and memory pressure alongside request timing, because each one points to a different fix.

Start with the slow query log

Enable the database's slow query log and sort the results by total time (count × duration), not by the slowest single query. A 20 ms query that runs on every request costs more than a 4-second report that runs once an hour. Run EXPLAIN on the worst offenders. For example, EXPLAIN SELECT * FROM orders WHERE customer_id = 42 may show a sequential scan over the whole orders table. After adding an index on customer_id, the same plan shows an index scan that touches only the matching rows.

Fix the cheap problems first

  • N+1 queries: Loading 50 orders and then calling order.customer inside a loop issues 51 queries. Eager loading (a join, or a single WHERE id IN (...)) reduces this to one or two queries.
  • Unbounded result sets: Paginate any query that can grow with the size of your data.
  • Oversized payloads: Select only the columns the endpoint uses, and stop serializing nested objects the client never reads.

These fixes take hours, and they often remove the bottleneck that would otherwise have justified weeks of architectural work.

-- PostgreSQL. Rank queries by total time, not by the slowest single run.
-- Requires the pg_stat_statements extension (column names are for PG 13+).
SELECT calls, round(total_exec_time) AS total_ms,
       round(mean_exec_time, 1) AS mean_ms, left(query, 60) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- Inspect a hot query's plan (illustrative output, costs omitted).
EXPLAIN SELECT id, status, total FROM orders WHERE customer_id = 42;
--   Seq Scan on orders
--     Filter: (customer_id = 42)        <- reads every row in the table

-- CONCURRENTLY avoids blocking writes while the index builds.
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);

EXPLAIN SELECT id, status, total FROM orders WHERE customer_id = 42;
--   Index Scan using idx_orders_customer_id on orders
--     Index Cond: (customer_id = 42)    <- reads only matching rows
-- (The planner may choose a Bitmap Heap Scan instead; either is fine.)

-- N+1 BEFORE: what an ORM sends when it lazy-loads order.customer in a loop.
SELECT id, customer_id, total FROM orders ORDER BY created_at DESC LIMIT 50;
SELECT id, name FROM customers WHERE id = 17;  -- repeated once per order:
SELECT id, name FROM customers WHERE id = 23;  -- 1 + 50 = 51 round trips
-- ...

-- N+1 AFTER: eager loading as one join, bounded and limited to needed columns.
SELECT o.id, o.total, c.id AS customer_id, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.created_at DESC
LIMIT 50;

This PostgreSQL example ranks queries by total execution time with pg_stat_statements, then compares a query plan before and after adding an index on customer_id. It ends by replacing an N+1 lazy-loading pattern with a single bounded join.

Vertical scaling, then stateless horizontal scaling

When measurement shows a box is simply out of CPU or memory, moving to a larger instance is a legitimate first step. It needs no code changes and adds no new failure modes. Its ceiling is real, though. Hardware tops out, price per core climbs steeply at the high end, one machine remains a single point of failure, and a resize usually means a restart or failover window.

Horizontal scaling removes those limits only if any instance can serve any request. That means moving state off the host:

  • Sessions go into a shared store, or become signed tokens that each instance verifies without a lookup.
  • Uploads go to object storage, not local disk.
  • Scheduled jobs run once, through leader election or a single dedicated worker, not on every instance.
  • A load balancer sends traffic only to instances that pass health checks.

Some state bugs appear only once a second instance exists. In-process caches drift apart, rate limiters count per instance and let through several times the intended limit, one request writes a temp file that the next request on another host can't find, and a nightly cron sends every email three times.

Treat connection multiplication as a hard constraint. Illustrative estimate: 12 instances × 4 worker processes × a pool of 10 = 480 connections. If the database is configured for 300, scaling out takes it down. Size pools per process, not per instance. Put a transaction-level connection pooler in front of the database so hundreds of client connections share a small, fixed set of server connections.

; pgbouncer.ini: one pooler between all app instances and PostgreSQL
; Illustrative estimate, not a measured result:
;   12 instances x 4 worker processes x pool of 10 = 480 client connections
;   PostgreSQL max_connections = 300, so 480 direct connections would exceed it
;   With this pooler, PostgreSQL sees at most 40 + 5 = 45 server connections
;   from the app, leaving room for migrations, admin sessions and replicas

[databases]
; Clients connect to the pooler as "appdb". The pooler forwards to the real server.
appdb = host=10.0.1.20 port=5432 dbname=appdb

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

; A server connection returns to the pool after each transaction.
; Session state (SET, advisory locks, LISTEN, temp tables) does not
; carry over between transactions, so the app must not rely on it.
pool_mode = transaction

; Accept every app-side connection, with headroom for scale-out (480 needed)
max_client_conn = 600

; Server connections per user/database pair: the number PostgreSQL actually sees
default_pool_size = 40

; Extra connections allowed briefly when clients wait longer than reserve_pool_timeout
reserve_pool_size = 5
reserve_pool_timeout = 3

; Close server connections that sit idle, so the pool shrinks after spikes
server_idle_timeout = 600

A PgBouncer configuration in transaction mode that lets 480 illustrative application connections share a fixed pool of about 45 PostgreSQL server connections.

Caching layers and read replicas

Once connections are under control, the cheapest way to cut database load is to stop asking it the same question over and over. Choose each cache layer by two things: who can share the result, and how stale it is allowed to be.

Layer Scope Typical data Staleness risk Invalidation
HTTP/CDN All clients, at the edge Public pages, assets, anonymous API responses Bounded by Cache-Control max-age TTL; purge on content change
Shared store (Redis, Memcached) All app instances Expensive query results, aggregates, rendered fragments Per-key TTL TTL plus delete-on-write
In-process One process Config, feature flags, small reference tables Each instance drifts on its own Short TTL or restart

Cache-aside works like this: read the key, and on a miss, query the database and write the result back with a TTL. Give each key family, such as user:{id}:profile, its own TTL and a named write path that deletes the key. Delete the key instead of updating it, because two writers updating the cache at the same time can leave the older value behind.

When a hot key expires, every concurrent request misses together and hits the database. To prevent this stampede, add jitter to TTLs, or let one request rebuild the value while the others wait or get the stale copy. A cache outage is the same failure across all keys at once. Keep cache timeouts short, and either size the database to survive a cold cache or shed load when it can't.

For read replicas, route reads explicitly in code: open a separate replica handle and pick it at the call site. Don't let a proxy guess from the SQL. Paths that must read the user's own writes stay on the primary, such as the page shown right after a save. Monitor replication lag continuously, and take a replica out of rotation when lag goes over the staleness your reads can tolerate.

import json
import random
import redis

cache = redis.Redis(host="cache.internal", port=6379,
                    socket_timeout=0.05, socket_connect_timeout=0.05)

PROFILE_TTL = 300  # seconds; TTL chosen for this key family

def profile_key(user_id):
    return f"user:{user_id}:profile"

def get_profile(user_id, replica):
    key = profile_key(user_id)
    try:
        cached = cache.get(key)
        if cached is not None:
            return json.loads(cached)
    except redis.exceptions.RedisError:
        pass  # cache outage: fall through to the DB (size it or shed load for this)
    profile = replica.load_profile(user_id)  # explicit replica handle, chosen here
    try:
        # Jitter spreads expiries so hot keys don't all miss at the same moment.
        cache.set(key, json.dumps(profile), ex=PROFILE_TTL + random.randint(0, 60))
    except redis.exceptions.RedisError:
        pass
    return profile

def update_profile(user_id, changes, primary):
    primary.save_profile(user_id, changes)  # writes always go to the primary
    try:
        cache.delete(profile_key(user_id))  # delete, don't update: avoids racing writers
    except redis.exceptions.RedisError:
        pass  # if delete fails, the TTL still bounds staleness
    # Read-your-own-write: the page shown after save reads from the primary.
    return primary.load_profile(user_id)

A cache-aside read path with per-family TTL and jitter, delete-on-write invalidation, fail-fast handling of cache errors, and explicit replica versus primary routing for reads that must see the user's own write (replica and primary are the app's own data-access handles).

Move work out of the request path with jobs and workers

Some of the load on the primary isn't reads at all. It's work that runs inside a request but doesn't have to: sending email, generating PDFs, calling slow third-party APIs, recomputing aggregates. Move that work onto a job queue and let worker processes handle it, and request latency then depends only on what the user actually waits for.

The workers should run the same codebase as the web tier, started with a different entry point. You get a second deployment of the monolith that you can scale on its own, give a different instance size, or pause during an incident, all without a network contract, a separate repository, or duplicated domain logic. In practice it is the module boundary a service split would give you, at a fraction of the cost.

Queues usually guarantee at-least-once delivery, so a job can run twice. A worker can crash after doing the work but before acknowledging it. Every job therefore has to be idempotent. A minimal pattern:

  1. Enqueue the job with a stable key derived from the business event, such as the order ID plus the action.
  2. In one transaction, insert that key into a processed-jobs table with a unique constraint and apply the side effect.
  3. If the insert hits the constraint, acknowledge the job and skip the work.

For external calls that can't join your transaction, pass the same key as the provider's idempotency key where one is supported.

Retry with exponential backoff and a maximum attempt count. After the last attempt, move the job to a dead-letter queue instead of dropping it.

Monitor queue depth, the age of the oldest job, and dead-letter volume. A failed request produces an error page someone sees. A failed job fails quietly unless you alert on these numbers.

import psycopg


def job_key(order_id: int, action: str) -> str:
    # Derive the key from the business event rather than a random UUID, so a
    # redelivered or accidentally re-enqueued job maps to the same key.
    return f"order:{order_id}:{action}"


def handle_create_invoice(conn: psycopg.Connection, order_id: int) -> None:
    key = job_key(order_id, "create_invoice")
    # Claim the key and apply the side effect in one transaction, so both
    # commit together or neither does.
    with conn.transaction():
        cur = conn.execute(
            "INSERT INTO processed_jobs (job_key) VALUES (%s) "
            "ON CONFLICT (job_key) DO NOTHING",
            (key,),
        )
        if cur.rowcount == 0:
            # An earlier delivery already did the work. Skip it and let the
            # caller acknowledge the message.
            return
        conn.execute(
            "INSERT INTO invoices (order_id, total_cents) "
            "SELECT id, total_cents FROM orders WHERE id = %s",
            (order_id,),
        )
    # A normal return means the worker may acknowledge the job. If this raises,
    # the transaction rolls back and the key is released. The queue's retry
    # policy (backoff, max attempts, then dead-letter) decides what happens next.

A worker job handler that claims a stable business-event key in a processed-jobs table and writes the invoice in the same transaction, so a redelivered job is acknowledged without being applied twice.

Impact on cost, engineering time, and the limits of these techniques

Technique Effort Code change Operational surface added Bottleneck addressed
Vertical scaling Hours None None (bigger instance, same topology) CPU, memory, disk I/O on one host
Stateless horizontal scaling Days Move sessions and local files out of the process Load balancer, deploy coordination App-tier CPU and concurrency
Connection pooler Days Minimal; watch session-level features One more hop that can fail Database connection exhaustion
Caching Weeks Invalidation logic in every write path Cache cluster, stampede and staleness handling Repeated reads of hot data
Read replicas Weeks Route reads; handle replication lag Replica monitoring, failover Read load on the primary
Jobs and workers Weeks Split handlers, make jobs idempotent Queue, worker fleet, dead-letter handling Slow work in the request path

Every row except the last two relieves read or compute pressure. None of them helps with writes. All inserts and updates still land on one primary. Replicas have to replay those writes too, so a write-saturated primary eventually means lagging replicas as well. Past that point you need partitioning large tables, sharding by tenant or another key, or moving a specific high-write dataset such as events, counters, or audit logs to its own store.

The added infrastructure also has a cost of its own. A pooler, a cache cluster, several replicas, and a worker fleet each need monitoring, upgrades, capacity planning, and on-call runbooks. Together that operational load can rival running a few services.

That is why the order matters. Do the cheap rows first and measure again after each one. Each change either removes the bottleneck or shows you which one is next. Often the measurements show that the expensive steps were never needed, which saves the most engineering time of anything in this table.

When to avoid this approach

Scaling the monolith is the wrong default in some situations. Skip it, or stop part-way, when:

  • The bottleneck is write throughput on the primary. Caches, replicas and stateless app servers don't reduce writes. If measurement shows the primary saturated by inserts and updates after query and index fixes, go straight to partitioning, sharding or moving the high-write dataset to its own store.
  • The constraint is organizational, not technical. If many teams block each other on one release train, faster queries won't help. The problem is deploy coupling, and the fix is module boundaries or extraction.
  • One component has hard isolation requirements. Regulatory scope, a separate security boundary or a stricter availability target can require a separate deployable no matter how well the monolith performs.
  • One workload has a very different resource profile. A GPU-bound or memory-heavy job such as media transcoding or model inference shouldn't force every web instance onto that hardware. Running it as a separate worker deployment is often enough. If it needs its own runtime or language, extract it.
  • The added infrastructure would cost more than the problem. A cache cluster with fragile invalidation, or replicas that most reads can't use because of read-your-own-write requirements, can add more on-call load than the latency they remove. If a step needs invalidation logic in dozens of write paths to save little load, leave it out.
  • The codebase can't be made stateless cheaply. If the app depends heavily on local disk, in-memory session state or per-host scheduled tasks, horizontal scaling can take longer than extracting the component that actually needs to scale.
  • You haven't measured. Every step here depends on knowing which resource is saturated. Applying caching or replicas without that data adds complexity and may not touch the real limit.

When to split instead: alternatives and keeping the monolith splittable

Splitting has three degrees. A modular monolith keeps one deployable but divides the code into modules with enforced boundaries. Extracting a single component moves one piece, such as payment processing or media transcoding, into its own service while the rest stays put. Full microservices distribute most domains, which adds network failure modes, distributed transactions and an operations burden to everything.

A slow application is not a reason to split. The earlier techniques address that more cheaply. These signals justify a split:

  • Scaling needs that differ by orders of magnitude. If one endpoint needs a hundred times the compute of the rest, scaling the whole binary wastes money.
  • Deploy coordination bottlenecks. Teams queue behind each other's releases or roll back unrelated changes.
  • Different availability or compliance requirements. For example, a cardholder-data component that must be isolated to shrink audit scope.

Do not defer once these appear. Each month of delay adds more code that couples to the component you will eventually pull out.

Boundaries only survive if a machine checks them. Put each module in its own package, for example billing/, catalog/ and accounts/, with a public api subpackage and everything else internal. Add a CI rule: code outside billing may import only billing.api, and only billing may query billing_* tables. Cross-module joins must go through those interfaces. When a module never reaches into another's tables, extracting it later is mostly a deployment change, not a rewrite.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to scale a monolith, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.