When it looked like a Caching Problem but Needed an Observability First
Django cache performance: measure first, tune second
At 4:30 on a Friday, the performance readout still had a seductively simple story: the platform was slow because it needed more caching. The trouble was that we could not explain the Django cache performance we were seeing. We did not know which requests were slow. Nor did we know which requests already hit the cache. Invalidation might also have been erasing useful entries. We replaced the proposed caching plan with an observability readout: measure latency by route, expose cache outcomes, trace invalidation, and only then change cache policy.
TL;DR
Django cache performance cannot be tuned responsibly when latency, cache coverage, and invalidation behavior are unknown. Instrument requests, cache outcomes, and invalidation events first. Then use that evidence to decide whether the bottleneck is computation, I/O, cache policy, or reliability elsewhere.
The argument is direct. Adding cache capacity or widening coverage before explaining the system is not performance engineering. It is an implementation preference searching for evidence.
Why the caching story was attractive
Caching offered a tidy narrative for an untidy situation. Some API requests completed in under roughly 500 ms. Other paths had experienced downtime. A database-backed cache-record model existed. Django signals invalidated records after model changes. Beyond that, our answers became vague.
We did not know:
- Which API routes and rendered pages were cached.
- What percentage of eligible requests produced hits.
- How latency differed between hits and misses.
- Whether invalidation removed one entry or a broad family of entries.
- How often invalidation occurred.
- Whether database queries, external calls, serialization, or queueing made the slowest requests slow.
That uncertainty mattered. The readout was blocking product scope and dependent engineering work. Two bodies of requirements were also converging: an older delivery scope and a newer platform roadmap. A claim such as “improve caching” could quietly shape architecture, schedules, and staffing. Yet it had no measured denominator.
A performance proposal without a measured request path is a roadmap assumption, not a diagnosis.
Admitting this in a high-stakes meeting was awkward. “We need more visibility” sounds less decisive than “we will add caching.” It is also the more accountable answer.
How to measure Django cache performance before tuning
I organize this investigation around three linked surfaces: request behavior, cache behavior, and mutation behavior. If one is missing, the others are easy to misread.
Measure requests by stable route
Raw URLs are dangerous metric labels. A path such as /stores/8421/ creates one time series per identifier. Eventually, that gives the monitoring system its own performance problem. I record the resolved Django route name instead.
A minimal middleware can emit duration and status while preserving bounded labels:
import time
from prometheus_client import Counter, Histogram
REQUESTS = Counter(
"http_requests_total",
"HTTP requests",
["route", "method", "status"],
)
LATENCY = Histogram(
"http_request_duration_seconds",
"Request duration",
["route", "method"],
)
class RequestMetricsMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
started = time.perf_counter()
response = self.get_response(request)
match = getattr(request, "resolver_match", None)
route = match.view_name if match and match.view_name else "unmatched"
method = request.method
REQUESTS.labels(route, method, str(response.status_code)).inc()
LATENCY.labels(route, method).observe(time.perf_counter() - started)
return responseEnable it after Django has enough request context to resolve the view:
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"platform_observe.middleware.RequestMetricsMiddleware",
]This does not identify every cause. It establishes where to look. I want p50, p95, request volume, and error rate by route. I also split the results by deployment. A single average conceals tail latency and low-volume disasters.
Record cache outcomes at the boundary
A cache hit counter belongs beside the code that decides whether to use a cached value. Inferring hits from faster requests is unreliable. A miss can still be cheap. A hit can still sit behind other slow work. The Django cache framework documentation describes the underlying cache API and configuration options.
from prometheus_client import Counter
CACHE_OUTCOMES = Counter(
"application_cache_outcomes_total",
"Application cache lookups",
["namespace", "outcome"],
)
def cached_store_list(cache, key, loader):
value = cache.get(key)
if value is not None:
CACHE_OUTCOMES.labels("store_list", "hit").inc()
return value
CACHE_OUTCOMES.labels("store_list", "miss").inc()
value = loader()
cache.set(key, value, timeout=300)
return valueA five-minute timeout in this example is a starting policy, not a universal recommendation. The useful result is the ratio:
sum by (namespace) (
rate(application_cache_outcomes_total{outcome="hit"}[15m])
)
/
sum by (namespace) (
rate(application_cache_outcomes_total[15m])
)Hit rate alone is insufficient. A 95 percent hit rate on a cheap endpoint may save little time. A 30 percent hit rate on an expensive endpoint may save much more. Join cache outcomes to route latency and traffic volume before ranking work.
Make invalidation observable
The platform used Django signals to invalidate cached records. Signals are convenient because they separate mutation code from cleanup. That separation is also their sharp edge. Invalidation becomes implicit control flow.
I instrument the receiver with a reason and namespace. I avoid putting object IDs into metric labels. IDs belong in structured logs or traces.
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from prometheus_client import Counter
logger = logging.getLogger(__name__)
INVALIDATIONS = Counter(
"application_cache_invalidations_total",
"Cache invalidations",
["namespace", "reason"],
)
@receiver(post_save, sender=Store)
def invalidate_store_list(sender, instance, created, **kwargs):
cache.delete("stores:list:v1")
reason = "create" if created else "update"
INVALIDATIONS.labels("store_list", reason).inc()
logger.info(
"cache_invalidated",
extra={"namespace": "store_list", "object_id": instance.pk},
)Warning
post_save can run inside a transaction that later rolls back. When cache correctness depends on committed data, schedule invalidation with transaction.on_commit() instead of deleting immediately.
That change looks like this:
from django.db import transaction
@receiver(post_save, sender=Store)
def invalidate_store_list(sender, instance, created, **kwargs):
reason = "create" if created else "update"
def invalidate():
cache.delete("stores:list:v1")
INVALIDATIONS.labels("store_list", reason).inc()
transaction.on_commit(invalidate)Django’s database transaction documentation also recommends transaction.on_commit() for cache changes that must wait for a successful commit.
The remaining limitation is real. Signal receivers are harder to discover and test than explicit service-layer invalidation. They can also trigger invalidation storms during bulk updates. Instrumentation makes that behavior visible. It does not make the coupling disappear.
What we rejected before collecting data
We considered three plausible fixes. None was ready to approve.
| Proposal | Why it sounded reasonable | Why we deferred it |
|---|---|---|
| Cache more routes | Some requests were reportedly slow | Coverage and per-route cost were unknown |
| Increase TTLs | Longer retention could improve hit rate | Staleness tolerance and invalidation frequency were unknown |
| Replace the cache backend | Downtime suggested an infrastructure issue | Application failures and backend failures were not separated |
The rejection was temporary and evidence-based. Any of these fixes could become correct after measurement. The point was to stop a plausible mechanism from becoming a committed plan before we knew what it would fix.
A cache also adds costs that performance decks tend to place in small print. These include stale data, key-version migrations, cold starts, memory pressure, and invalidation fan-out. A cache is also another dependency during incidents. Those costs can be worthwhile. They still belong in the decision.
Turning measurements into a decision
The readout needed to unblock work. “Instrument everything” would have been another form of avoidance. We defined a bounded evidence package:
- Rank the ten highest-volume routes by p95 latency and error rate.
- Report cache hit, miss, and invalidation rates for each cache namespace.
- Compare hit and miss latency for cached routes.
- Trace a small sample of the slowest requests to database and external-call spans.
- Identify which roadmap decisions depend on each unresolved technical question.
The last step matters. If the baseline and verification use different definitions, improvement becomes a matter of slide composition. The same route labels, percentile windows, and cache counters should survive the change. This is also the discipline behind measuring performance work before declaring victory.
This approach has a cost. Instrumentation consumes engineering time while dependent work waits. Traces can add storage expense. Aggressive sampling can also add measurable overhead. I would cap labels, start with low trace sampling, and time-box the first readout. The alternative is faster only until the wrong implementation reaches production.
FAQ
Why is cache hit rate unreliable by itself?
Hit rate does not include request cost, traffic volume, tail latency, or staleness. Correlate hits and misses with route-level latency and request volume before deciding where caching matters.
Where should I measure Django cache hits and misses?
Measure them at the application boundary that decides whether to use the cached value. Do not infer cache outcomes from response time or backend command counts.
Why can Django signal cache invalidation cause stale data?
A signal may invalidate before the surrounding transaction commits, then expose old database state to the next cache fill. Use transaction.on_commit() when invalidation must follow a successful commit.
What metrics should a performance readout include?
Include request volume, p50 and p95 latency, error rate, cache outcomes, invalidation rate, and traces for representative slow requests. Present them by stable route or cache namespace rather than raw URL.
When should we add more caching?
Add it when measurements show repeated expensive work, acceptable staleness, and an invalidation policy you can operate. Verify the result with the same measurements used for the baseline.
The responsible performance plan begins where the attractive explanation runs out of evidence.
