Showing posts with label backend. Show all posts
Showing posts with label backend. Show all posts

Friday, April 17, 2026

LLM Applications in Production 2026: RAG Optimization, Prompt Caching, Streaming, and Cost Control

Hero image

Introduction

Between 2024 and 2026, LLM APIs crossed the threshold from "impressive demo" to "core infrastructure." The companies that shipped fast in 2023 learned the hard way what production LLM systems actually demand: latency that doesn't embarrass you, costs that don't crater your margin, context windows that stay coherent across long sessions, and reliability that survives token storms, provider outages, and malformed outputs.

The tooling matured fast. Anthropic Claude introduced prompt caching. OpenAI rolled out automatic prefix caching and the Batch API. Vector databases became commodity infrastructure. Cross-encoder re-ranking went from research paper to pip install. And yet most teams are still leaving significant performance and cost on the table because they never moved beyond the basic client.messages.create() call they copy-pasted from the quickstart.

This post covers the six engineering patterns that separate working LLM demos from production-grade LLM applications: RAG architecture optimization, prompt caching and cost control, streaming responses, context window management, reliability and evaluation, and production architecture patterns. Each section includes complete, runnable Python code with comments explaining the cost and latency impact of every decision.

The numbers matter here. At $3.00 per million input tokens and $15.00 per million output tokens (Claude Sonnet 3.5 pricing), a system processing 100,000 queries per day with an average of 2,000 input tokens and 500 output tokens spends $600/day on input and $750/day on output — $1,350/day, $490,500/year. A 40% cache hit rate on system prompts cuts that by $240/day. Hybrid search that eliminates 30% of irrelevant retrieved chunks saves another $90/day. These aren't rounding errors. They're the difference between a profitable product and one that burns cash.

1. RAG Architecture Optimization

Retrieval-Augmented Generation became the default architecture for knowledge-intensive LLM applications. The basic pattern — embed a query, find similar document chunks, stuff them in the prompt — works well enough to ship a demo. Production requires every layer of that pipeline to be deliberate.

Chunking Strategy

Chunk size is the most consequential decision in a RAG pipeline, and most teams get it wrong by picking a fixed size arbitrarily. Fixed-size chunking (e.g., 512 tokens, 50-token overlap) is fast and predictable but routinely splits semantically complete units — a sentence, a code block, a numbered list item — across chunk boundaries. The retrieved chunk is coherent in isolation but loses meaning.

Semantic chunking uses embedding similarity to find natural breakpoints: when the embedding distance between consecutive sentences exceeds a threshold, start a new chunk. This produces variable-length chunks that respect document structure. The tradeoff is 3-5x slower indexing — acceptable for offline ingestion, problematic for real-time document addition.

Sentence-window chunking is a practical middle ground: index at the sentence level for precision retrieval, then expand each hit to a ±3 sentence window before passing to the LLM. The small index unit gives you high-precision retrieval; the expanded context gives the LLM enough surrounding text to answer correctly. This approach consistently outperforms both fixed and semantic chunking on question-answering benchmarks at reasonable indexing cost.

Embedding Model Selection

OpenAI's text-embedding-3-large (3072 dimensions, ~$0.13/million tokens) remains the default for teams that want strong out-of-the-box performance without operational overhead. For high-volume applications, local models eliminate per-query cost entirely. BGE-M3 from BAAI supports 8192-token input, produces 1024-dimensional embeddings, and runs comfortably on a single A10G GPU — at $0.80/hour on major cloud providers, break-even versus OpenAI's API is roughly 6 million tokens/month.

Nomic Embed v2 is a strong alternative with a permissive Apache 2.0 license, Matryoshka representation learning (you can truncate to 256 dimensions without significant accuracy loss), and competitive MTEB benchmark scores. For multilingual applications, mE5-large or multilingual-E5-large outperform most alternatives without requiring separate models per language.

Always evaluate embedding models on your own documents and queries, not just MTEB benchmarks. Domain shift is real — a model trained on web text may underperform on medical records or legal documents regardless of its aggregate benchmark score.

Hybrid Search and Re-Ranking

Dense vector search alone misses exact keyword matches. BM25 keyword search alone misses semantic variations. Hybrid search combines both, and Reciprocal Rank Fusion (RRF) merges the ranked lists without requiring score normalization:

import httpx
from rank_bm25 import BM25Okapi
import numpy as np
from sentence_transformers import CrossEncoder
from typing import List, Dict, Any

# Reciprocal Rank Fusion — combines dense and sparse rankings
# k=60 is standard; higher k reduces the impact of top-ranked docs
def reciprocal_rank_fusion(
    dense_results: List[Dict],
    sparse_results: List[Dict],
    k: int = 60
) -> List[Dict]:
    """
    Merge two ranked lists using RRF.
    Cost impact: zero — pure CPU, no API calls.
    Latency: ~1ms for lists up to 1000 items.
    """
    scores: Dict[str, float] = {}
    doc_map: Dict[str, Dict] = {}

    for rank, doc in enumerate(dense_results):
        doc_id = doc["id"]
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (rank + k)
        doc_map[doc_id] = doc

    for rank, doc in enumerate(sparse_results):
        doc_id = doc["id"]
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (rank + k)
        doc_map[doc_id] = doc

    ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return [doc_map[doc_id] for doc_id, _ in ranked]


# Cross-encoder re-ranking — the most impactful single improvement to RAG quality
# Cross-encoders score (query, document) pairs jointly, not independently
# ms-marco-MiniLM-L-6-v2: 22M params, ~4ms/pair on CPU, excellent for top-20 re-ranking
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rerank_chunks(
    query: str,
    candidates: List[Dict],
    top_k: int = 5
) -> List[Dict]:
    """
    Re-rank retrieved chunks with a cross-encoder.
    Cost: ~40ms CPU for 20 candidates — worth it, dramatically improves recall@5.
    Run this AFTER hybrid search narrows to top 20; don't run on 100+ candidates.
    """
    pairs = [(query, doc["text"]) for doc in candidates]
    scores = reranker.predict(pairs)

    ranked = sorted(
        zip(candidates, scores),
        key=lambda x: x[1],
        reverse=True
    )
    return [doc for doc, _ in ranked[:top_k]]


def build_rag_pipeline(vector_store, bm25_index: BM25Okapi, documents: List[Dict]):
    """
    Complete RAG pipeline: hybrid search + re-rank + metadata filter.
    """
    def retrieve(
        query: str,
        query_embedding: List[float],
        metadata_filter: Dict = None,
        dense_top_k: int = 20,
        sparse_top_k: int = 20,
        final_top_k: int = 5
    ) -> List[Dict]:
        # Metadata filter before vector search — eliminates irrelevant results
        # before spending compute on embedding comparison
        # Cost impact: reduces tokens sent to LLM by 20-40% in typical deployments
        filter_kwargs = {}
        if metadata_filter:
            filter_kwargs["filter"] = metadata_filter

        # Dense retrieval
        dense_results = vector_store.similarity_search_by_vector(
            query_embedding,
            k=dense_top_k,
            **filter_kwargs
        )

        # Sparse retrieval (BM25 operates on tokenized text)
        tokenized_query = query.lower().split()
        bm25_scores = bm25_index.get_scores(tokenized_query)
        top_sparse_idx = np.argsort(bm25_scores)[-sparse_top_k:][::-1]
        sparse_results = [documents[i] for i in top_sparse_idx if bm25_scores[i] > 0]

        # Merge with RRF
        merged = reciprocal_rank_fusion(dense_results, sparse_results)

        # Re-rank top candidates with cross-encoder
        # Only re-rank top 20 to keep latency under 100ms
        reranked = rerank_chunks(query, merged[:20], top_k=final_top_k)

        return reranked

    return retrieve

Context compression with LLMLingua reduces retrieved chunk token count by 40-60% with minimal accuracy loss by removing low-perplexity tokens from retrieved documents. At $0.003/1K input tokens, compressing 2,000 tokens of retrieved context to 1,200 tokens saves $0.0024 per query — $2,400/day at 1 million daily queries.

Architecture diagram
flowchart TD A[User Query] --> B[Embed Query\ntext-embedding-3-large\n~10ms / $0.0001] A --> C[Tokenize for BM25\nfree / <1ms] B --> D[Dense Vector Search\nTop-20 candidates\n~20ms] C --> E[BM25 Keyword Search\nTop-20 candidates\n~5ms] D --> F[Reciprocal Rank Fusion\nMerge ranked lists\n~1ms] E --> F F --> G{Metadata Filter\napplied?} G -- Yes --> H[Filter by date/source/ACL\n~0ms] G -- No --> I[Cross-Encoder Re-ranking\nms-marco-MiniLM-L-6-v2\n~40ms for top-20] H --> I I --> J[Top-5 Chunks Selected] J --> K[Context Compression\nLLMLingua -40% tokens\noptional] K --> L[LLM Generation\nClaude / GPT-4o] L --> M[Response to User]

2. Prompt Caching and Cost Control

Prompt caching is the highest-leverage cost optimization available in 2026. Anthropic charges $0.30/MTok for cached input reads on Claude Sonnet 3.5, versus $3.00/MTok for uncached — a 90% discount. OpenAI's automatic prefix caching gives a 50% discount on prompt prefixes longer than 1,024 tokens without requiring any code change.

Anthropic Prompt Caching

