Read-Heavy API Architecture for High-Traffic Product Pages
11 min read

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 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:#000

A 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:#000
graph 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:#000

Side 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
LayerTechnologyGood ForTarget Hit Rate
CDN / EdgeCloudFront, FastlyStable JSON/HTML responses60–80%
Application CacheRedis, MemcachedPer-domain query results90–95% of CDN misses
DatabasePostgreSQL, MySQLSource of truthOnly 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.

ComponentCharacteristicChange FrequencyIdeal TTL
Product detailStable (name, description, images)Rarely (hours–days)10–30 minutes
Price / StockSensitive, changes oftenOften (seconds–minutes)5–10 seconds
Latest reviewsAppend-onlyMedium (minutes)30–120 seconds
RecommendationsVery dynamic per userVery often5–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:#000
graph 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:#000

Each 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?

ConditionRecommendation
Data has very different ideal TTLsSplit the endpoint
Some data changes far more oftenSplit the endpoint
One data failure must not break the pageSplit the endpoint
High cache churn and rising DB loadSplit the endpoint
Traffic still low, data not yet stableDefer splitting, one endpoint for now
Small team, focused on product validationDefer 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:#000

The BFF’s responsibilities go beyond aggregation:

Without BFFWith BFF
FetchingClient calls all endpointsBFF does internal parallel fetches
TimeoutClient handles per endpointBFF isolates timeouts per domain
Partial failureClient shows errorsBFF falls back to default values
VersioningClient updates when APIs changeBFF absorbs internal changes
Backend changesLeak to the frontendHidden 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:

DomainCache KeyTTLInvalidation
Product detailproduct:detail:{id}10–30 minutesProductUpdated event
Price / Stockproduct:price:{id}:{variant}5–10 secondsPriceChanged / StockUpdated event
Latest reviewsproduct:reviews:latest:{id}30–120 secondsTTL (append-only, no invalidation needed)
Recommendationsproduct:reco:{id}:{segment}5–15 secondsTTL 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 CaseUse Elasticsearch?Alternative
GET /product/{id} — lookup by ID❌ Not neededRedis 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:#000

Many 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:#000

Summary

  • 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.

Portfolio