Output caching across sessions¶
@reactive.calc caches within a session. The moment a second user
connects, the calc reruns for that user. For apps where many users
trigger the same expensive computation (dashboards, demos, public
tools), this is pure duplicate work.
Wrapping a pure helper in functools.cache or functools.lru_cache
at module scope gives you a per-process, cross-session cache. One
user's miss pays the cost once; every other user on the same replica
gets the result in under a millisecond.
On a CPU-bound synthetic app (one replica, small input domain, 45 ms
compute per call), adding @cache to a module-scope helper produced
a 37–64× drop in median response time and +21% throughput at the
saturation point where the uncached version starts dropping requests:
| app | concurrency | interactions / 30 s | p50 ms | p95 ms |
|---|---|---|---|---|
| uncached | 10 | 1 185 | 51.2 | 234.5 |
| cached | 10 | 1 193 | 0.8 | 158.8 |
| uncached | 30 | 2 755 | 101.0 | 274.3 |
| cached | 30 | 3 329 | 2.7 | 174.6 |
Your mileage will vary with compute cost, input-domain size, and cache hit rate — but the shape of the win is reliable: the median collapses to a dict lookup, and throughput stops falling off at saturation.
Recipe¶
from functools import lru_cache
from shiny import App, render, ui
@lru_cache(maxsize=128)
def expensive_plot(xvar: str, yvar: str, filter_tuple: tuple[str, ...]):
# Any hashable-keyed function is a candidate: SQL results,
# matplotlib figures, sklearn predictions, pandas aggregations.
df = load_frame()
df = df[df["category"].isin(filter_tuple)]
return make_fig(df, xvar, yvar)
app_ui = ui.page_fluid(
ui.input_select("xvar", "X", {"a": "A", "b": "B"}),
ui.input_select("yvar", "Y", {"x": "X", "y": "Y"}),
ui.input_checkbox_group("filters", "Categories", ["north", "south"]),
ui.output_plot("chart"),
)
def server(input, output, session):
@render.plot
def chart():
return expensive_plot(
input.xvar(),
input.yvar(),
tuple(sorted(input.filters())), # tuple so it's hashable
)
app = App(app_ui, server)
Three properties make this work:
expensive_plotis defined at module scope, not insideserver(). That's what makes the cache survive across sessions — one dict per process, not one per session.- All arguments are hashable. Lists and dicts aren't; convert to
tuple(sorted(...))orfrozenset(...)before calling. - The function is a pure function of its arguments. No
datetime.now, no global-state reads, no reads from a DB that changes over time. If the result should refresh when data changes, output caching is the wrong tool — use@reactive.pollor@reactive.file_readerinstead.
When to bound the cache¶
@cache is unbounded. Prefer @lru_cache(maxsize=N) unless the key
space is genuinely small and each entry is small:
| Key space | Entry size | Recommendation |
|---|---|---|
| < 100 | < 1 MB | @cache is fine |
| small but entries are big (DataFrames, figures) | any | @lru_cache(maxsize=32) — bound for memory |
| large (date ranges, free-text) | any | @lru_cache(maxsize=256) + measure |
Size the bound by N × peak entry size — that's the steady-state
memory added per replica. ShinyHub's per-app memory limit
(memory_limit_mb) still applies, so an unbounded cache that OOMs
will trigger an OOM-kill and restart.
When not to use it¶
- User-specific results. Caching
build_report(user_id, …)shares across sessions but by-user keying works; cachingbuild_report(filters)when filters silently include user data leaks between users. If the key captures the user explicitly, you're fine. - Time-sensitive data. Prices, live metrics, "rows added in the
last minute" — either don't cache or use
@reactive.pollwith a TTL-keyed helper (def fetch(bucket=int(time.time() // 60))). - Cheap computations. If the work is under ~5 ms, the caching overhead and cognitive cost aren't worth it.
Interaction with replicas¶
The cache is per-process, so each replica has its own dict.
Increasing replicas doesn't dilute the hit rate once warm (random
input selection still covers the small key space quickly) — but it
does multiply total memory overhead by the replica count. Don't
forget that when sizing memory_limit_mb.
Sticky cookies don't help here: the cache is content-keyed, not session-keyed. Two different users on two different replicas that happen to request the same plot will each cause one miss on their own replica and then hit for the rest of the session.
Interaction with schedules¶
If a scheduled job produces a file that the app reads, output-caching the file read is safe — but key it by the file's mtime so fresh writes invalidate the cache:
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def _load(mtime_ns: int):
return pd.read_parquet("data/shared/fetch/latest.parquet")
def load_latest():
p = Path("data/shared/fetch/latest.parquet")
return _load(p.stat().st_mtime_ns)
_load is keyed by the mtime, so replacing the parquet evicts the
old entry automatically on the next call.
Verifying the cache is working¶
Print the hit ratio on demand (only valid for lru_cache, not
cache):
@lru_cache(maxsize=128)
def expensive_plot(...): ...
# Log or expose via a debug route.
info = expensive_plot.cache_info()
# CacheInfo(hits=420, misses=36, maxsize=128, currsize=36)
A hit ratio above ~90% under steady load confirms the cache is doing its job. A ratio near 0 means your key space is too large or the function isn't actually being called through the cached entry point — double-check the module-scope definition.