The key insight is to structure prompts so stable content (system instructions, reference documents, few-shot examples) comes first, and dynamic content (the user's query, conversation history) comes last. Anthropic caches the stable prefix; you pay full price only for the dynamic suffix.

import anthropic
from typing import List, Dict, Optional

client = anthropic.Anthropic()

# System prompt with cache_control — mark stable content for caching
# Minimum cacheable size: 1,024 tokens for Haiku/Sonnet, 2,048 for Opus
# Cache TTL: 5 minutes default, 1 hour with "ephemeral" type
# Cost: $3.75/MTok to CREATE a cache entry, $0.30/MTok to READ it
# Break-even: cache creation cost recovered after 8 reads of the same content

SYSTEM_PROMPT = """You are an expert software engineer assistant specializing in
distributed systems, LLM applications, and production infrastructure. You provide
precise, actionable technical guidance with working code examples.

When answering questions:
- Lead with the direct answer, then explain the reasoning
- Include complete code examples, not snippets
- Call out cost and latency implications explicitly
- Flag common production pitfalls

Your knowledge base includes the following reference documentation:
[... large stable reference document, 2000+ tokens ...]
"""  # In practice, load from file; must exceed 1024 tokens for caching


def chat_with_caching(
    user_message: str,
    conversation_history: List[Dict],
    retrieved_context: Optional[str] = None
) -> anthropic.types.Message:
    """
    Structured for maximum cache hits:
    1. System prompt (stable, cached) — 90% discount on reads
    2. Retrieved context (semi-stable, can cache if same docs reused)
    3. Conversation history (dynamic, NOT cached)
    4. Current user message (dynamic, NOT cached)
    """

    # Build messages: stable context first, dynamic last
    messages = []

    # Retrieved context as a cacheable user turn if it's the same document set
    # This is valuable when many queries hit the same knowledge base pages
    if retrieved_context:
        messages.append({
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": f"Reference context for this conversation:\n\n{retrieved_context}",
                    # Cache this if the same context appears in multiple turns
                    "cache_control": {"type": "ephemeral"}
                }
            ]
        })
        messages.append({
            "role": "assistant",
            "content": "Understood. I'll use this context to answer your questions."
        })

    # Dynamic conversation history (no cache — changes every turn)
    messages.extend(conversation_history)

    # Current user message (always dynamic)
    messages.append({"role": "user", "content": user_message})

    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=2048,
        system=[
            {
                "type": "text",
                "text": SYSTEM_PROMPT,
                # Cache the system prompt — this is the highest-value cache target
                # At 2000 tokens, 1000 req/day: saves ~$5/day vs uncached
                "cache_control": {"type": "ephemeral"}
            }
        ],
        messages=messages
    )

    # Log cache performance — track hit rate to validate your cache strategy
    usage = response.usage
    cache_read_tokens = getattr(usage, 'cache_read_input_tokens', 0)
    cache_create_tokens = getattr(usage, 'cache_creation_input_tokens', 0)
    uncached_tokens = usage.input_tokens - cache_read_tokens - cache_create_tokens

    # Cost calculation for observability
    cost_uncached = uncached_tokens * 3.00 / 1_000_000
    cost_cached_reads = cache_read_tokens * 0.30 / 1_000_000
    cost_cache_creation = cache_create_tokens * 3.75 / 1_000_000
    cost_output = usage.output_tokens * 15.00 / 1_000_000
    total_cost = cost_uncached + cost_cached_reads + cost_cache_creation + cost_output

    print(f"Cache stats: {cache_read_tokens} read / {cache_create_tokens} created / "
          f"{uncached_tokens} uncached | Cost: ${total_cost:.5f}")

    return response


# OpenAI automatic prefix caching — no code changes required
# Caching activates automatically on prompts > 1024 tokens
# 50% discount on cached prefix tokens
# Structure: long stable system prompt first, dynamic content last

from openai import AsyncOpenAI
import asyncio

openai_client = AsyncOpenAI()

async def openai_cached_completion(
    user_message: str,
    conversation_history: List[Dict]
) -> dict:
    """
    OpenAI prefix caching is automatic — just ensure the stable prefix
    is long (>1024 tokens) and consistent across requests.
    Discount: 50% off cached input tokens ($0.0015 vs $0.003 per 1K for GPT-4o-mini)
    """
    # The system message must be identical across requests for cache hits
    # Even a single token difference creates a new cache entry
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        # Stable few-shot examples go here — they'll be cached
        # Dynamic history and user message go last
        *conversation_history,
        {"role": "user", "content": user_message}
    ]

    response = await openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=messages,
        max_tokens=1024
    )

    # Check cache hit in usage stats
    usage = response.usage
    if hasattr(usage, 'prompt_tokens_details'):
        cached = usage.prompt_tokens_details.cached_tokens
        print(f"OpenAI cache hit: {cached} tokens cached "
              f"(saved ${cached * 0.0015 / 1000:.5f})")

    return response

Model Routing

A complexity classifier routes simple queries (factual lookups, short answers) to cheap models (GPT-4o-mini at $0.15/MTok input) and complex queries (multi-step reasoning, code generation) to expensive models (Claude Sonnet at $3.00/MTok input). This alone typically cuts LLM spend by 35-50% in mixed-complexity workloads.

async def classify_query_complexity(query: str) -> str:
    """
    Cheap classifier — use the fast model to decide which model to use.
    GPT-4o-mini at $0.15/MTok is 20x cheaper than Claude Sonnet.
    Cost of classification: ~200 tokens = $0.00003. Worth it above ~500 queries/day.
    """
    response = await openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    "Classify query complexity as SIMPLE or COMPLEX.\n"
                    "SIMPLE: factual lookup, yes/no, short definition, basic how-to\n"
                    "COMPLEX: multi-step reasoning, code generation, architectural design, "
                    "synthesis across multiple sources\n"
                    "Respond with only the word SIMPLE or COMPLEX."
                )
            },
            {"role": "user", "content": query}
        ],
        max_tokens=5
    )
    return response.choices[0].message.content.strip()


async def routed_completion(query: str, conversation_history: List[Dict]) -> str:
    """Route to cheap or expensive model based on query complexity."""
    complexity = await classify_query_complexity(query)

    if complexity == "SIMPLE":
        # GPT-4o-mini: $0.15/MTok input, $0.60/MTok output
        response = await openai_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[*conversation_history, {"role": "user", "content": query}],
            max_tokens=512
        )
        return response.choices[0].message.content
    else:
        # Claude Sonnet: $3.00/MTok input, $15.00/MTok output
        # Use for complex reasoning — the quality gap justifies the cost
        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=2048,
            messages=[*conversation_history, {"role": "user", "content": query}]
        )
        return response.content[0].text
flowchart LR subgraph Prompt Structure direction TB A["🔒 CACHED ZONE\nSystem prompt\n~2000 tokens\n$0.30/MTok on reads\nWritten once, read thousands of times"] B["🔒 CACHED ZONE\nRetrieved context / reference docs\n~1500 tokens\nCache if same docs reused\nacross multiple turns"] C["🔓 DYNAMIC ZONE\nConversation history\n~500-1000 tokens\nChanges every turn\nFull price: $3.00/MTok"] D["🔓 DYNAMIC ZONE\nCurrent user message\n~50-200 tokens\nAlways new\nFull price: $3.00/MTok"] end A --> B --> C --> D E["Example cost at 1000 req/day\n2000-token system prompt\nWithout caching: $6.00/day\nWith caching 90% hit rate: $0.87/day\nSavings: $5.13/day = $1,872/year"] style A fill:#2d6a4f,color:#fff style B fill:#2d6a4f,color:#fff style C fill:#d62828,color:#fff style D fill:#d62828,color:#fff style E fill:#f0f0f0,color:#333

3. Streaming Responses

Streaming is not a nice-to-have — it is a core reliability and UX pattern for any LLM application with a human in the loop. The reason is simple: users perceive a system that shows the first word in 300ms and streams the rest over 4 seconds as dramatically faster than one that returns the complete answer after 4.3 seconds, even though the total generation time is the same. The metric that matters for perceived responsiveness is Time to First Token (TTFT), not total generation time.

TTFT targets for production systems: under 300ms for real-time chat, under 1 second for document analysis, under 2 seconds for complex multi-step reasoning. These are achievable with the right infrastructure placement — LLM API calls from a server co-located with the provider's endpoints shave 50-150ms vs calls from user devices.

# FastAPI streaming endpoint
import asyncio
import json
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import anthropic

app = FastAPI()
stream_client = anthropic.Anthropic()


class StreamRequest(BaseModel):
    message: str
    conversation_id: str
    system_prompt: str = ""


async def generate_stream(message: str, system: str):
    """
    Generator that yields SSE-formatted chunks.
    Cost note: you pay for ALL tokens generated even on cancelled streams.
    Implement server-side cancellation to avoid paying for abandoned requests.
    """
    try:
        with stream_client.messages.stream(
            model="claude-sonnet-4-5",
            max_tokens=2048,
            system=system or "You are a helpful assistant.",
            messages=[{"role": "user", "content": message}]
        ) as stream:
            for text in stream.text_stream:
                # SSE format: data: <payload>\n\n
                # Wrap in JSON to carry metadata alongside content
                chunk = json.dumps({"type": "text", "content": text})
                yield f"data: {chunk}\n\n"

            # Send final usage stats so client can track cost
            final_message = stream.get_final_message()
            usage = {
                "type": "usage",
                "input_tokens": final_message.usage.input_tokens,
                "output_tokens": final_message.usage.output_tokens,
                # Approximate cost at Sonnet pricing
                "cost_usd": round(
                    final_message.usage.input_tokens * 3.00 / 1_000_000 +
                    final_message.usage.output_tokens * 15.00 / 1_000_000,
                    6
                )
            }
            yield f"data: {json.dumps(usage)}\n\n"
            yield "data: [DONE]\n\n"

    except anthropic.APIError as e:
        error = json.dumps({"type": "error", "message": str(e)})
        yield f"data: {error}\n\n"
        yield "data: [DONE]\n\n"


