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