10 Points to Audit Before Scaling Your Application
Scaling is not just adding more servers. Before throwing resources at the problem, audit these 10 areas that kill performance in production.
Most scaling problems are not infrastructure problems. They are code and architecture problems. Adding servers to a poorly optimised application just makes it fail faster, at a higher bill. This is the checklist to run before you provision anything, because the cheapest capacity is the capacity you were wasting.
How to run this audit
Instrument first. You need real numbers from production, or a production-like load test, not guesses. An APM tool such as Sentry, Datadog or New Relic for request traces, query logging or the database slow query log for the data layer, and a load test with k6 or Locust to reproduce the pressure. Then walk the ten points below against what the data actually shows, roughly in this order, because that is roughly the order of impact.
The ten points at a glance
| # | Point | How to detect | Fix |
|---|---|---|---|
| 1 | N+1 queries | Query count per request in the APM or debug bar | Eager loading |
| 2 | Missing indexes | EXPLAIN ANALYZE on the slowest queries | Index the columns in WHERE, JOIN, ORDER BY |
| 3 | Synchronous work in the request | Slow endpoints doing email, PDF, image work | Move it to a queue |
| 4 | Missing caching | No Cache-Control headers, repeated identical queries | HTTP cache, CDN, query and page cache |
| 5 | Unbounded queries | Endpoints with no pagination, all() calls | Always paginate and cap the page size |
| 6 | Session and cache on local disk | file driver in the config | Move to Redis before the second server |
| 7 | Framework caches not enabled | config, route, view caches missing in prod | Enable them in the deploy step |
| 8 | Logging overhead | debug level in production, disks filling | warning level, sampling, ship logs off box |
| 9 | Heavy service resolution | Expensive clients built in every constructor | Lazy resolve or bind as a singleton |
| 10 | Memory leaks in workers | Worker memory climbing over hours | max-time or max-jobs restarts, profile over 24h |
The rest of this article is the detail behind each row.
1. N+1 queries
The most common cause of a slow list page. You load a collection, then loop over it and touch a relation, and the ORM fires one query per row. A page showing 50 orders with their customer runs 51 queries instead of 2. Detect it by counting queries per request in your APM or debug bar, or turn on strict mode in development so lazy loading throws instead of hiding the problem.
// N+1: one query for orders, then one per order for the customer
$orders = Order::latest()->take(50)->get();
foreach ($orders as $order) {
echo $order->customer->name; // fires a query every iteration
}
// Fixed: two queries total, customers loaded up front
$orders = Order::with('customer')->latest()->take(50)->get();2. Missing indexes
Run EXPLAIN ANALYZE on your slowest queries and look for a sequential scan on a large table. Index the columns you filter, join and sort on, especially foreign keys, which many frameworks do not index automatically. Use a composite index when you filter on several columns together, in the order the query uses them. Do not index everything: each index slows writes and takes space.
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE tenant_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
-- Seq Scan on a 10M-row table -> add a matching index
CREATE INDEX idx_orders_tenant_status_created
ON orders (tenant_id, status, created_at DESC);3. Synchronous work in the request cycle
Anything the user does not need in the response should not run before you send it: sending an email, generating a PDF or a report, resizing an image, calling a slow third-party API, firing a webhook. Push it to a queue, Redis with a worker or SQS, and return immediately. The request handler should do only what is needed to render the answer.
4. Missing HTTP and application caching
- ✓HTTP layer: Cache-Control and ETag headers on cacheable responses, a CDN in front of static assets and of pages that anonymous users share.
- ✓Application layer: cache the result of expensive queries and computed values, and full pages for logged-out traffic.
- ✓Invalidation: decide up front between a short TTL and event-based invalidation. A cache you cannot invalidate correctly is a bug waiting to ship.
5. Unbounded queries and responses
User::all() on a 500k-row table, an API endpoint that returns every record, a report that loads a whole table into memory. Each one works fine in staging and falls over in production. Always paginate, always cap the page size, and use cursor pagination when the offset gets large.
6. Session and cache storage that does not scale
File-based sessions and file cache live on one machine. The moment you add a second app server, users bounce between them and lose their session, and each server has a cold cache. Move sessions and cache to Redis or another shared store before you scale out, not after the incident.
7. Framework caches not enabled in production
# Laravel: run these in the deploy step
php artisan config:cache
php artisan route:cache
php artisan view:cacheThe equivalent elsewhere: OPcache enabled with a sane memory limit, and preloading if your stack supports it. These are free wins that many deployments simply forget.
8. Logging and observability overhead
Debug-level logging in production writes millions of lines, fills disks, and adds latency to every request. Set the level to warning or error in production, sample the high-volume events, and ship logs off the box to a system you can actually query. Structured logs, not free text, so you can filter them when something breaks.
9. Heavy work in constructors and service resolution
A service that builds an S3 client, an HTTP client or a report engine in its constructor pays that cost on every request that resolves it, even when the method that needs it is never called. Resolve it lazily, bind it as a singleton so it is built once, or inject a closure that defers construction until first use.
10. Memory leaks in long-running processes
Queue workers, schedulers and daemons keep the process alive across many jobs, so a small leak that is invisible in a request and response cycle compounds over hours until the worker is killed. Profile workers over a full day, not a single job. Set max-time or max-jobs so they restart cleanly on a schedule, and watch resident memory as a metric.
The 80/20 of scaling performance
Most of the win is in the data layer. Fix the N+1 queries, add the missing indexes, and put a cache in front of the expensive work, and you typically get a large throughput improvement with zero extra servers. Infrastructure scaling is what you do after the code and the queries are clean, not instead of it.
Instrument before you optimise. Spend your effort on the bottleneck the data shows, not the one the team assumes exists. The two are different more often than not.
When you actually need more infrastructure
Once the audit is clean and you still hit limits, the usual next steps are: read replicas for read-heavy load, horizontal app scaling now that sessions and cache are shared, a dedicated queue cluster, a connection pooler such as PgBouncer in front of the database, and sharding only as a last resort when a single primary genuinely cannot keep up.
FAQ
What should you check before scaling an application?
The ten points above, in order: N+1 queries, missing indexes, synchronous work in the request, caching gaps, unbounded queries, non-scalable session storage, framework caches, logging overhead, heavy service resolution, and memory leaks in workers. Instrument first so you fix the bottleneck the data points to, not the one you assume.
Does scaling always mean adding servers?
No, and it usually should not be the first move. Most scaling problems are code and architecture problems. Adding servers to an unoptimised application raises the bill and moves the bottleneck without removing it.
How do you find the actual performance bottleneck?
With data, not intuition. An APM tool for request traces, query logging or the slow query log for the database, and a load test to reproduce the pressure. The bottleneck is rarely where the team first looks.
Scaling is an outcome of clean code and a clean data layer, not a substitute for them. Run the audit, fix what the numbers point to, and provision infrastructure only once the application has earned it.
Need help with this topic? Technical Audit
Discover this service →