@app.post("/stream")
async def stream_endpoint(request: StreamRequest):
    return StreamingResponse(
        generate_stream(request.message, request.system_prompt),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # Critical for nginx — disables response buffering
            "Connection": "keep-alive"
        }
    )

The JavaScript client implements reconnection on dropped streams, which is essential for mobile users and unreliable connections. Partial content already shown to the user must be tracked so reconnection appends rather than replaces:

// JavaScript EventSource client with reconnect and cancellation
class LLMStreamClient {
    constructor(endpoint) {
        this.endpoint = endpoint;
        this.controller = null;
    }

    async stream(message, onChunk, onDone, onError) {
        // AbortController allows client-side cancellation
        // Without this, navigating away still consumes tokens server-side
        this.controller = new AbortController();

        const response = await fetch(this.endpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ message }),
            signal: this.controller.signal
        });

        if (!response.ok) {
            onError(new Error(`HTTP ${response.status}`));
            return;
        }

        const reader = response.body.getReader();
        const decoder = new TextDecoder();
        let buffer = '';

        try {
            while (true) {
                const { done, value } = await reader.read();
                if (done) break;

                buffer += decoder.decode(value, { stream: true });
                const lines = buffer.split('\n\n');
                buffer = lines.pop(); // Keep incomplete chunk in buffer

                for (const line of lines) {
                    if (!line.startsWith('data: ')) continue;
                    const data = line.slice(6);

                    if (data === '[DONE]') {
                        onDone();
                        return;
                    }

                    try {
                        const parsed = JSON.parse(data);
                        if (parsed.type === 'text') onChunk(parsed.content);
                        if (parsed.type === 'usage') onDone(parsed);
                        if (parsed.type === 'error') onError(new Error(parsed.message));
                    } catch (e) {
                        // Malformed JSON in stream — log and continue
                        console.warn('Stream parse error:', e, 'Raw:', data);
                    }
                }
            }
        } catch (e) {
            if (e.name !== 'AbortError') onError(e);
        }
    }

    cancel() {
        // Client-side cancel — sends abort signal to fetch
        // Server still generates tokens until it processes the disconnect
        // FastAPI detects client disconnect within ~500ms via request.is_disconnected()
        if (this.controller) this.controller.abort();
    }
}
Comparison visual
sequenceDiagram participant U as User participant S as Server participant L as LLM API Note over U,L: Non-Streaming — user waits 4.3 seconds before seeing anything U->>S: POST /complete S->>L: messages.create() L-->>S: [4000ms generating...] S-->>U: Complete response (4300ms total) Note over U: User sees nothing for 4300ms Note over U,L: Streaming — user sees first token at 300ms U->>S: POST /stream S->>L: messages.stream() L-->>S: chunk[0] "The" (300ms) S-->>U: SSE: "The" (TTFT = 300ms ✓) L-->>S: chunk[1..N] (ongoing) S-->>U: SSE: tokens streaming... L-->>S: [DONE] (4000ms) S-->>U: SSE: [DONE] (4300ms total) Note over U: Perceived as fast because content appeared at 300ms

4. Context Window Management

Modern LLMs support 128K to 1M token context windows, but "fits in context" and "performs well in context" are different claims. Research on needle-in-a-haystack benchmarks consistently shows degraded recall on information placed in the middle of very long contexts — models attend more strongly to the beginning and end of the prompt. Stuffing every available document into a 128K context window degrades answer quality compared to a well-curated 8K context.

The right mental model is a sliding window: keep the system prompt and the most relevant retrieved context fixed, summarize older conversation turns when the rolling history grows beyond budget, and always track token counts before sending.

import tiktoken
from typing import List, Dict, Optional, Tuple

# tiktoken for OpenAI models; Anthropic has its own token counting API
# Always count BEFORE sending — surprise context overruns are expensive
enc = tiktoken.encoding_for_model("gpt-4o")


def count_tokens_openai(text: str) -> int:
    """Count tokens for OpenAI models. ~0.1ms per call."""
    return len(enc.encode(text))


async def count_tokens_anthropic(messages: List[Dict], system: str) -> int:
    """Use Anthropic's token counting API — exact, model-specific."""
    response = client.messages.count_tokens(
        model="claude-sonnet-4-5",
        system=system,
        messages=messages
    )
    return response.input_tokens


class ConversationManager:
    """
    Manages conversation history within a token budget using rolling summarization.

    Strategy:
    - Keep last N turns verbatim (recent context is highest value)
    - Summarize older turns when budget exceeded (preserves key facts, saves tokens)
    - Entity extraction for persistent facts (user preferences, key decisions)

    Token budget allocation (example for 8K context):
    - System prompt: 1500 tokens (reserved)
    - Retrieved context: 3000 tokens (reserved for RAG)
    - Conversation history: 2500 tokens (managed here)
    - Response buffer: 1000 tokens (reserved for output)
    """

    def __init__(
        self,
        system_prompt: str,
        max_history_tokens: int = 2500,
        summarize_threshold: int = 2000,  # Summarize when history exceeds this
        keep_recent_turns: int = 4        # Always keep last N turns verbatim
    ):
        self.system_prompt = system_prompt
        self.max_history_tokens = max_history_tokens
        self.summarize_threshold = summarize_threshold
        self.keep_recent_turns = keep_recent_turns
        self.history: List[Dict] = []
        self.summary: Optional[str] = None

    def _count_history_tokens(self) -> int:
        total = 0
        for msg in self.history:
            total += count_tokens_openai(str(msg.get("content", "")))
        if self.summary:
            total += count_tokens_openai(self.summary)
        return total

    async def _summarize_old_turns(self, turns_to_summarize: List[Dict]) -> str:
        """
        Summarize older conversation turns.
        Cost: ~500 input tokens + ~200 output tokens = ~$0.0016 per summarization.
        Saves ~2000 tokens on every subsequent request = ~$0.006/request.
        Break-even: ~1 subsequent request after summarization.
        """
        conversation_text = "\n".join([
            f"{msg['role'].upper()}: {msg['content']}"
            for msg in turns_to_summarize
        ])

        response = await openai_client.chat.completions.create(
            model="gpt-4o-mini",  # Use cheap model for summarization
            messages=[
                {
                    "role": "system",
                    "content": (
                        "Summarize this conversation segment concisely. "
                        "Preserve: key decisions made, facts established, "
                        "user preferences, unresolved questions. "
                        "Omit: pleasantries, repeated information, verbose explanations. "
                        "Output a 2-4 sentence summary."
                    )
                },
                {"role": "user", "content": conversation_text}
            ],
            max_tokens=200
        )
        return response.choices[0].message.content

    async def add_turn(self, role: str, content: str):
        """Add a turn and compress history if over token budget."""
        self.history.append({"role": role, "content": content})

        # Check if we need to compress
        if self._count_history_tokens() > self.summarize_threshold:
            # Split: keep recent turns verbatim, summarize the rest
            recent = self.history[-self.keep_recent_turns:]
            old = self.history[:-self.keep_recent_turns]

            if old:
                new_summary = await self._summarize_old_turns(old)
                # Append to existing summary if present
                if self.summary:
                    self.summary = f"{self.summary}\n\nLater: {new_summary}"
                else:
                    self.summary = new_summary
                self.history = recent

    def get_messages_for_api(self) -> Tuple[List[Dict], int]:
        """
        Return messages formatted for API, prepending summary if present.
        Also returns token count for budget enforcement.
        """
        messages = []

        if self.summary:
            messages.append({
                "role": "user",
                "content": f"[Conversation summary from earlier: {self.summary}]"
            })
            messages.append({
                "role": "assistant",
                "content": "Understood, I have that context."
            })

        messages.extend(self.history)
        token_count = self._count_history_tokens()

        return messages, token_count

5. Reliability and Evaluation

LLM APIs have higher variance failure modes than traditional HTTP services: rate limiting under load, partial stream failures, context length errors from unexpected input sizes, and occasional model degradation that produces coherent but incorrect outputs. A production LLM client handles all of these.

import asyncio
import random
from dataclasses import dataclass
from enum import Enum
import anthropic
from openai import AsyncOpenAI
from pydantic import BaseModel, ValidationError
from typing import TypeVar, Type, Optional, Callable, Any

T = TypeVar('T', bound=BaseModel)


class LLMProvider(Enum):
    ANTHROPIC = "anthropic"
    OPENAI = "openai"


@dataclass
class LLMConfig:
    provider: LLMProvider
    model: str
    max_tokens: int = 1024
    timeout: float = 30.0  # Hard timeout — LLMs can genuinely hang on large outputs


