Vary-aware caching
Some includes have the same src but different content depending on who's asking. The pricing table shows different plans to admins vs. free users. The nav shows "Dashboard" or "Sign in" based on authentication. The hero is in French for French users.
The vary attribute declares which request dimensions this include varies on. bext computes the cache key from the source path plus the declared dimension values, producing a separate cached fragment per (dim × dim × ...) combination.
<bext-include src="/partials/pricing-table"
vary="role,locale,geo"
ttl="10m"
></bext-include>
Same HTML tag. Separate cache entries for admin/en/US vs viewer/fr/FR vs admin/fr/DE.
Supported dimensions#
Values come from the request context, extracted by bext middleware. You can reference these in any include's vary:
| Dimension | Source | Example values |
|---|---|---|
role |
Auth session | admin, editor, viewer, anonymous |
auth |
Auth session (coarse) | authenticated, anonymous |
locale |
Accept-Language header or cookie |
en, fr, de, ja |
geo |
GeoIP / CDN headers | US, FR, DE, JP |
device |
User-Agent classification |
mobile, tablet, desktop |
theme |
Cookie or header | light, dark, auto |
currency |
Session or geo-derived | USD, EUR, GBP |
Plus any custom dimensions defined in bext.config.toml:
[include.vary.custom]
"preferred-unit" = { source = "cookie", name = "unit" }
"api-version" = { source = "header", name = "x-api-version" }
Cache key derivation#
The key is a stable 64-bit FNV-1a hash of:
hash(src || "\0" || sorted_vary_pairs)
Only dimensions declared in the include's vary list contribute to the key. A request with role=admin, locale=fr, geo=US hitting an include with vary="role,locale" produces the same cache key as a request with the same role and locale but a different geo.
// Request A: role=admin, locale=fr, geo=US
// Request B: role=admin, locale=fr, geo=FR
// Include: vary="role,locale"
// → same cache key (geo ignored for this include)
// Request C: role=viewer, locale=fr, geo=US
// Include: vary="role,locale"
// → different cache key (role changed)
This is how one element can produce N cache entries — one per unique combination of the values the include actually cares about.
Cardinality control#
A vary dimension with too many distinct values fills the cache with one-hit entries. If you vary on locale with 50 locales, you have 50 cache entries. Vary on locale and role with 4 roles, you have 200 entries. Add geo with 100 countries and you have 20,000.
bext tracks cardinality and warns when a single include produces too many variants:
WARN include vary cardinality: src="/partials/pricing" dim="locale"
has 47 unique values — consider grouping (en, fr, de, zh, *)
Cap the variants per include in config:
[include.cache]
max_variants_per_include = 100
When exceeded, the coldest variant is LRU-evicted. This bounds memory use regardless of traffic shape.
Cardinality compounds fast: vary="locale,role,geo" with 50 locales × 4 roles × 100 countries = 20,000 possible cache entries. Set max_variants_per_include in [include.cache] and prefer coarser groupings (e.g. en/fr/de/zh/*) for high-cardinality dimensions.
Auth-gated includes must vary#
If an include has auth="required" or role="...", it must include the relevant dimension in vary when cached — otherwise the cache would serve the admin toolbar to anonymous users (cache poisoning).
<!-- WRONG: admin content will leak to non-admins -->
<bext-include src="/partials/admin"
auth="required" role="admin" ttl="30m"
></bext-include>
<!-- RIGHT: cached per role -->
<bext-include src="/partials/admin"
auth="required" role="admin"
ttl="30m" vary="role"
></bext-include>
bext logs a warning when auth-gated includes are cached without a role vary. In strict mode (configurable), the cache is disabled for such includes rather than serving potentially wrong content.
Only dimensions listed in [include.vary.dimensions] (or [include.vary.custom]) are recognized. Referencing an undeclared dimension in vary="..." is silently ignored — it won't contribute to the cache key.
SSI and ESI#
SSI and ESI don't have a vary attribute on the tag. Declare vary in config rules instead:
[[include.rules]]
pattern = "/partials/pricing*"
vary = ["role", "locale"]
ttl = "10m"
Any matching include — bext-native, SSI, or ESI — gets this vary config.
<!-- all three get vary="role,locale" from the rule above -->
<bext-include src="/partials/pricing" ttl="10m"></bext-include>
<!--#include virtual="/partials/pricing" -->
<esi:include src="/partials/pricing" />
Using vary in resolvers#
Custom resolvers receive the vary values in the request context:
impl IncludeResolver for MyResolver {
fn resolve(&self, d: &IncludeDirective, ctx: &IncludeContext) -> Option<String> {
let role = ctx.vary_value("role").unwrap_or("anonymous");
let locale = ctx.vary_value("locale").unwrap_or("en");
Some(render_pricing_for(role, locale))
}
}
The cache layer keys on these values automatically based on the include's vary attribute. The resolver itself just reads them.
Custom dimensions#
Add request-specific vary dimensions in config:
# Example: vary on a feature-flag cookie
[include.vary.custom]
"beta-features" = { source = "cookie", name = "bext_beta" }
# Example: vary on an API version header
"api-version" = { source = "header", name = "x-api-version" }
Use them like any built-in dimension:
<bext-include src="/partials/api-docs"
vary="api-version"
ttl="1h"
></bext-include>
Now each API version gets its own cached copy of the docs.
See also#
- Caching includes — TTL, SWR, tags
- Auth-gated includes — why vary on
rolematters - Configuration — custom dimensions and rules
- Advanced caching — bext's tiered cache architecture