Read-Heavy API Architecture for High-Traffic Product Pages
On almost every e-commerce platform and content catalog, the product detail page is the endpoint with the highest read QPS — far exceeding any other endpoint. The problem is always the same: the database starts getting slow, latency rises during traffic spikes, and every “small optimization” never feels like enough. The root cause isn’t a suboptimal query, but an architecture design that treats the read path like the write path. This article discusses a pragmatic, production-proven approach to building read-heavy APIs that are lightweight, scalable, and pressure-resistant — from basic principles, layered caching strategies, composable endpoints, to when this architecture isn’t yet needed.
The Big Picture — Recommended Architecture
The right approach strictly separates two paths: a read path that’s lightweight and cached, and a side-effect path that’s async and doesn’t block responses.
graph LR
Client["Client<br/>(Browser / Mobile)"]
BFF["BFF<br/>(Backend for Frontend)"]
subgraph "Read Path (Hot)"
Detail["GET /product/id/detail<br/>TTL: 10-30 minutes"]
Price["GET /product/id/price<br/>TTL: 5-10 seconds"]
Reviews["GET /product/id/reviews<br/>TTL: 30-120 seconds"]
Reco["GET /product/id/recommendations<br/>TTL: 5-15 seconds"]
end
subgraph "CDN Layer"
CDN["CDN / Edge Cache"]
end
subgraph "Cache Layer"
Redis["Redis / Memcached"]
end
subgraph "Side-Effect Path (Async)"
Queue["Message Queue<br/>(SQS / Kafka)"]
Worker["Async Worker<br/>(View count, analytics, last-viewed)"]
end
DB["Database<br/>(Only on cache miss)"]
Client --> BFF
BFF --> Detail & Price & Reviews & Reco
Detail & Price & Reviews & Reco --> Redis
Redis -->|"Cache miss"| DB
Client --> CDN
Detail -.->|"Fire & forget"| Queue
Price -.->|"Fire & forget"| Queue
Reviews -.->|"Fire & forget"| Queue
Reco -.->|"Fire & forget"| Queue
Queue --> Worker
style Client fill:#f5f5f4,stroke:#78716c,color:#000
style BFF fill:#e0e7ff,stroke:#4f46e5,color:#000
style Redis fill:#bfdbfe,stroke:#2563eb,color:#000
style CDN fill:#bfdbfe,stroke:#2563eb,color:#000
style Queue fill:#fef3c7,stroke:#d97706,color:#000
style Worker fill:#fef3c7,stroke:#d97706,color:#000
style DB fill:#bbf7d0,stroke:#16a34a,color:#000A realistic target achievable with this approach: 80–95% of traffic stops at the cache, and the database only receives cache-miss traffic.
Core Principle — Separate Pure Reads from Side Effects
Read APIs Must Be Deterministic
A healthy read API has one key characteristic: the same request always produces the same response. This is what makes the read path aggressively cacheable and infinitely horizontally scalable.
Problems start when read APIs begin doing things that change state:
graph LR
subgraph "❌ Anti-Pattern: Side Effects in the Read Path"
R1["GET /product/id"] --> DB1["Database"]
DB1 -->|"SELECT product"| R1
DB1 -->|"UPDATE view_count"| R1
DB1 -->|"INSERT analytics_event"| R1
DB1 -->|"UPDATE user.last_viewed"| R1
end
style R1 fill:#fecaca,stroke:#dc2626,color:#000
style DB1 fill:#fecaca,stroke:#dc2626,color:#000graph LR
subgraph "✅ Correct: Pure Read Path + Async Side Effects"
R2["GET /product/id"] --> Cache["Redis"]
Cache -->|"Cache miss only"| DB2["Database"]
R2 -.->|"Fire & forget"| Queue["Message Queue"]
Queue --> Worker["Async Worker"]
end
style R2 fill:#bbf7d0,stroke:#16a34a,color:#000
style Cache fill:#bfdbfe,stroke:#2563eb,color:#000
style DB2 fill:#bbf7d0,stroke:#16a34a,color:#000
style Queue fill:#fef3c7,stroke:#d97706,color:#000
style Worker fill:#fef3c7,stroke:#d97706,color:#000Side effects that must be removed from the read path: view counters, last-viewed users, analytics events, recommendation signals. All of them can be sent as fire-and-forget events to a message broker — no need to wait for their results.
Cache Isn’t an Optimization — Cache Is Design
In read-heavy systems, caching isn’t an extra layer tacked on after the system runs. Cache is part of the architecture itself, designed from the start together with the API contract and data model.
Late-designed caching almost always has problems: inconsistent cache keys, arbitrary TTLs, unclear invalidation strategies, and ultimately adding complexity without real benefit.
Three Cache Layers
graph TB
Client["Client Request"]
CDN["Layer 1: CDN / Edge Cache<br/>Stable responses, close to users"]
AppCache["Layer 2: Application Cache<br/>Redis / Memcached<br/>Per-domain data"]
DB["Layer 3: Database<br/>Only on cache miss"]
Client --> CDN
CDN -->|"Cache miss"| AppCache
AppCache -->|"Cache miss"| DB
style Client fill:#f5f5f4,stroke:#78716c,color:#000
style CDN fill:#bfdbfe,stroke:#2563eb,color:#000
style AppCache fill:#bfdbfe,stroke:#2563eb,color:#000
style DB fill:#bbf7d0,stroke:#16a34a,color:#000| Layer | Technology | Good For | Target Hit Rate |
|---|---|---|---|
| CDN / Edge | CloudFront, Fastly | Stable JSON/HTML responses | 60–80% |
| Application Cache | Redis, Memcached | Per-domain query results | 90–95% of CDN misses |
| Database | PostgreSQL, MySQL | Source of truth | Only cache misses |
Cache Key Rules
A good cache key must be deterministic and specific enough. Too broad causes stale data, too narrow causes a low hit rate.
product:detail:{product_id}
product:price:{product_id}:{variant_id}
product:reviews:latest:{product_id}
product:recommendation:{product_id}:{user_segment}
The Monolithic Endpoint Problem — the Lowest TTL Rules Everything
This is the hidden cost most often overlooked. If a single endpoint combines all product page data, its cache TTL must follow the fastest-changing data. That means product details that rarely change get invalidated every time stock changes.
| Component | Characteristic | Change Frequency | Ideal TTL |
|---|---|---|---|
| Product detail | Stable (name, description, images) | Rarely (hours–days) | 10–30 minutes |
| Price / Stock | Sensitive, changes often | Often (seconds–minutes) | 5–10 seconds |
| Latest reviews | Append-only | Medium (minutes) | 30–120 seconds |
| Recommendations | Very dynamic per user | Very often | 5–15 seconds |
If all of this is forced into one endpoint with one TTL → the TTL gets pulled down to the most sensitive one (5–10 seconds) → very high cache churn → the database takes almost the same load as without caching.
Composable Read APIs — One Domain, One Endpoint
The solution is splitting one fat endpoint into several endpoints by data domain. This is a lightweight application of CQRS (Command Query Responsibility Segregation) on the read side.
graph LR
subgraph "❌ Anti-Pattern: Monolithic Endpoint"
BFF1["BFF"] --> Fat["GET /product/id<br/>detail + price + reviews<br/>+ recommendations + view tracking"]
Fat --> DB1["DB (all queries)"]
end
style Fat fill:#fecaca,stroke:#dc2626,color:#000
style DB1 fill:#fecaca,stroke:#dc2626,color:#000graph LR
subgraph "✅ Correct: Composable Endpoints"
BFF2["BFF"] --> E1["GET /product/id/detail<br/>TTL: 10-30 minutes"]
BFF2 --> E2["GET /product/id/price<br/>TTL: 5-10 seconds"]
BFF2 --> E3["GET /product/id/reviews<br/>TTL: 30-120 seconds"]
BFF2 --> E4["GET /product/id/recommendations<br/>TTL: 5-15 seconds"]
end
style BFF2 fill:#e0e7ff,stroke:#4f46e5,color:#000
style E1 fill:#bbf7d0,stroke:#16a34a,color:#000
style E2 fill:#bbf7d0,stroke:#16a34a,color:#000
style E3 fill:#bbf7d0,stroke:#16a34a,color:#000
style E4 fill:#bbf7d0,stroke:#16a34a,color:#000Each endpoint can have: its own cache with a TTL matching the data’s characteristics, different storage (Redis-only, read replica, search index), and an independent invalidation strategy. One domain’s failure doesn’t break the whole page.
When Do You Need to Split Endpoints?
| Condition | Recommendation |
|---|---|
| Data has very different ideal TTLs | Split the endpoint |
| Some data changes far more often | Split the endpoint |
| One data failure must not break the page | Split the endpoint |
| High cache churn and rising DB load | Split the endpoint |
| Traffic still low, data not yet stable | Defer splitting, one endpoint for now |
| Small team, focused on product validation | Defer splitting, simple is better |
Practical rule: if more than two of the above conditions are met, splitting the endpoint is almost always the right decision.
BFF — Don’t Throw Complexity at the Client
Splitting endpoints into many domains creates a new problem: the client must call 4–5 endpoints and merge the results itself. That moves complexity from the backend to the frontend — not a solution.
A Backend for Frontend (BFF) solves this by being a single entry point for the client while internally doing parallel fetches to the domain endpoints.
graph LR
Client["Client"] -->|"1 request"| BFF["BFF Layer"]
BFF -->|"Parallel fetch"| D1["Detail Service"]
BFF -->|"Parallel fetch"| D2["Price Service"]
BFF -->|"Parallel fetch"| D3["Review Service"]
BFF -->|"Parallel fetch"| D4["Recommendation Service"]
D1 & D2 & D3 & D4 -->|"Aggregated response"| BFF
BFF -->|"1 response"| Client
style Client fill:#f5f5f4,stroke:#78716c,color:#000
style BFF fill:#e0e7ff,stroke:#4f46e5,color:#000
style D1 fill:#bbf7d0,stroke:#16a34a,color:#000
style D2 fill:#bbf7d0,stroke:#16a34a,color:#000
style D3 fill:#bbf7d0,stroke:#16a34a,color:#000
style D4 fill:#bbf7d0,stroke:#16a34a,color:#000The BFF’s responsibilities go beyond aggregation:
| Without BFF | With BFF | |
|---|---|---|
| Fetching | Client calls all endpoints | BFF does internal parallel fetches |
| Timeout | Client handles per endpoint | BFF isolates timeouts per domain |
| Partial failure | Client shows errors | BFF falls back to default values |
| Versioning | Client updates when APIs change | BFF absorbs internal changes |
| Backend changes | Leak to the frontend | Hidden behind the BFF |
The client still makes one request, the backend stays modular. This is what lets the system evolve without forcing client-side updates.
Per-Domain Cache Strategies
Here are proven cache strategies for each product page domain:
| Domain | Cache Key | TTL | Invalidation |
|---|---|---|---|
| Product detail | product:detail:{id} | 10–30 minutes | ProductUpdated event |
| Price / Stock | product:price:{id}:{variant} | 5–10 seconds | PriceChanged / StockUpdated event |
| Latest reviews | product:reviews:latest:{id} | 30–120 seconds | TTL (append-only, no invalidation needed) |
| Recommendations | product:reco:{id}:{segment} | 5–15 seconds | TTL or stale-while-revalidate |
Stale-while-revalidate is the best strategy for data that can be slightly stale but must always be fast: serve from cache first (even if expired), then refresh in the background. Good for price and stock, where users tolerate a 1–2 second delay more than a blank page.
Elasticsearch — When It’s Needed, When It Isn’t
Elasticsearch is often seen as the “quick fix” when the database starts getting slow. In fact, Elasticsearch isn’t a cache replacement and isn’t a cheap solution for primary-key lookups.
graph LR
subgraph "❌ Misconception: ES for Detail by ID"
DB3["DB getting heavy"] --> ES1["Move to Elasticsearch"]
ES1 --> P1["Eventual consistency<br/>hard to explain to the business"]
ES1 --> P2["High operational cost<br/>(cluster, shard, reindex)"]
ES1 --> P3["Debugging data mismatches<br/>takes time"]
end
style DB3 fill:#f5f5f4,stroke:#78716c,color:#000
style ES1 fill:#fecaca,stroke:#dc2626,color:#000
style P1 fill:#fecaca,stroke:#dc2626,color:#000
style P2 fill:#fecaca,stroke:#dc2626,color:#000
style P3 fill:#fecaca,stroke:#dc2626,color:#000| Use Case | Use Elasticsearch? | Alternative |
|---|---|---|
GET /product/{id} — lookup by ID | ❌ Not needed | Redis cache + DB |
| Product full-text search | ✅ Very suitable | — |
| Complex filters (category, price, brand) | ✅ Very suitable | — |
| Listings with dynamic sorting | ✅ Suitable | — |
| Aggregations (product count per category) | ✅ Suitable | — |
The simple rule: add Elasticsearch because of query needs, not because the database feels heavy. If the problem is high read QPS for detail-by-ID lookups, the solution is caching — not a search engine.
The Most Common Anti-Patterns
Monolithic endpoint with the lowest TTL. One GET /product/{id} fetching everything at once looks simple at first, but becomes a big problem as traffic grows. The whole endpoint’s cache must be invalidated every time stock changes — which can happen hundreds of times per minute for popular products.
Synchronous side effects in the read path. Incrementing view counters, inserting analytics events, and updating last-viewed inside read API handlers make the read path lose its deterministic nature. Latency rises with traffic because every read causes several writes to the database.
Database as a cache replacement. Adding indexes endlessly, optimizing queries, but read traffic still goes straight to the database. Databases aren’t designed for thousands of concurrent reads per second of the same data — that’s what caches do.
Elasticsearch too early. DB starts getting slow → immediately migrate to Elasticsearch for GET /product/{id}. This adds operational complexity (cluster management, reindexing), introduces eventual consistency that’s hard to explain to stakeholders, and doesn’t solve the original problem — the absence of effective caching.
Client-side orchestration. The frontend calls 5–6 endpoints, merges responses, and handles per-domain timeouts. When one endpoint is slow, the whole UX is affected. Business logic starts spreading into the client. This is a symptom of a missing BFF layer.
A Wise Evolution Sequence
A common mistake is jumping straight to the most complex architecture without evidence that the complexity is needed. Every evolution should be triggered by a proven bottleneck, not assumptions.
graph TD
S1["Stage 1<br/>One simple endpoint<br/>Adequate DB indexes"]
S2["Stage 2<br/>Move side effects to async<br/>Add Redis cache"]
S3["Stage 3<br/>Split endpoints per domain<br/>TTL per data characteristics"]
S4["Stage 4<br/>Add BFF layer<br/>CDN / Edge cache"]
S5["Stage 5<br/>Separate read model<br/>Denormalized / materialized views"]
S6["Stage 6<br/>Search index (ES/OpenSearch)<br/>If the use case is truly search-driven"]
S1 -->|"Low cache hit rate<br/>DB starting to feel heavy"| S2
S2 -->|"High cache churn<br/>TTL ineffective"| S3
S3 -->|"Client complexity rising<br/>Many endpoints to manage"| S4
S4 -->|"Query patterns getting complex<br/>Need denormalization"| S5
S5 -->|"Need full-text search<br/>Complex filters"| S6
style S1 fill:#f5f5f4,stroke:#78716c,color:#000
style S2 fill:#fef3c7,stroke:#d97706,color:#000
style S3 fill:#fde68a,stroke:#d97706,color:#000
style S4 fill:#bfdbfe,stroke:#2563eb,color:#000
style S5 fill:#bfdbfe,stroke:#2563eb,color:#000
style S6 fill:#bbf7d0,stroke:#16a34a,color:#000Many systems succeed at large scale with just Stages 2–3. Stages 5 and 6 are only needed once specific problems have appeared and been proven.
When This Architecture Isn’t Needed Yet
Complex read-heavy architecture can become a burden if applied too early. Signs that a simple architecture is still enough:
- Traffic is still low and the database shows no signs of a bottleneck
- The product page is rarely accessed by many users at the same time
- Data structure is still changing often — too early for denormalization
- A small team focused on product and market-fit validation
- Not enough observability yet to measure cache hit rates and query latency
At this stage, the more appropriate solution is one simple endpoint, clear queries with adequate indexes, and minimal caching. A good architecture is one that’s sufficient — not the most sophisticated.
Decision Guide — Pick the Right Strategy
graph TD
Start["Product page starting<br/>to feel slow"] --> Q1{"Bottleneck<br/>already identified?"}
Q1 -->|"Not yet"| Measure["Measure first:<br/>query time, cache hit rate,<br/>DB connection pool"]
Q1 -->|"Yes"| Q2{"Where is the bottleneck?"}
Q2 -->|"DB write contention<br/>during reads"| AsyncFix["Move side effects<br/>to an async queue"]
Q2 -->|"DB hit by many<br/>reads of the same data"| CacheFix["Add Redis cache<br/>with the right TTL"]
Q2 -->|"High cache churn,<br/>TTL ineffective"| SplitFix["Split endpoints<br/>per data domain"]
Q2 -->|"Client complexity<br/>rising"| BFFFix["Add a BFF layer"]
Q2 -->|"Need full-text search<br/>or complex filters"| ESFix["Only now consider<br/>Elasticsearch"]
style Measure fill:#fef3c7,stroke:#d97706,color:#000
style AsyncFix fill:#bbf7d0,stroke:#16a34a,color:#000
style CacheFix fill:#bbf7d0,stroke:#16a34a,color:#000
style SplitFix fill:#bbf7d0,stroke:#16a34a,color:#000
style BFFFix fill:#bbf7d0,stroke:#16a34a,color:#000
style ESFix fill:#bfdbfe,stroke:#2563eb,color:#000Summary
- The read path must be pure — no side effects (view counters, analytics, last viewed) inside read API handlers. All side effects move to an async queue.
- Cache is design, not optimization — designed from the start together with the API contract, not tacked on after the system runs. Target: 80–95% of traffic stops at the cache.
- A monolithic endpoint = the shortest TTL rules everything — split endpoints per data domain so each domain can have its own TTL and invalidation strategy.
- Composable Read APIs — one endpoint per domain: detail (10–30 minutes), price (5–10 seconds), reviews (30–120 seconds), recommendations (5–15 seconds).
- A BFF hides complexity — the client still makes one request; the BFF does parallel fetches to domain endpoints, handles timeout isolation, and provides fallbacks on partial failures.
- Elasticsearch isn’t a cache replacement — add it for query needs (full-text search, complex filters), not because the database feels heavy for primary-key lookups.
- Gradual evolution — async side effects → Redis cache → split endpoints → BFF → read model → search index. Each stage is triggered by a proven bottleneck, not assumptions.
- A good architecture is one that’s sufficient — don’t apply this complexity before a bottleneck is proven to exist. Premature optimization costs more than the problem it tries to solve.