class ResilientLLMClient:
    """
    Production-grade LLM client with:
    - Exponential backoff retries on rate limits and transient errors
    - Provider fallback (Anthropic → OpenAI)
    - Hard timeout enforcement
    - Structured output with retry on parse failure
    """

    def __init__(self):
        self.anthropic = anthropic.Anthropic()
        self.openai = AsyncOpenAI()

        # Primary + fallback provider chain
        self.primary = LLMConfig(
            provider=LLMProvider.ANTHROPIC,
            model="claude-sonnet-4-5",
            timeout=30.0
        )
        self.fallback = LLMConfig(
            provider=LLMProvider.OPENAI,
            model="gpt-4o",
            timeout=30.0
        )

    async def _call_with_timeout(
        self,
        config: LLMConfig,
        messages: List[Dict],
        system: str = ""
    ) -> str:
        """Single LLM call with hard timeout. Raises TimeoutError if exceeded."""
        try:
            if config.provider == LLMProvider.ANTHROPIC:
                # asyncio.wait_for wraps the sync Anthropic client in a thread
                response = await asyncio.wait_for(
                    asyncio.get_event_loop().run_in_executor(
                        None,
                        lambda: self.anthropic.messages.create(
                            model=config.model,
                            max_tokens=config.max_tokens,
                            system=system,
                            messages=messages
                        )
                    ),
                    timeout=config.timeout
                )
                return response.content[0].text

            else:  # OpenAI
                response = await asyncio.wait_for(
                    self.openai.chat.completions.create(
                        model=config.model,
                        max_tokens=config.max_tokens,
                        messages=[
                            {"role": "system", "content": system},
                            *messages
                        ]
                    ),
                    timeout=config.timeout
                )
                return response.choices[0].message.content

        except asyncio.TimeoutError:
            # Timeout after 30s — happens on very long outputs or provider latency spikes
            raise TimeoutError(f"LLM call timed out after {config.timeout}s")

    async def complete(
        self,
        messages: List[Dict],
        system: str = "",
        max_retries: int = 3
    ) -> str:
        """
        Complete with exponential backoff retries and provider fallback.
        Jitter prevents thundering herd on rate limit recovery.
        """
        last_error = None

        for attempt in range(max_retries):
            try:
                return await self._call_with_timeout(self.primary, messages, system)

            except (anthropic.RateLimitError, anthropic.APIStatusError) as e:
                last_error = e
                # Exponential backoff with full jitter: sleep(random(0, 2^attempt))
                # Full jitter outperforms equal jitter for distributed systems
                wait = random.uniform(0, 2 ** attempt)
                print(f"Primary provider error (attempt {attempt + 1}): {e}. "
                      f"Retrying in {wait:.1f}s")
                await asyncio.sleep(wait)

            except TimeoutError as e:
                last_error = e
                print(f"Primary provider timeout (attempt {attempt + 1})")

        # All retries exhausted — try fallback provider
        print(f"Falling back to {self.fallback.provider.value} after {max_retries} failures")
        try:
            return await self._call_with_timeout(self.fallback, messages, system)
        except Exception as e:
            raise RuntimeError(
                f"Both providers failed. Primary: {last_error}. Fallback: {e}"
            )

    async def complete_structured(
        self,
        messages: List[Dict],
        output_schema: Type[T],
        system: str = "",
        max_parse_retries: int = 2
    ) -> T:
        """
        Complete and parse into a Pydantic model.
        Retries with the parse error in the prompt on validation failure.
        """
        schema_instruction = (
            f"\n\nRespond with valid JSON matching this schema:\n"
            f"{output_schema.model_json_schema()}\n"
            f"Output ONLY the JSON object, no explanation."
        )

        current_messages = list(messages)

        for attempt in range(max_parse_retries + 1):
            response_text = await self.complete(current_messages, system + schema_instruction)

            try:
                # Handle markdown code fences that models sometimes add
                json_text = response_text.strip()
                if json_text.startswith("```"):
                    json_text = json_text.split("```")[1]
                    if json_text.startswith("json"):
                        json_text = json_text[4:]

                return output_schema.model_validate_json(json_text)

            except (ValidationError, ValueError) as e:
                if attempt < max_parse_retries:
                    # Add parse error to conversation so the model can self-correct
                    current_messages.append({"role": "assistant", "content": response_text})
                    current_messages.append({
                        "role": "user",
                        "content": f"That response failed validation: {e}. "
                                   f"Please correct it and respond with valid JSON only."
                    })
                else:
                    raise ValueError(
                        f"Failed to parse structured output after {max_parse_retries} retries. "
                        f"Last response: {response_text[:200]}"
                    )


# LLM-as-judge for automated quality evaluation
# Cost: ~500 tokens per evaluation = $0.0015 at GPT-4o-mini pricing
# Use for: regression testing on prompt changes, production quality sampling

class EvaluationResult(BaseModel):
    score: int  # 1-5
    reasoning: str
    passed: bool

llm_client = ResilientLLMClient()

async def llm_judge_quality(
    question: str,
    answer: str,
    reference_answer: Optional[str] = None
) -> EvaluationResult:
    """
    Use a cheap model to score answer quality.
    Calibrate against human labels before deploying to production.
    Run on 5% sample in production, 100% in staging regression tests.
    """
    reference_section = ""
    if reference_answer:
        reference_section = f"\nReference answer: {reference_answer}"

    result = await llm_client.complete_structured(
        messages=[{
            "role": "user",
            "content": (
                f"Question: {question}\n"
                f"Answer: {answer}"
                f"{reference_section}\n\n"
                "Score the answer 1-5 where:\n"
                "5: Complete, accurate, well-structured\n"
                "4: Mostly correct, minor gaps\n"
                "3: Partially correct, notable gaps\n"
                "2: Mostly incorrect or misleading\n"
                "1: Wrong or unhelpful"
            )
        }],
        output_schema=EvaluationResult,
        system="You are an expert evaluator. Be precise and critical."
    )
    return result

Latency SLOs for production LLM services: TTFT p50 under 400ms, p95 under 1.2s. Total generation p50 under 4s for typical outputs, p95 under 15s. Anything slower than these thresholds should trigger investigation — provider latency spikes, context window pressure, or infrastructure bottlenecks between your service and the LLM API.

6. Production Architecture Patterns

The LLM API call is rarely the bottleneck in a well-architected system. The bottlenecks are queue management for burst traffic, result deduplication for repeated queries, and observability gaps that make it impossible to diagnose cost spikes or quality regressions.

# Semantic result caching — cache LLM responses for semantically similar queries
# Not exact string matching; uses embedding similarity to detect near-duplicate queries
# Hit rate in practice: 15-35% depending on query diversity
# Saves: ~$0.018 per cache hit at 2000-token average input (Sonnet pricing)

from functools import lru_cache
import hashlib
import json
import time

class SemanticCache:
    """
    Cache LLM responses by query embedding similarity.
    Backend: Redis with vector search (Redis Stack) or any vector DB.
    TTL: 1 hour for factual queries, 24h for stable reference questions.
    """

    def __init__(self, similarity_threshold: float = 0.95, ttl_seconds: int = 3600):
        self.threshold = similarity_threshold
        self.ttl = ttl_seconds
        # In production: use Redis + vector index
        # This demo uses in-memory storage
        self._cache: List[Dict] = []

    def _get_embedding(self, text: str) -> List[float]:
        """Get embedding for cache key."""
        # Use a fast, cheap embedding model for cache lookups
        # text-embedding-3-small: $0.02/MTok — negligible vs LLM call cost
        response = client.messages.create(  # placeholder — use embedding API
            model="text-embedding-3-small",
            input=text
        )
        return response.data[0].embedding

    def get(self, query: str) -> Optional[str]:
        """Look up cached response by semantic similarity."""
        if not self._cache:
            return None

        query_emb = self._get_embedding(query)
        now = time.time()

        best_score = 0
        best_entry = None

        for entry in self._cache:
            if now - entry["timestamp"] > self.ttl:
                continue
            # Cosine similarity
            score = np.dot(query_emb, entry["embedding"]) / (
                np.linalg.norm(query_emb) * np.linalg.norm(entry["embedding"])
            )
            if score > best_score:
                best_score = score
                best_entry = entry

        if best_score >= self.threshold and best_entry:
            return best_entry["response"]
        return None

    def set(self, query: str, response: str):
        """Cache a query-response pair."""
        embedding = self._get_embedding(query)
        self._cache.append({
            "query": query,
            "embedding": embedding,
            "response": response,
            "timestamp": time.time()
        })


# Per-user token budget enforcement
# Prevents single users from exhausting shared rate limits
# Track daily token spend per user_id; block or throttle at threshold

class TokenBudgetEnforcer:
    """
    Track and enforce per-user daily token budgets.
    Storage: Redis with TTL, keyed by user_id:YYYY-MM-DD.
    """

    def __init__(self, daily_token_limit: int = 100_000):
        self.limit = daily_token_limit
        # In production: use Redis
        self._usage: Dict[str, int] = {}

    def check_and_increment(self, user_id: str, tokens_requested: int) -> bool:
        """
        Returns True if user is within budget, False if over limit.
        Atomically checks and increments — use Redis INCRBY for production.
        """
        key = f"{user_id}:{time.strftime('%Y-%m-%d')}"
        current = self._usage.get(key, 0)

        if current + tokens_requested > self.limit:
            return False

        self._usage[key] = current + tokens_requested
        return True

Observability is non-negotiable. Every LLM request should emit a structured log entry with: user_id, model, prompt_tokens, completion_tokens, cached_tokens, cost_usd, latency_ms, ttft_ms, request_id, session_id. This data drives cost attribution, quality monitoring, and capacity planning. Without it, you're operating blind.

Multi-tenant deployments must isolate usage tracking and, where compliance requires it, prompt/response logging per tenant. Store conversation history in tenant-partitioned storage. Rotate API keys per-environment, not globally — a compromised development key should not affect production.

Conclusion

The LLM production stack in 2026 is not complicated, but it requires discipline at every layer. The patterns in this post address the four places where teams consistently waste resources or sacrifice reliability.

On cost: prompt caching alone, applied to the system prompt, returns 50-90% on cached reads versus cold input. Model routing with a cheap classifier cuts LLM spend by 35-50% on mixed-complexity workloads. These are not marginal improvements — they determine whether a product is economically viable at scale.

