A client came to me with an API that was taking several seconds per request. Their SaaS dashboard had earned a reputation among users for feeling "sluggish," and the team had already tried the obvious levers — scaling up servers and increasing the database size. None of it helped. After a focused audit of the application code, the response time dropped dramatically, and it did so without adding a single server or changing the infrastructure at all. The problem was never the infrastructure. It was the code. Here is what I found and fixed, in roughly the order it mattered.
N+1 queries: the silent killer
The first thing I looked at was the dashboard's main list view, which loaded a list of projects. For each project in that list, the application made a separate query to fetch the project owner's name. Instead of a single round-trip to the database, a page showing fifty projects was making fifty-one. This is the classic N+1 query problem, and it is the single most common cause of slow API endpoints I see in client audits.
The fix is a straightforward JOIN, collapsing the 1+N queries into one:
-- Before: 1 + N queries
SELECT * FROM projects; -- 1 query
-- Then for each project:
SELECT * FROM users WHERE id = ?; -- N queries
-- After: 1 query with a JOIN
SELECT p.*, u.name FROM projects p
JOIN users u ON p.owner_id = u.id;
This single change made the biggest difference in response time. When you are making dozens of sequential database round-trips per request, the latency of each hop adds up fast — and it scales linearly with the size of the result set. Collapsing it into one query removes that linear cost entirely.
Missing database indexes
The second issue was indexing. The projects table had grown large over time, and queries that filtered by status and created_at were doing full table scans because there was no composite index that matched the query pattern. The database was reading every row in the table to answer a query that only needed a handful of them.
I added a composite index on the two columns the filter actually used:
-- Added this index
CREATE INDEX idx_projects_status_created
ON projects(status, created_at DESC);
Filtered list queries went from full-table scans to fast index lookups. The order of columns in a composite index matters here — status comes first because it is the equality predicate, and created_at DESC follows so the index can also satisfy the ordering the query wanted, avoiding a separate sort step. MySQL's documentation on avoiding table scans covers the reasoning in more depth.
No response caching
The third problem was that the dashboard data changed rarely, but every page load kept fetching fresh data from the database. There was no caching layer at all, so even repeat visits within the same minute re-ran every query. I added Redis caching with a five-minute TTL, which is short enough that stale data is unlikely to matter for this dashboard but long enough to absorb a lot of repeated traffic.
The implementation checks the cache before hitting the database and writes the result back with an expiry:
// Before: always hit the database
const projects = await db.query('SELECT * FROM projects...');
// After: check cache first
const cached = await redis.get('dashboard:projects');
if (cached) return JSON.parse(cached);
const projects = await db.query('SELECT * FROM projects...');
await redis.set('dashboard:projects', JSON.stringify(projects), 'EX', 300);
The EX 300 argument tells Redis to expire the key after 300 seconds, as described in the Redis SET command documentation. For data that changes infrequently, even a short TTL dramatically reduces database load under traffic spikes.
Unoptimized serialization
The final issue was serialization. The API was returning full ORM objects with every relationship eagerly loaded, even though the frontend only needed a handful of fields. That meant the database was doing more work than necessary, the serializer was doing more work than necessary, and the network payload was larger than it needed to be — three compounding costs from a single bad habit.
I switched to selecting only the fields the frontend actually used:
// Before: returns everything including relations
const projects = await Project.findAll({ include: [{ all: true }] });
// After: only what the UI needs
const projects = await Project.findAll({
attributes: ['id', 'name', 'status', 'updatedAt'],
include: [{ model: User, attributes: ['name'] }]
});
This shrinks the query, the serialization, and the response body all at once. It is a small change that pays off on every request.
The result
After these four changes, the API response time dropped dramatically. The database stayed the same, the servers stayed the same, and the infrastructure bill stayed the same — only the code changed. The biggest single win was eliminating the N+1 queries, with the index and caching changes close behind, and the serialization cleanup rounding things out.
The broader lesson is this: before you scale up, optimize what you have. Most performance problems are not infrastructure problems — they are code problems. N+1 queries, missing indexes, and the absence of a caching layer are the common culprits I look for in every audit, and they are usually fixable in a matter of hours. Throwing bigger servers at a code problem just makes the inefficiency more expensive. Django's database optimization documentation is a good starting point if you want to build a mental checklist for your own audits.
Need help with this?
Get in touch — I take on a few new clients each month.
References
Need help with this?
I take on a few new clients each month. Let's talk about your project.
Get in touch