Cutting Backend Load with uWSGI's cache2 Response Cache
uWSGI's cache2 directive caches full WSGI responses in-process, keyed by path and method. Here's a minimal setup, how to verify hits, and the staleness and key-collision traps to avoid.
17 Jul 2026, 11:05 UTC

If your uWSGI-served app spends most of its time re-rendering the same expensive pages for anonymous traffic, you can shave real load off the backend without adding Redis or Varnish: uWSGI ships with a built-in response cache, configured through the cache2 directive. The catch is that it's a blunt instrument — it caches by request path and method, not by the full request — so it's excellent for idempotent, mostly-static endpoints and risky for anything personalized. This post walks through what it actually does, a minimal working setup, and where it bites.
What cache2 actually caches
uWSGI has had an internal caching framework for years, originally exposed through the older cache subsystem and the uwsgi.cache_set()/cache_get() API. The newer cache2 option builds on that framework to cache entire WSGI responses: when a request comes in, uWSGI computes a key from the path and HTTP method, checks the cache, and serves the stored response body directly if there's a hit — your Python code never runs.
Two properties matter for deciding whether to use it:
- It's in-process memory. Entries live in the uWSGI master/worker memory space. Restart the master and the cache is gone, unless you've pointed it at persistent storage.
- The default key ignores query strings and headers.
/report?month=janand/report?month=febcollide unless you customize the key. Same forAuthorization— which is why you should never enable this blindly on authenticated endpoints.
A minimal working setup
Here's a small WSGI app that simulates an expensive render, plus the uWSGI config that caches it:
# app.py
import time
def application(environ, start_response):
time.sleep(0.5) # pretend this is a heavy DB query
body = b"rendered at %f" % time.time()
start_response("200 OK", [("Content-Type", "text/plain")])
return [body]# uwsgi.ini
[uwsgi]
module = app:application
http = :8080
cache2 = name=responses,items=1000,maxsize=500000000
cache2-expires = 300
route = ^/report cache:key=${REQUEST_METHOD}${PATH_INFO},name=responsesRun it from the shell as a user with permission to bind the port:
uwsgi --ini uwsgi.iniThen verify from another terminal:
time curl -s http://localhost:8080/report
time curl -s http://localhost:8080/reportWhat to check: the first request should take roughly the 0.5s your app sleeps; the second should return almost instantly with the same timestamp in the body — proof the app didn't run. If both requests take 0.5s, the cache route isn't matching; double-check the regex and that the cache name in the route matches the cache2 declaration. Note that exact option spellings and route action names vary across uWSGI versions, so confirm against uwsgi --help | grep cache on your build before deploying.
Verifying hits and watching memory
uWSGI exposes internal counters through its stats server. Add this to the ini:
stats = 127.0.0.1:1717Then query it (the uwsgi package's own tooling or a simple TCP read works; the output is JSON):
nc 127.0.0.1 1717 | python3 -m json.tool | grep -i cacheLook for cache hit/miss counters incrementing as you repeat requests. This is also your safety check for the memory trade-off: the cache preallocates space proportional to maxsize and item count, and that memory lives in every process that shares the cache. A 500MB cache is fine on a dedicated box; on a small container it can push you into OOM territory. Reduce maxsize, restart, and watch RSS (ps -o rss= -p <pid>) to confirm the footprint actually shrinks.
The trade-offs you sign up for
Three limitations deserve honesty:
- Staleness masks bugs. Until
cache2-expireselapses (300s in the example), users see the old body even if the underlying data changed — or if your app is now erroring. A broken backend can look healthy. Pair this with monitoring on the origin, not just the edge. - No automatic invalidation. There's no built-in hook for "purge when the database row changes." You either wait for expiry, restart, or build invalidation yourself via the cache API. Short expiries (30–120s) are often the pragmatic compromise.
- Key collisions on dynamic routes. If you cache a path that serves different content per query param or per user, you will serve the wrong content to the wrong person. Customize the key (e.g., include
${QUERY_STRING}) or restrict the route regex to genuinely anonymous, deterministic endpoints.
When to reach for something else
cache2 is the right tool when you have a handful of hot, anonymous, cacheable endpoints and you don't want another moving part in the stack. If you need shared caching across multiple uWSGI instances, header-aware variation, or purge APIs, that's Varnish or a CDN's job — uWSGI's cache won't grow into that role gracefully.
Actionable next step: pick your single most-requested anonymous endpoint, enable cache2 with a 60-second expiry in staging, and compare hit ratios and response times in the stats output before and after. If the hit ratio is high and staleness is acceptable, you've found free capacity; if not, you've learned that with a five-line config change rather than a new infrastructure dependency.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.