On latency: streaming is not optional for interactive applications. TTFT under 300ms is achievable and required. Hybrid search with cross-encoder re-ranking adds 50ms of retrieval latency and meaningfully improves answer quality — the tradeoff is almost always worth it.

On reliability: exponential backoff with provider fallback handles the vast majority of LLM API failures transparently. Structured output with parse-error retry loops catches the long tail of model output failures. Hard timeouts prevent hung requests from blocking your async workers.

On accuracy: RAG quality comes from retrieval precision, not context window size. Semantic chunking with sentence-window expansion, hybrid dense+sparse search, and cross-encoder re-ranking produce retrievals that compete with significantly larger context approaches at 30-40% lower token cost.

The teams shipping the best LLM products in 2026 are not the ones with the biggest context windows — they are the ones who instrument every API call, measure cache hit rates, run eval suites before every prompt change, and treat the LLM as a component in a system rather than a magic box. Build the boring infrastructure first. The product quality follows.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-19 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Distributed Caching in 2026: Cache Invalidation, CDN Strategy, and Building a Cache That Doesn't Lie

Hero image

Introduction

Phil Karlton's famous quip — "There are only two hard problems in computer science: cache invalidation and naming things" — gets repeated at conferences, on t-shirts, and in job interviews. What rarely gets discussed is why cache invalidation is hard. The concept is not complex. You write new data, you remove or update the old cached version. That sentence takes three seconds to understand. So why does stale data cause production incidents at companies with hundreds of engineers?

The answer is timing. Cache invalidation is hard because the failures are invisible until the conditions align: a race between a write and a read, a cache miss storm that collapses your database under 40x normal load, a CDN serving a deleted product page to ten thousand users because no one called the purge API. These failure modes do not appear in development. They appear at 2 AM under load, when the cache hits 94% on most paths but a newly deployed schema breaks the other 6% in a way that corrupts user-visible data.

The cost calculation is asymmetric. A cache miss costs you latency — a round-trip to the database that might add 10-50ms to a response. A stale cache hit costs you correctness — a user sees a price that was updated six minutes ago, a balance that does not reflect their last transaction, a permission state that no longer applies. Latency is measurable and recoverable. Stale data erodes trust in ways that are harder to quantify.

In 2026, distributed caching has gotten more complex, not simpler. Multi-region deployments mean a write in us-east-1 has to invalidate cached data at CDN edge nodes in Frankfurt, Singapore, and São Paulo simultaneously. Serverless and edge compute mean your "in-process" cache has a lifetime of milliseconds. Read replicas, CQRS patterns, and event-sourced architectures introduce propagation delays between the write path and the read path that your cache has to account for.

This post covers the patterns that actually work at production scale: cache invalidation strategies with race-condition proofs, key design that prevents thundering herds, layered architectures that keep hit rates above 90%, CDN configuration that survives a content publish, and monitoring that tells you when your cache starts lying before your users notice.


1. Cache Invalidation Strategies

Six core patterns cover nearly every cache invalidation use case. The right choice depends on your consistency requirements, write volume, and whether your application can tolerate brief windows of stale data.

TTL-based expiry is the simplest approach: every cache entry has a time-to-live, after which it expires. No coordination required. The tradeoff is eventual consistency with a bounded staleness window. If your TTL is 60 seconds, you accept that reads in that window may return data up to 60 seconds old. This is the right default for content that changes slowly and where brief staleness is acceptable — product catalog data, user profile summaries, feature flag configs. It breaks down when writes are frequent or when correctness is load-bearing (financial balances, inventory counts, permissions).

Event-driven invalidation publishes an invalidation event on every write. A subscriber receives the event and deletes the cache key. This achieves near-real-time consistency without the write-through penalty, but it introduces a coordination dependency: if the invalidation subscriber is down or lagging, your cache serves stale data indefinitely. Redis keyspace notifications or a dedicated event bus (Kafka, Redis Streams) are common implementations.

Write-through writes to both the cache and the database in a single operation before returning to the caller. No stale reads are possible because the cache is always updated on write. The cost is higher write latency — every write pays the round-trip to both systems. This pattern makes sense when read performance is critical, write volume is moderate, and you cannot tolerate stale reads under any condition.

Write-behind (write-back) writes to the cache first and flushes to the database asynchronously. Write latency drops to a single cache round-trip, but you accept data loss on crash: if the cache node dies before the async flush completes, those writes are gone. This is the right pattern for high-throughput counters, rate limiters, and analytics events where some loss is acceptable. Never use it for financial transactions or any data where durability is required.

Cache-aside (lazy loading) is the most common pattern: on a cache miss, load from the database and populate the cache. Simple to implement, but it does not invalidate on write — you rely on TTL or explicit deletion to clear stale entries.

The double-delete pattern is what you reach for when event-driven invalidation and cache-aside combine and race conditions become a real risk. Without double-delete, a concurrent reader can populate the cache with stale data after your invalidation event fires. The sequence:

  1. Delete the cache key (first delete, before the write)
  2. Write to the database
  3. Delete the cache key again (second delete, after the write)

The first delete ensures any reader that is currently in-flight with stale data does not repopulate the cache after your write. The second delete clears any entry that a concurrent reader populated between the first delete and the database write completing. There is still a narrow window of staleness, but it is bounded to the time between the second delete and the next cache population — not indefinite.

Tag-based invalidation assigns cache entries to logical groups. When a product is updated, a single invalidation event clears all cache keys tagged with product:{id} — the product detail page, the search result snippets, the recommendation widget, the recently-viewed list. Tag-based invalidation is supported natively by Fastly (surrogate keys), Cloudflare (cache tags), and CloudFront (with origin-side logic). For Redis, you can implement it with a reverse index: a set keyed by tag containing all cache keys belonging to that tag.

Here is a complete implementation of event-driven invalidation with Redis keyspace notifications and write-through with cache-aside fallback:

import redis
import json
import hashlib
import time
from typing import Optional, Any, Callable
from dataclasses import dataclass

# Prevents stale repopulation after a write by using
# double-delete: delete before write, delete after write.
# Without this, a concurrent reader can populate the cache
# with the pre-write value between your delete and your DB write.

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

def double_delete_write(
    key: str,
    db_write_fn: Callable,
    *args,
    delay_ms: int = 50,
    **kwargs
) -> Any:
    """
    Write-through with double-delete race condition protection.

    Failure mode prevented: without the pre-delete, a reader that
    loaded stale data before your write will repopulate the cache
    with the old value after you delete and re-write.
    """
    # First delete: prevents stale repopulation by in-flight readers
    r.delete(key)

    # Write to the database (source of truth)
    result = db_write_fn(*args, **kwargs)

    # Small delay: lets any concurrent readers that were between
    # the first delete and the DB write complete their round-trip
    # before we delete again. In practice 50ms is sufficient.
    time.sleep(delay_ms / 1000)

    # Second delete: clears anything a concurrent reader populated
    # in the window between first delete and DB write completing
    r.delete(key)

    return result


def cache_aside_read(
    key: str,
    db_read_fn: Callable,
    ttl: int = 300,
    *args,
    **kwargs
) -> Any:
    """
    Cache-aside (lazy loading) with automatic cache population on miss.

    Failure mode: if cache is empty (cold start or after invalidation),
    all concurrent readers hit the DB simultaneously (thundering herd).
    See Section 2 for stampede protection.
    """
    cached = r.get(key)
    if cached is not None:
        return json.loads(cached)

    # Cache miss: load from DB
    value = db_read_fn(*args, **kwargs)

    if value is not None:
        r.setex(key, ttl, json.dumps(value))

    return value


# Event-driven invalidation via Redis Streams
# Subscriber deletes cache keys when write events are published.
# Failure mode: if subscriber is lagging, cache serves stale data.
# Mitigate with maxlen on stream and consumer group with ACK.

def publish_invalidation_event(entity_type: str, entity_id: str):
    """Publish cache invalidation event to Redis Stream."""
    r.xadd(
        "cache:invalidation",
        {
            "entity_type": entity_type,
            "entity_id": entity_id,
            "timestamp": str(time.time()),
        },
        maxlen=10000,  # Prevent unbounded stream growth
    )


def invalidation_subscriber_loop():
    """
    Consumer group subscriber that deletes cache keys on write events.
    Run as a background worker process.
    """
    group = "cache-invalidators"
    stream = "cache:invalidation"

    # Create consumer group (idempotent)
    try:
        r.xgroup_create(stream, group, id="0", mkstream=True)
    except redis.exceptions.ResponseError:
        pass  # Group already exists

    consumer_name = f"worker-{int(time.time())}"

    while True:
        # Read up to 10 events, block 1s if stream is empty
        messages = r.xreadgroup(
            group, consumer_name, {stream: ">"}, count=10, block=1000
        )

        if not messages:
            continue

        for stream_name, entries in messages:
            for entry_id, fields in entries:
                entity_type = fields["entity_type"]
                entity_id = fields["entity_id"]

                # Delete all cache keys for this entity
                pattern = f"{entity_type}:*:{entity_id}:*"
                keys = r.scan_iter(pattern)
                pipe = r.pipeline()
                for key in keys:
                    pipe.delete(key)
                pipe.execute()

                # ACK the message — prevents reprocessing on restart
                r.xack(stream, group, entry_id)
