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 cachingvary="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