The Problem: An API That Was Costing Real Money
A few months ago, a client came to us with a problem that was bleeding revenue. Their e-commerce platform's product search API was taking 2.3 seconds on average — and during peak traffic, it spiked to 4+ seconds. For an online store, every 100ms of delay costs conversions. At 2.3 seconds, they were losing customers before the page even loaded.
The API served product listings with inventory, pricing, and availability across multiple warehouses. It was built on Node.js + Express with PostgreSQL, and it had grown organically over two years without much performance attention. The code worked — it just worked slowly.
Here's exactly how we diagnosed the bottlenecks and brought response times down to under 400ms.
Step 1: Measure Before You Touch Anything
You can't optimize what you don't measure. We instrumented the API with OpenTelemetry tracing and added request-level timing logs. Within an hour of deploying instrumentation, the picture was clear:
| Component | Avg Time | % of Total |
|---|---|---|
| Database queries | 1,420ms | 62% |
| External inventory API call | 480ms | 21% |
| Response serialization | 210ms | 9% |
| Auth/validation | 120ms | 5% |
| Other | 70ms | 3% |
The database was the obvious villain. But the external inventory call was also a problem — it was synchronous and blocking.
Step 2: Fix the Database Queries
Digging into the PostgreSQL query logs, we found three issues:
N+1 Query Problem
The product listing endpoint was fetching products, then looping through each one to fetch its variants, then looping through variants to fetch inventory. Classic N+1. A page of 20 products was triggering 60+ queries.
Fix: We rewrote the data access layer to use JOINs and fetch everything in 2-3 queries:
-- Before: 1 product query + 20 variant queries + 40 inventory queries
-- After: 3 queries total
SELECT p.*,
json_agg(json_build_object('id', v.id, 'sku', v.sku, 'price', v.price)) as variants
FROM products p
LEFT JOIN variants v ON v.product_id = p.id
WHERE p.category_id = $1
GROUP BY p.id
LIMIT 20;
Missing Indexes
The WHERE clause was filtering on category_id and is_active, but neither column had an index. A sequential scan on 200K+ products was happening on every request.
Fix: Two composite indexes:
CREATE INDEX idx_products_category_active ON products(category_id, is_active) WHERE is_active = true;
CREATE INDEX idx_variants_product ON variants(product_id);
Unnecessary Data Fetching
The API was returning full product descriptions (sometimes 2KB+ of HTML) in the listing endpoint. Nobody reads descriptions in a search result.
Fix: We created a lightweight product_summary view that excluded heavy text fields and computed pre-aggregated values.
Result: Database time dropped from 1,420ms to ~180ms.
Step 3: Parallelize the External Call
The inventory availability check was calling a third-party warehouse API synchronously — the request sat idle waiting for a response that took 400-500ms.
Fix: We moved the inventory check to a background job using Redis-backed queues (BullMQ). The API now returns product listings immediately with a inventory_status: 'pending' flag. A separate WebSocket channel pushes real-time inventory updates to the frontend within 1-2 seconds.
This was a UX tradeoff — the product appears instantly, and inventory data fills in a moment later. The client's users preferred this dramatically over waiting 2+ seconds for a complete response.
Result: The 480ms blocking call was eliminated from the critical path.
Step 4: Response Serialization
We found that JSON.stringify() on large response objects was taking 200ms+. The culprit: the API was serializing full product objects with nested relations, many of which the frontend never used.
Fix: We implemented a response shaping layer using DTOs (Data Transfer Objects) that only included fields the frontend actually consumed. We also added response compression (gzip at the Express level), which cut payload size by 70%.
Result: Serialization dropped to ~40ms.
Step 5: Add Caching (But Carefully)
With the database queries optimized, we added a Redis cache layer for product listings. The key insight: cache at the right granularity.
We cached at the category + filter combination level with a 60-second TTL. This meant:
- First request: ~200ms (database)
- Subsequent requests within 60s: ~15ms (Redis)
- Cache invalidation on product update via database triggers → Redis pub/sub
We deliberately kept the TTL short because inventory changes frequently. A longer TTL would have served stale data.
The Final Numbers
| Metric | Before | After | Improvement |
|---|---|---|---|
| Avg response time | 2,300ms | 380ms | 83.5% |
| p95 response time | 4,100ms | 620ms | 84.9% |
| p99 response time | 5,800ms | 890ms | 84.7% |
| DB queries per request | 60+ | 3 | 95% |
| Payload size | 48KB | 14KB | 71% |
The client saw an immediate 12% increase in product page conversions and a 9% drop in search abandonment. The infrastructure cost actually went down because fewer database CPU cycles were burned on inefficient queries.
What We Learned
- Instrument first. You can't fix what you can't see. OpenTelemetry + query logging gave us the map.
- The database is usually the bottleneck. In our experience at Paradane, 7 out of 10 slow APIs are slow because of database access patterns, not application code.
- N+1 queries are everywhere. ORMs make them easy to write and hard to spot. Always check your query logs.
- Synchronous external calls are API killers. If you're calling a third-party service in the request path, you're borrowing their latency.
- Cache with intent. Don't just slap Redis on everything. Think about TTL, invalidation, and what staleness means for your users.
Is Your API Slow?
If your API response times are creeping up, start with instrumentation. The fixes are usually simpler than you think — indexes, query consolidation, and removing blocking I/O solve most problems. And if you need help diagnosing or optimizing your stack, we do this kind of work at Paradane every day.
Have you tackled a similar optimization? What was your biggest win? Drop a comment below.
Top comments (0)