Architecture diagram
sequenceDiagram participant W as Writer participant C as Cache participant DB as Database participant R as Reader rect rgb(255, 220, 220) Note over W,R: WITHOUT double-delete (race condition) R->>C: GET product:42 (miss) W->>C: DELETE product:42 W->>DB: UPDATE product SET price=99 R->>DB: SELECT * FROM product WHERE id=42 (gets OLD value) R->>C: SET product:42 = {price: 79} (stale!) Note over C: Cache now has pre-write value end rect rgb(220, 255, 220) Note over W,R: WITH double-delete (safe) W->>C: DELETE product:42 (1st delete) W->>DB: UPDATE product SET price=99 Note over W: wait 50ms for in-flight readers W->>C: DELETE product:42 (2nd delete) R->>C: GET product:42 (miss) R->>DB: SELECT * FROM product WHERE id=42 (gets NEW value) R->>C: SET product:42 = {price: 99} (fresh) end

2. Cache Key Design

A cache key is a contract. Change the underlying data schema without changing the key and your cache silently serves incorrect data until TTL expires. Design keys with explicit versioning and namespacing from the start — retrofitting key design in production requires a full cache flush.

Key naming convention: {service}:{entity}:{id}:{version}

  • service prevents collisions between microservices sharing a Redis cluster
  • entity is the data type: user, product, session, feed
  • id is the primary identifier for the specific record
  • version is the schema version of the serialized value

Example: catalog:product:8842:v3. When you deploy a schema change that adds a required field, bump to v4. All v3 keys become unreachable immediately on deploy — no stale deserialization errors, no migration script.

Per-user cache keys include the user ID for personalized data: feed:user:7731:v2. This prevents cross-user data leaks and allows per-user invalidation on account update.

The thundering herd problem is what happens when a popular cache key expires. Every instance in your fleet misses simultaneously, issues a database query simultaneously, and you absorb 50x normal database load in a two-second window while all those queries execute and all those instances race to repopulate the same key. At p99, one of those queries is slow. The others pile up. Your database connection pool exhausts. Your application starts returning 500s.

TTL jitter is the cheap fix: instead of a fixed TTL of 300 seconds, use 300 + random.randint(-30, 30). Keys that would have expired together now expire across a 60-second window, spreading the load across 60 database round-trips instead of one synchronized burst. This is standard practice in any system with more than a handful of cache clients.

XFetch (probabilistic early recomputation) is the correct fix for high-traffic keys where even jittered expiry produces unacceptable spikes. The algorithm probabilistically recomputes a cache entry before it expires, based on how expensive the recomputation is and how close the entry is to expiry. Keys with expensive recomputation (slow DB queries, aggregation jobs) are recomputed earlier; cheap keys are refreshed close to their natural expiry. Only one instance recomputes at a time — others continue serving the cached value until the fresh value is available.

import math
import random
import time
import json
from typing import Optional, Callable, Tuple

def build_cache_key(
    service: str,
    entity: str,
    entity_id: str | int,
    version: str = "v1",
    user_id: Optional[str | int] = None,
) -> str:
    """
    Namespaced, versioned cache key builder.

    Versioned keys prevent stale deserialization: bump version on
    schema change and old cached values auto-invalidate on next read.
    """
    parts = [service, entity, str(entity_id), version]
    if user_id is not None:
        parts.insert(3, f"u{user_id}")
    return ":".join(parts)


def ttl_with_jitter(base_ttl: int, jitter_pct: float = 0.1) -> int:
    """
    Add ±jitter_pct random variation to TTL.

    Failure mode prevented: without jitter, all instances that
    cached a popular key at the same time will miss simultaneously,
    creating a synchronized DB load spike (thundering herd).
    """
    jitter = int(base_ttl * jitter_pct)
    return base_ttl + random.randint(-jitter, jitter)


def xfetch_get(
    r: redis.Redis,
    key: str,
    recompute_fn: Callable,
    base_ttl: int,
    beta: float = 1.0,
) -> Any:
    """
    XFetch algorithm: probabilistic early recomputation.

    Prevents thundering herd on high-traffic keys by recomputing
    a single instance's cache early, while all other instances
    continue serving the cached value.

    beta > 1.0: recompute earlier (use for expensive recomputations)
    beta < 1.0: recompute later (use for cheap recomputations)

    Reference: Vattani et al., "Exact analysis of TTL cache networks"
    """
    raw = r.get(key)

    now = time.time()

    if raw is not None:
        data = json.loads(raw)
        ttl_remaining = r.ttl(key)
        delta = data.get("_delta", 1.0)  # Seconds taken to compute last time

        # XFetch decision: probabilistically recompute before expiry
        # Higher delta (expensive computation) → recompute earlier
        # Higher beta → recompute earlier
        should_recompute = (
            now - delta * beta * math.log(random.random())
        ) >= (now + ttl_remaining - base_ttl)

        if not should_recompute:
            return data["value"]

    # Recompute (either cache miss or early recomputation)
    start = time.time()
    value = recompute_fn()
    delta = time.time() - start

    ttl = ttl_with_jitter(base_ttl)
    r.setex(
        key,
        ttl,
        json.dumps({"value": value, "_delta": delta}),
    )

    return value
flowchart TD A[Popular cache key expires\nTTL = 300s, no jitter] --> B{All 50 app instances\nmiss simultaneously} B --> C[50 concurrent DB queries\nfor same row] C --> D[DB connection pool exhausted\nQuery queue backs up] D --> E[p99 query: 800ms\nInstances timeout waiting] E --> F[500 errors\nRetries compound load] style A fill:#ff9999 style F fill:#ff6666 G[Same key with TTL jitter\n300s ± 30s] --> H{Instances expire\nstaggered over 60s window} H --> I[~1 DB query per second\nInstead of 50 simultaneous] I --> J[DB load stays flat\nNo queue buildup] J --> K[p99 stays at 12ms\nNo incidents] style G fill:#99ff99 style K fill:#66cc66

3. Layered Caching Architecture

A single Redis cluster is not a caching architecture. At scale, you need multiple cache layers operating at different latencies and scopes, with explicit rules for consistency and promotion between layers.

L1: In-process cache lives inside your application process. Zero network round-trips — sub-microsecond lookups against an in-memory LRU or bounded hash map. The tradeoff is that L1 is per-instance and not shared: if you have 40 application instances, you have 40 independent L1 caches that can diverge from each other. L1 is appropriate for hot reference data that changes infrequently: config objects, feature flags, permission sets, lookup tables. Bounded size is mandatory — an unbounded L1 cache is a memory leak. Typical size: 1,000-10,000 entries.

L2: Redis cluster is the shared cache tier. Millisecond latency, consistent across all application instances, supports atomic operations. Redis Cluster provides horizontal scaling and fault tolerance. This is where the majority of your application's cache reads should be served from. Hit rate target: 85-95% for well-designed applications.

L3: CDN edge cache eliminates origin hits entirely for cacheable HTTP responses. Requests served from CDN edge nodes never reach your application servers — Cloudflare, Fastly, and CloudFront operate hundreds of Points of Presence globally, serving from the edge node closest to the user. Latency target: under 5ms for cache hits. A well-configured CDN can absorb 90%+ of read traffic for public content.

L4: Origin / database is the source of truth. Every request that reaches L4 represents a failure of the layers above it. Hit rates at L4 should be minimized — target under 5% of all read requests hitting the database for any high-traffic path.

Cache promotion ensures a miss at L1 but a hit at L2 repopulates L1 for subsequent requests. A miss at L2 but a hit at CDN is harder to leverage in server-side caching, but CDN hit data can inform cache warming strategies.

Consistency across layers is where complexity lives. When you invalidate a key in L2, your L1 caches across 40 instances still hold the old value. Options: L1 TTL short enough to self-heal quickly (10-30 seconds), explicit L1 invalidation via a pub/sub channel, or accepting brief L1 divergence for data where eventual consistency is acceptable. For permission and authentication data, accept no divergence: skip L1 entirely or use zero-TTL L1 entries that expire immediately.

import time
import json
from collections import OrderedDict
from typing import Optional, Any, Callable
import redis
import threading

class LRUCache:
    """Thread-safe LRU in-process cache with bounded size."""

    def __init__(self, max_size: int = 1000, default_ttl: int = 30):
        self._cache: OrderedDict = OrderedDict()
        self._max_size = max_size
        self._default_ttl = default_ttl
        self._lock = threading.Lock()

    def get(self, key: str) -> Optional[Any]:
        with self._lock:
            if key not in self._cache:
                return None
            value, expires_at = self._cache[key]
            if time.time() > expires_at:
                del self._cache[key]
                return None
            # Move to end (most recently used)
            self._cache.move_to_end(key)
            return value

    def set(self, key: str, value: Any, ttl: Optional[int] = None):
        with self._lock:
            ttl = ttl or self._default_ttl
            expires_at = time.time() + ttl
            if key in self._cache:
                self._cache.move_to_end(key)
            self._cache[key] = (value, expires_at)
            # Evict least recently used if over capacity
            if len(self._cache) > self._max_size:
                self._cache.popitem(last=False)

    def delete(self, key: str):
        with self._lock:
            self._cache.pop(key, None)


