Caching includes
Includes get their own cache lifecycle independent of the page. Declare a TTL on the element, and bext stores the resolved HTML in its tiered cache; subsequent requests serve from cache without invoking the resolver.
<bext-include src="/__bext/nav"
ttl="5m" swr="1h" tags="chrome"
></bext-include>
The nav is resolved once, served fresh for 5 minutes, then served stale for up to an hour while bext revalidates it in the background. Tagged chrome so you can invalidate all chrome fragments with a single purge call.
The attributes#
| Attribute | Format | Purpose |
|---|---|---|
ttl |
5m, 1h, adaptive |
Cache freshness lifetime |
swr |
1h, 1d |
Stale-while-revalidate window |
tags |
chrome,nav |
Tags for grouped invalidation |
Duration syntax: 30s, 5m, 1h, 1d, or raw ms (e.g., 200ms). No ttl means no caching.
How caching works#
bext's include cache is a wrapper over the existing tiered cache:
Request arrives
↓
Compute cache key = hash(src, vary_values)
↓
L0 (hot map) → HIT: return cached HTML
→ MISS ↓
L1 (mmap) → HIT: promote to L0, return
→ MISS ↓
L2 (Redis) → HIT: promote to L0+L1, return
→ MISS ↓
Resolver chain → compute HTML
↓
Store in L0 + L1 + L2 with TTL
↓
Return HTML
- L0 is a per-process DashMap — sub-microsecond reads.
- L1 is mmap-backed and persists across restarts, so warm caches survive deploys.
- L2 is Redis (optional) and shares state across horizontally-scaled instances.
Stale-while-revalidate#
With swr="1h":
time=0 include resolved, stored in cache (TTL=5m, SWR=1h)
time=4m request → fresh → return cached
time=6m request → stale but in SWR window
→ return cached HTML immediately (non-blocking)
→ spawn background task to revalidate
background task completes → cache updated
time=1h5m request → expired → cache miss → resolve
The user never waits for revalidation. The stampede guard ensures only one background revalidation runs per key, even under traffic spikes.
Cache tags#
Tags let you invalidate groups of includes at once:
<bext-include src="/__bext/nav" tags="chrome,nav"></bext-include>
<bext-include src="/_partials/footer.html" tags="chrome"></bext-include>
After deploying a new footer:
curl -X POST http://localhost/__bext/cache/purge \
-d '{"tags":["chrome"]}'
Both includes are invalidated. The next request rebuilds them.
Tags can also come from config:
[[include.rules]]
pattern = "/__bext/nav*"
tags = ["chrome", "nav"]
[[include.rules]]
pattern = "/_partials/*"
tags = ["chrome"]
Adaptive TTL#
Use ttl="adaptive" to scale cache lifetime with hit count:
<bext-include src="/partials/homepage-hero"
ttl="adaptive" swr="1h" tags="homepage"
></bext-include>
The formula (same as ISR adaptive TTL):
next_ttl = min(base_ttl * (1 + ln(1 + hits)), max_ttl)
With defaults (base=60s, max=600s, threshold=10 hits):
| Hit count | Effective TTL |
|---|---|
| < 10 | 60s (base) |
| 100 | ~337s |
| 1000 | ~475s |
| 10000 | 600s (capped) |
| ∞ | 600s (capped) |
Popular includes stay cached longer (better amortization of resolution cost). Cold includes expire at the base TTL (better freshness for the long tail).
Stampede protection#
When a hot include expires and a flood of concurrent requests arrive, only one worker regenerates — others either serve stale content (if in the SWR window) or wait for the first regeneration to complete.
pub enum StampedeLock {
Acquired(token), // you regenerate
Waiting, // someone else is regenerating
}
This prevents the "thundering herd" problem where N workers all compute the same expensive fragment simultaneously.
Configuration#
Global cache tuning:
[include.cache]
enabled = true
l1_max_entries = 5000
l2_prefix = "bext:include:"
max_variants_per_include = 100
[include.cache.adaptive]
base_ttl_ms = 60_000
max_ttl_ms = 600_000
extension_threshold = 10
Per-source defaults (applied to any matching include, including SSI/ESI):
[[include.rules]]
pattern = "/__bext/nav*"
ttl = "5m"
swr = "1h"
tags = ["chrome"]
[[include.rules]]
pattern = "/api/*"
ttl = "30s"
swr = "5m"
timeout = "200ms"
[[include.rules]]
pattern = "/_partials/*"
ttl = "1d"
tags = ["chrome"]
The first matching rule wins. Attributes on the HTML element override the rule.
The first matching [[include.rules]] entry wins. Put more-specific patterns (e.g. /api/live/*) before broad catch-alls (e.g. /api/*) so they are not shadowed.
When not to cache#
Omitting ttl disables the cache entirely — the resolver runs on every request. For live data, prefer a short ttl plus swr, or use Realtime includes instead of relying on zero-TTL caching.
Some includes should resolve fresh on every request:
- Per-user personalization without a vary dimension (you'd be caching one user's content for another).
- Live data like current timestamps, cryptocurrency prices, stock quotes. Use
realtimeinstead. - One-shot operations like CSRF tokens, nonces.
Omit ttl to bypass the cache entirely — the resolver runs on every request.
See also#
- Vary-aware caching —
vary="role,locale"for per-dimension caching - Timeout & Fallback — resilience for slow sources
- Realtime includes — live updates instead of short TTLs
- Cache Purge API — bext's tag-based invalidation