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.
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.
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 {
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 role matters
- Configuration — custom dimensions and rules
- Advanced caching — bext's tiered cache architecture