class MultiLayerCache:
    """
    L1 (in-process LRU) → L2 (Redis) → L4 (origin/DB) with
    automatic cache promotion on miss.

    Cache promotion: miss at L1 but hit at L2 repopulates L1
    so subsequent requests from this instance avoid the network.

    Coordinated invalidation: invalidation deletes from both
    L1 and L2 simultaneously. L1 divergence window = L1 TTL max.
    """

    def __init__(
        self,
        redis_client: redis.Redis,
        l1_max_size: int = 1000,
        l1_ttl: int = 30,      # Short: L1 divergence window
        l2_ttl: int = 300,     # Longer: shared cache lifetime
    ):
        self._l1 = LRUCache(max_size=l1_max_size, default_ttl=l1_ttl)
        self._l2 = redis_client
        self._l1_ttl = l1_ttl
        self._l2_ttl = l2_ttl

    def get(
        self,
        key: str,
        origin_fn: Optional[Callable] = None,
    ) -> Optional[Any]:
        # L1 check: zero-latency in-process lookup
        value = self._l1.get(key)
        if value is not None:
            return value

        # L2 check: Redis round-trip (~1ms)
        raw = self._l2.get(key)
        if raw is not None:
            value = json.loads(raw)
            # Cache promotion: populate L1 for subsequent requests
            self._l1.set(key, value, ttl=self._l1_ttl)
            return value

        # L4 miss: load from origin/database
        if origin_fn is None:
            return None

        value = origin_fn()
        if value is not None:
            self._populate(key, value)

        return value

    def _populate(self, key: str, value: Any):
        """Populate both L1 and L2."""
        ttl = ttl_with_jitter(self._l2_ttl)
        self._l2.setex(key, ttl, json.dumps(value))
        self._l1.set(key, value, ttl=self._l1_ttl)

    def invalidate(self, key: str):
        """
        Coordinated invalidation: delete from L1 and L2 simultaneously.

        Failure mode: if you only delete from L2, this instance's L1
        continues serving stale data for up to l1_ttl seconds.
        """
        self._l1.delete(key)
        self._l2.delete(key)

    def invalidate_pattern(self, pattern: str):
        """Invalidate all keys matching a Redis glob pattern."""
        # L2 pattern delete
        keys = list(self._l2.scan_iter(pattern))
        if keys:
            self._l2.delete(*keys)
        # L1: cannot pattern-match, rely on TTL expiry
        # For strict L1 invalidation, maintain a reverse index
Comparison visual
flowchart LR U([User Request]) --> L1 subgraph Application Instance L1[L1: In-Process LRU\nSub-microsecond\n1K-10K entries\nTTL: 30s] end L1 -->|miss| L2 L1 -->|hit| R1([Response]) subgraph Shared Cache L2[L2: Redis Cluster\n~1ms latency\nShared across instances\nTTL: 300s] end L2 -->|promote to L1| L1 L2 -->|hit| R2([Response]) L2 -->|miss| L3 subgraph CDN Edge L3[L3: CDN PoP\nCloudflare / Fastly / CloudFront\nunder 5ms global\nHTTP Cache-Control] end L3 -->|hit| R3([Response]) L3 -->|miss| L4 subgraph Origin L4[(L4: Database\nSource of Truth\nTarget under 5% of reads)] end L4 -->|populate L2, L1| L2 L4 --> R4([Response])

4. CDN Caching Strategy

CDN caching is controlled entirely by HTTP response headers. If you do not explicitly set Cache-Control, your CDN will either cache nothing or cache everything with its default TTL — neither is what you want.

Cache-Control directives that matter in production:

  • max-age=N: client-side TTL in seconds
  • s-maxage=N: CDN-side TTL (overrides max-age for shared caches); use this to set different cache lifetimes for browsers vs CDN
  • stale-while-revalidate=N: serve stale content for N seconds while fetching a fresh version in the background. This is the single most impactful directive for perceived latency — a user never waits for a cache refresh
  • stale-if-error=N: serve stale content for N seconds if the origin returns a 5xx error. This is your CDN-level circuit breaker for origin outages
  • no-store: do not cache under any conditions (authentication pages, payment flows)
  • private: cacheable by browsers but not CDNs

Surrogate keys (cache tags) let you purge all CDN-cached responses related to a piece of content with a single API call. When you update a product, you purge product-8842 and every CDN edge node globally drops all responses tagged with that key — product detail pages, search result snippets, recommendation widgets — regardless of their remaining TTL. Cloudflare calls these Cache Tags. Fastly calls them Surrogate-Keys. CloudFront requires implementing the equivalent with a custom header and Lambda@Edge.

The Vary header instructs the CDN to cache separate response copies for different request header values. Vary: Accept-Encoding is standard (gzip vs brotli). Vary: Accept-Language creates per-language cache buckets. Avoid Vary: Cookie or Vary: Authorization — these make the vast majority of responses uncacheable at the CDN since nearly every authenticated user sends a unique cookie.

CDN origin shield (called Origin Shield in CloudFront, Shielding in Fastly, Tiered Cache in Cloudflare) collapses all edge cache misses through a single intermediate node before reaching your origin. Without origin shield, a cold cache across 250 edge nodes means 250 simultaneous origin requests for the same content. With origin shield, those 250 edge nodes coalesce into one origin request. Required for any CDN purge event — the moment you invalidate a popular content tag, origin shield prevents the invalidation from becoming a traffic spike.

from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
import httpx
import hashlib
import json
import time
from typing import Optional
import redis

app = FastAPI()
r = redis.Redis(host="localhost", port=6379, decode_responses=True)

CLOUDFLARE_ZONE_ID = "your-zone-id"
CLOUDFLARE_API_TOKEN = "your-api-token"


def set_cache_headers(
    response: Response,
    max_age: int = 60,
    s_maxage: int = 300,
    stale_while_revalidate: int = 60,
    stale_if_error: int = 86400,
    cache_tags: Optional[list[str]] = None,
):
    """
    Set Cache-Control and CDN cache tag headers.

    s_maxage > max_age: CDN holds content longer than browsers,
    preventing origin requests while allowing browser refresh.

    stale-while-revalidate: users never wait for cache refresh,
    background revalidation happens asynchronously.

    stale-if-error: CDN serves stale content during origin outages
    — your circuit breaker at the edge.
    """
    directives = [
        f"public",
        f"max-age={max_age}",
        f"s-maxage={s_maxage}",
        f"stale-while-revalidate={stale_while_revalidate}",
        f"stale-if-error={stale_if_error}",
    ]
    response.headers["Cache-Control"] = ", ".join(directives)

    if cache_tags:
        # Cloudflare: Cache-Tag header (comma-separated)
        response.headers["Cache-Tag"] = ",".join(cache_tags)
        # Fastly: Surrogate-Key header (space-separated)
        response.headers["Surrogate-Key"] = " ".join(cache_tags)


@app.get("/api/products/{product_id}")
async def get_product(product_id: int, response: Response):
    """
    Product endpoint with multi-layer caching and CDN cache tags.
    Cache tags enable targeted purge on product update without
    flushing the entire CDN cache.
    """
    cache_key = build_cache_key("catalog", "product", product_id, "v2")

    # Try Redis first
    cached = r.get(cache_key)
    if cached:
        product = json.loads(cached)
    else:
        # Load from DB (simulated)
        product = {"id": product_id, "name": "Widget", "price": 99.99}
        r.setex(cache_key, ttl_with_jitter(300), json.dumps(product))

    set_cache_headers(
        response,
        max_age=60,
        s_maxage=300,
        stale_while_revalidate=60,
        stale_if_error=86400,
        cache_tags=[f"product-{product_id}", "products"],
    )

    return product


async def purge_cloudflare_cache_tags(tags: list[str]):
    """
    Programmatic CDN purge via Cloudflare API on content update.

    Call this after every product write so CDN-cached pages
    immediately reflect the new state. Without purge, users see
    stale CDN responses for up to s_maxage seconds.
    """
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            f"https://api.cloudflare.com/client/v4/zones/{CLOUDFLARE_ZONE_ID}/purge_cache",
            headers={
                "Authorization": f"Bearer {CLOUDFLARE_API_TOKEN}",
                "Content-Type": "application/json",
            },
            json={"tags": tags},
        )
        resp.raise_for_status()
        return resp.json()


@app.put("/api/products/{product_id}")
async def update_product(product_id: int, data: dict):
    """
    Write path: update DB, invalidate Redis, purge CDN.
    Three-layer invalidation: Redis (immediate) + CDN (programmatic purge).
    """
    # DB write (simulated)
    # await db.execute("UPDATE products SET ... WHERE id = ?", ...)

    # Redis invalidation with double-delete
    cache_key = build_cache_key("catalog", "product", product_id, "v2")
    double_delete_write(
        cache_key,
        lambda: None,  # DB write already done above
    )

    # CDN purge: clears all edge-cached responses tagged with this product
    await purge_cloudflare_cache_tags([
        f"product-{product_id}",
    ])

    return {"status": "updated", "id": product_id}

5. Distributed Cache Patterns for Correctness

Performance is the headline, but correctness is the real requirement. A cache that serves wrong data is worse than no cache.

Read-your-writes consistency is the failure mode that frustrates users most visibly. A user posts a comment. The write succeeds. They reload their feed. The comment is not there — it is in the database, but the cached feed snapshot has not been refreshed yet. From the user's perspective, their action had no effect.

The solution is a short-circuit bypass: after a write, mark the user's session as "recently wrote" with a very short TTL (5-10 seconds). On subsequent reads within that window, bypass the cache and read directly from the database primary. After the window closes, resume normal cache-served reads. This adds negligible overhead — the bypass window is short, and most users are not writing continuously.

Negative caching prevents database hammering on missing keys. Without it, every request for a non-existent user ID (common in scraping, enumeration attempts, and cache stampedes after a delete) hits the database. Cache a sentinel value for not-found results with a short TTL (30-60 seconds). The cache returns the sentinel, your application interprets it as a miss, and the database is protected.

import hashlib
import time
from typing import Optional, Any, Callable
import redis
import json

r = redis.Redis(host="localhost", port=6379, decode_responses=True)

NEGATIVE_SENTINEL = "__NOT_FOUND__"
NEGATIVE_TTL = 60  # Cache not-found for 60s; prevents DB hammering


