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 | ~140s |
| 1000 | ~230s |
| 10000 | ~330s |
| ∞ | 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.
When not to cache
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 realtime instead.
- 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