def cache_with_negative(
    key: str,
    db_read_fn: Callable,
    positive_ttl: int = 300,
) -> Optional[Any]:
    """
    Cache-aside with negative caching.

    Failure mode prevented: without negative caching, every request
    for a deleted or non-existent record hits the database.
    Common in scraping attacks and after delete operations.
    """
    cached = r.get(key)

    if cached == NEGATIVE_SENTINEL:
        return None  # Known not-found, skip DB entirely

    if cached is not None:
        return json.loads(cached)

    value = db_read_fn()

    if value is None:
        # Cache the not-found result with short TTL
        r.setex(key, NEGATIVE_TTL, NEGATIVE_SENTINEL)
        return None

    r.setex(key, ttl_with_jitter(positive_ttl), json.dumps(value))
    return value


def generate_etag(content: Any) -> str:
    """Generate ETag from content hash for conditional GET."""
    content_bytes = json.dumps(content, sort_keys=True).encode()
    return hashlib.sha256(content_bytes).hexdigest()[:16]


from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse

app = FastAPI()


@app.get("/api/articles/{article_id}")
async def get_article(article_id: int, request: Request, response: Response):
    """
    Conditional GET with ETag and 304 Not Modified.

    Reduces bandwidth: client caches response + ETag, sends
    If-None-Match on subsequent requests. Server returns 304
    if content unchanged — no body transmitted.

    Combine with Redis: ETag stored alongside content,
    check ETag before serializing full response body.
    """
    cache_key = build_cache_key("content", "article", article_id, "v1")
    etag_key = f"{cache_key}:etag"

    # Load content (from cache or DB)
    content = cache_aside_read(
        cache_key,
        lambda: {"id": article_id, "title": "Article", "body": "..."},
    )

    if content is None:
        return JSONResponse({"error": "Not found"}, status_code=404)

    current_etag = r.get(etag_key)
    if current_etag is None:
        current_etag = generate_etag(content)
        r.setex(etag_key, 300, current_etag)

    # Check If-None-Match: return 304 if client has current version
    client_etag = request.headers.get("if-none-match")
    if client_etag and client_etag == f'"{current_etag}"':
        return Response(status_code=304)

    response.headers["ETag"] = f'"{current_etag}"'
    response.headers["Cache-Control"] = "public, max-age=60, s-maxage=300"

    return content


def sticky_read_bypass(
    user_id: str,
    cache_key: str,
    db_read_fn: Callable,
    bypass_ttl: int = 10,
    cache_ttl: int = 300,
) -> Any:
    """
    Read-your-writes: bypass cache for users who recently wrote.

    Failure mode prevented: user writes data, immediately reads
    their feed/profile, cache returns pre-write state — user
    thinks their write was lost.
    """
    bypass_key = f"bypass:{user_id}"

    if r.exists(bypass_key):
        # User recently wrote — read from DB primary directly
        return db_read_fn()

    return cache_aside_read(cache_key, db_read_fn, ttl=cache_ttl)


def mark_user_wrote(user_id: str, bypass_ttl: int = 10):
    """
    Call after any write by user_id to activate bypass window.
    Expires automatically after bypass_ttl seconds.
    """
    r.setex(f"bypass:{user_id}", bypass_ttl, "1")

6. Monitoring and Debugging Cache Behavior

A cache you cannot observe is a cache you cannot trust. Hit rate drops before incidents — instrument early.

Redis INFO stats provide cluster-wide metrics: keyspace_hits, keyspace_misses, evicted_keys, expired_keys, used_memory, connected_clients. Calculate hit rate as hits / (hits + misses). Target: above 85% for general application caches, above 95% for high-traffic public APIs. A hit rate drop from 92% to 78% on a Tuesday afternoon is a signal, not noise — investigate what changed.

Eviction monitoring tells you when your cache is under memory pressure. When Redis reaches maxmemory, it applies its eviction policy (allkeys-lru, volatile-lru, allkeys-random, etc.). Evictions are visible in evicted_keys from INFO. If eviction rate is non-zero, your cache is too small for your working set — either increase memory, reduce key sizes, or lower TTLs on lower-priority keys. allkeys-lru is the right policy for most application caches: evict the least recently used key regardless of TTL.

Key-level inspection:
- TTL key returns remaining TTL in seconds (-1 = no expiry, -2 = key does not exist)
- DEBUG OBJECT key returns serialized length, encoding, and LRU idle time
- OBJECT ENCODING key shows memory encoding: ziplist/listpack for small hashes (compact), hashtable for large ones (more memory)
- OBJECT FREQ key (requires maxmemory-policy lfu) shows access frequency

Latency percentiles are the most actionable metric for cache health. Measure p50, p95, and p99 for cache reads (Redis round-trip) vs database reads. Target: Redis p99 under 5ms, database p99 under 50ms for indexed reads. A Redis p99 spike to 50ms is usually a network issue or a large key serialization bottleneck.

Cache poisoning detection: if your application deserializes cached values without validation, a compromised Redis node or a serialization bug can inject malformed data. Store a checksum alongside the cached value and verify on read. Discard and reload from DB on checksum mismatch — this converts a poisoning event into a cache miss rather than a corrupted read.

Alerting thresholds to configure:
- Hit rate < 80%: alert immediately, investigate DB load
- Eviction rate > 100 keys/sec: investigate memory pressure
- Redis p99 latency > 10ms: investigate network or large key sizes
- keyspace_misses spike: correlate with deployment events (schema version change causes full miss)
- connected_clients near maxclients (default 10,000): connection leak or pool misconfiguration

import redis
import time
from typing import Dict

r = redis.Redis(host="localhost", port=6379, decode_responses=True)


def get_cache_stats() -> Dict:
    """
    Pull key cache health metrics from Redis INFO.
    Returns hit_rate, eviction_rate, memory_usage_pct.
    """
    info = r.info()
    stats = r.info("stats")
    memory = r.info("memory")

    hits = stats.get("keyspace_hits", 0)
    misses = stats.get("keyspace_misses", 0)
    total = hits + misses

    hit_rate = (hits / total * 100) if total > 0 else 0

    used_memory = memory.get("used_memory", 0)
    max_memory = memory.get("maxmemory", 0)
    memory_pct = (used_memory / max_memory * 100) if max_memory > 0 else 0

    return {
        "hit_rate_pct": round(hit_rate, 2),
        "keyspace_hits": hits,
        "keyspace_misses": misses,
        "evicted_keys": stats.get("evicted_keys", 0),
        "expired_keys": stats.get("expired_keys", 0),
        "used_memory_mb": round(used_memory / 1024 / 1024, 1),
        "memory_usage_pct": round(memory_pct, 2),
        "connected_clients": info.get("connected_clients", 0),
    }


def inspect_key(key: str) -> Dict:
    """
    Inspect a specific cache key for TTL, encoding, and memory usage.
    Use DEBUG OBJECT to identify large keys that inflate memory or
    increase serialization latency.
    """
    ttl = r.ttl(key)
    encoding = r.object_encoding(key)

    try:
        debug_obj = r.debug_object(key)
    except Exception:
        debug_obj = {}

    return {
        "key": key,
        "ttl_seconds": ttl,
        "encoding": encoding,
        "serialized_length_bytes": debug_obj.get("serializedlength"),
        "lru_idle_seconds": debug_obj.get("lru_seconds_idle"),
    }


def check_cache_health(hit_rate_threshold: float = 80.0) -> Dict:
    """
    Health check function for monitoring integration (Datadog, Prometheus).
    Returns status=WARN or CRITICAL with actionable diagnostics.
    """
    stats = get_cache_stats()
    warnings = []

    if stats["hit_rate_pct"] < hit_rate_threshold:
        warnings.append(
            f"Hit rate {stats['hit_rate_pct']}% below threshold "
            f"{hit_rate_threshold}% — check DB load"
        )

    if stats["memory_usage_pct"] > 85:
        warnings.append(
            f"Memory at {stats['memory_usage_pct']}% — "
            f"increase maxmemory or audit key sizes"
        )

    status = "OK" if not warnings else "WARN"

    return {"status": status, "stats": stats, "warnings": warnings}

Conclusion

Cache invalidation is a consistency problem that presents as a performance problem. When your cache is lying — serving stale prices, outdated permissions, deleted content — the debugging path is long because the symptoms (wrong data, user complaints) look nothing like the cause (a race condition between a write and a read that occurs in a 50-millisecond window at peak traffic).

The patterns in this post map to specific failure modes. Double-delete prevents stale repopulation from concurrent readers. TTL jitter and XFetch prevent thundering herds on key expiry. Layered caching with cache promotion keeps hit rates above 90% while containing the footprint of any single layer's inconsistency. CDN cache tags with programmatic purge prevent content updates from being invisible at the edge for minutes or hours. Negative caching stops database hammering from non-existent key lookups. Read-your-writes bypass prevents users from losing confidence in your application's responsiveness.

The production numbers that matter: hit rate above 85% for Redis (above 95% for high-traffic public APIs), Redis p99 under 5ms, CDN serving 80%+ of public read traffic, database receiving under 5% of total reads. If your numbers are below these targets, the gap is almost always one of the patterns above — not hardware or infrastructure.

Start with correct key design, add TTL jitter from day one, implement double-delete on any write path that has concurrent readers, and instrument hit rate before you need to debug it. The cache that does not lie is not one that never has misses — it is one where every miss is intentional and every hit is fresh.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-18 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Bigger Is Not the Same as Better. The Job That Moved Is the Phone, Not the Lab.

Bigger is a plan. The phone is the receipt. The brief for this cycle is a question: does bigger always mean better in AI? The 2026 answer i...