On this page
Cache policy
TTL selection
bouine picks freshness in this order:
CDN-Cache-Control(RFC 9211 targeted header — overridesCache-Controlfor shared caches)s-maxagemax-ageExpires(valid dates only; syntactically invalid Expires values are ignored)- Route
ttl_default(operator fallback when origin sends no freshness) - Heuristic freshness from
Last-Modified(10% ofDate − Last-Modified, where allowed by status code)
If the route has ttl_override set, its value replaces the result of
this waterfall entirely. See
TTL override below.
TTL override
ttl_override forces bouine’s internal cache lifetime to a specific value,
regardless of what max-age, s-maxage, CDN-Cache-Control, or Expires
the origin sends. The upstream’s response headers are forwarded to downstream
clients unaltered — only bouine’s internal freshness counter changes.
routes:
- name: api
match: { path_prefix: /api/ }
pool: backend
cache:
ttl_override: 1h # bouine caches for 1 h
ttl_default: 30s # fallback if origin sends no freshness headers
stale_while_revalidate: 5mWhy it exists: bouine in front of a downstream CDN
The canonical use case is a bouine → Cloudflare (or bouine → any CDN) stack where you want to control the two caches independently:
Service → bouine → Cloudflare → ClientThe service emits Cache-Control: max-age=60. Without ttl_override:
- bouine caches for 60 s, revalidates after each minute.
- Cloudflare sees
Cache-Control: max-age=60and also caches for 60 s.
With ttl_override: 1h:
- bouine caches for 1 h. The service is hit once per hour per bouine node instead of once per minute.
- Cloudflare still receives
Cache-Control: max-age=60unaltered and caches for 60 s at the edge. - Clients get low edge latency (Cloudflare’s 60 s cache) while the origin is shielded by bouine’s much longer storage window.
TTL override does not mutate the forwarded headers.
Cache-Control: max-age=60is what Cloudflare (and browsers) see. Only bouine’s internal storage lifetime changes.
What the override does NOT affect
| Upstream directive | Behaviour with ttl_override set |
|---|---|
no-store | Response is not cached at all. Override has no effect. |
private | Response is not cached at all. Override has no effect. |
no-cache | Response is stored but revalidated on every request. Override is bypassed. |
must-revalidate | Honoured after the override TTL expires. |
proxy-revalidate | Honoured after the override TTL expires. |
jitter_percent | Applied to the override value (not the origin’s max-age). |
no-store and private are evaluated before the object is stored;
the override never runs for these responses.
Revalidation behaviour
When bouine’s override TTL expires it performs a conditional revalidation
(If-None-Match / If-Modified-Since). If the origin returns 304 Not Modified,
the override TTL is re-applied to the refreshed object — the object does
not revert to the origin’s max-age. A full 200 is stored fresh with the
override TTL.
Combining with other cache fields
ttl_override composes cleanly with the rest of the cache policy:
cache:
ttl_override: 2h # bouine keeps for 2 h
stale_while_revalidate: 10m # serve stale during background refresh
stale_if_error: 24h # serve if origin is down for up to 24 h
jitter_percent: 5 # ±5 % jitter on the 2 h override
negative_ttl: 10s # cache 404s for 10 s (unaffected)ttl_default is not superseded when ttl_override is set — it remains the
fallback for responses the origin sends without any freshness headers.
Set both if your origin is inconsistent about emitting Cache-Control.
Stale serving
cache:
ttl_default: 60s
stale_while_revalidate: 10s
stale_if_error: 300sstale_while_revalidate: serve stale immediately and trigger a background revalidation. The next request gets a freshHITwithout any blocking. Concurrency is bounded to 256 simultaneous background revalidations.stale_if_error: serve stale when the origin returns 5xx or is unreachable, up to the configured window. Unlike SWR, bouine always attempts the origin first and only falls back to stale on error.
Stayin Alive
When stale_if_error expires and the upstream is still down, bouine normally starts returning errors to clients. Stayin Alive prevents this — the cached object is served indefinitely until the upstream recovers, regardless of how long ago it expired.
routes:
- match: { path_prefix: /products/ }
pool: backend
cache:
ttl_default: 60s
stale_if_error: 5m
stayin_alive: true # serve stale forever if upstream is downWhen stayin_alive: true:
- If the upstream returns 5xx, bouine serves the last known good response.
- If the upstream is unreachable (connection error, timeout), bouine serves the last known good response.
- If the upstream returns 2xx, the fresh response replaces the stale one as normal.
- If there is no cached entry at all, bouine cannot help — it returns 502.
A WARN log is emitted on every stale-served request while the upstream is unhealthy:
{"level":"WARN","msg":"stayin-alive: upstream 5xx, serving stale indefinitely","status":503,"key":12345}When to use it
Use Stayin Alive for routes where showing slightly stale content is far better than showing an error — product pages, homepages, navigation menus, search results. Do not use it for checkout, cart, account, or any route that must reflect real-time state.
Negative caching
cache:
negative_ttl: 5sCaches 404, 405, 410, and 501 responses briefly. Use this to protect origins from repeated misses.
Set-Cookie caching
By default, a response carrying a Set-Cookie header is never cached — even
if it has explicit freshness (max-age). This matches nginx’s proxy_cache
behaviour and prevents one user’s session cookie from being replayed to other
users (a session-fixation vector).
cache:
allow_set_cookie: false # default — Set-Cookie blocks cachingTo cache such responses anyway, opt in explicitly. When enabled, bouine still
delivers the cookie to the first client (the MISS), but strips
Set-Cookie from the stored copy so subsequent HITs never replay it:
cache:
allow_set_cookie: true # cache, but strip Set-Cookie from stored object| Upstream sends | allow_set_cookie: false (default) | allow_set_cookie: true |
|---|---|---|
Set-Cookie + max-age=60 | Not cached (proxied through) | Cached without Set-Cookie |
Set-Cookie + no-store | Not cached | Not cached |
No Set-Cookie | Cached normally | Cached normally |
Only enable
allow_set_cookieon routes where theSet-Cookieis not user-specific (e.g. a non-personalised A/B cookie). Never enable it on authentication or session routes.
Object size limit
Skip caching responses whose body exceeds a size limit. The response is still proxied to the client — only storage is skipped, so large downloads don’t evict useful entries:
cache:
max_object_size: 1MiB # don't cache bodies larger than 1 MiB0 (default) means no limit.
Write-method invalidation (POST/PUT/DELETE)
Per RFC 9111 section 4.4, bouine invalidates the cache entry for the
request URI when a non-safe method (POST, PUT, DELETE, PATCH) receives a
2xx or 3xx response from the origin. The invalidation targets the
GET-equivalent key, evicts all Vary variants, and removes the object from
the refresh registry. If the response carries a Content-Location or
Location header, those URLs are also invalidated (section 4.4).
Additionally, successful POST responses with explicit freshness
(Cache-Control: max-age or s-maxage) and a matching Content-Location
are stored under the GET key per RFC 9111 section 4.3.1. This is the only
case where a non-GET response is cached.
Refresh before expiry
Refresh-before-expiry fires a background conditional revalidation before an object’s TTL expires, keeping the cache perpetually warm. Clients always see cache hits; origin traffic drops to lightweight 304 responses with no body transfer.
This is distinct from stale_while_revalidate (SWR): SWR fires
reactively when a client request arrives after expiry and serves stale
while revalidating. Refresh-before-expiry fires proactively before
expiry — the object never enters the stale window, and no client request
is needed to trigger the refresh.
cache:
ttl_override: 30s
refresh_before_expiry: true
refresh_margin_percent: 20 # fire at 80% of TTL (24s into 30s)
refresh_timeout: 5s # max duration for a single refresh fetch
refresh_concurrency: 16 # max concurrent background refreshes
refresh_min_hits: 3 # only re-schedule objects hit >= 3 times
refresh_persist_cycles: 2 # keep refreshing 2 cycles after popularity drops
refresh_min_score: 1048576 # 1 MiB-hit score gate (staleHits × bodySize)
refresh_max_rps: 100 # cap at 100 refresh fetches/s per route
refresh_reactive_first: true # SWR-first, promote popular objects to proactiveHow it works
- When a cacheable object is stored, bouine schedules a background
refresh at
StoredAt + TTL - margin(unlessrefresh_reactive_firstis enabled — see Reactive-first mode below). - At the scheduled time, a single drainer goroutine fires a conditional
request (
If-None-Match/If-Modified-Since) to the origin. - On
304 Not Modified, the object’s TTL is refreshed in place — the object never expires. - On
200 OK, the object is replaced with the fresh response. - On error, the refresh is rescheduled with backoff (the object remains fresh until its original TTL expires, at which point SWR/miss path takes over).
After each refresh completes, bouine evaluates the object’s popularity using the popularity gates below. Objects that fall below the configured thresholds are not re-scheduled and expire naturally, reducing origin traffic for long-tail content.
Configuration fields
| Field | Default | Description |
|---|---|---|
refresh_before_expiry | false | Enable proactive background refresh |
refresh_margin_percent | 10 | Percentage of TTL before expiry to fire (1–50). E.g. 20 fires at 80% of TTL. |
refresh_timeout | 10s | Maximum duration for a single background refresh fetch (5s–120s) |
refresh_concurrency | 8 | Maximum concurrent background refresh fetches per route (1–64) |
refresh_min_hits | 0 | Minimum cache hits during a TTL window for an object to qualify for re-scheduling after a refresh. 0 disables the gate (every object is refreshed). >0 means only objects hit at least N times are re-scheduled; unpopular long-tail objects expire naturally. |
refresh_persist_cycles | 0 | Number of additional TTL cycles to keep refreshing after the popularity gate (refresh_min_hits) would block. Each refresh with hits below the threshold decrements the counter; a popular refresh resets it. 0 disables persistence — the gate kills re-scheduling immediately. Requires refresh_min_hits > 0. |
refresh_min_score | 0 | Minimum refresh priority score (staleHits × object body size in bytes) for re-scheduling. Weights the decision by object size: a 4 MB object with 1 hit outranks a 512 B object with 100 hits. 0 disables the score gate. Requires refresh_before_expiry and refresh_min_hits > 0. |
refresh_max_rps | 0 | Caps background refresh fetches per second per route. When the cap is reached, pending refreshes are deferred with jittered backoff rather than dropped. 0 means no limit. Range 0 or 1–10000. |
refresh_reactive_first | false | Changes the initial refresh strategy from proactive to reactive. New objects are not scheduled for proactive refresh; instead they rely on SWR — if accessed while stale, a background revalidation refreshes the object, and the popularity gate decides whether to promote it to proactive refresh for subsequent windows. Requires stale_while_revalidate > 0 and refresh_min_hits > 0. |
Popularity gates
When refresh_min_hits or refresh_min_score is set, bouine evaluates
popularity after each background refresh completes (not on the
initial store). The object’s per-window hit count (staleHits) and body
size are checked against the configured thresholds:
refresh_min_hits: the hit count during the previous TTL window must be >= N. This filters out long-tail objects that were cached once but rarely accessed again.refresh_min_score: the score (staleHits × obj.BodySize) must be >= N. This adds a size-weighted dimension — a large object with few hits may be more valuable to keep warm than a small object with many hits.
When both gates are set, both must pass for the object to be re-scheduled. If either fails, the object is not re-scheduled and will expire naturally at the end of its current TTL.
The first TTL window always gets one refresh cycle — the popularity gate only applies on re-scheduling after a refresh completes.
Persist cycles
When refresh_persist_cycles > 0 and the popularity gate would block
re-scheduling, the object gets a grace period: it continues to be
refreshed for N additional TTL cycles. Each cycle where hits remain
below refresh_min_hits decrements the persist counter. If a popular
refresh occurs (hits >= refresh_min_hits), the counter is reset to the
configured value.
This prevents objects from falling off the refresh schedule due to a
temporary traffic dip. Set refresh_persist_cycles to 2–3 for routes
with bursty traffic patterns where popularity fluctuates.
Rate limiting
refresh_max_rps caps the number of background refresh fetches per
second per route. When the cap is reached, the refresh is not dropped —
it is re-scheduled with a jittered backoff (100–500 ms). This prevents
refresh bursts from overwhelming the origin, which is especially useful
when:
- Many objects share the same TTL and would refresh simultaneously
- The origin has rate limits or costs per request
- You want to bound the maximum origin load from refresh traffic
Reactive-first mode
When refresh_reactive_first: true, new objects are not scheduled
for proactive background refresh. Instead, they rely on
stale_while_revalidate:
- The object is cached normally and served fresh until its TTL expires.
- If a client requests it while stale, SWR serves the stale copy and triggers a background revalidation.
- After the revalidation completes, the popularity gate evaluates whether to promote the object to proactive refresh for subsequent TTL windows.
This mode is ideal for routes with high key cardinality where most
objects are accessed only once or twice — proactive refresh would waste
origin requests on objects that will never be accessed again. Only
objects that prove their popularity (by being accessed while stale and
passing the refresh_min_hits gate) graduate to proactive refresh.
Sizing guidance
- Memory cost: ~232–482 B per scheduled object (heap entry + Vary-only request header registry). At 1M objects, this is ~465 MB.
- Goroutines: one drainer + up to
refresh_concurrencyrefresh goroutines per route with refresh enabled. - Origin traffic: refresh fetches are conditional (304-capable), so
body transfer is typically zero. The origin only needs to support
ETagorLast-Modifiedfor conditional requests. - Rate-limited traffic: when
refresh_max_rpsis set, the maximum origin load from refreshes is bounded at N requests/s per route. - Minimum TTL: objects with TTL < 5s are not scheduled — the refresh window is too tight for a network round-trip.
- Negative-cached objects (404/405/410/501) are never refreshed.
Interaction with other features
- SWR: refresh-before-expiry and SWR are complementary. If a refresh fails and the object goes stale, SWR serves stale content to clients while a revalidation is attempted. In reactive-first mode, SWR is the primary trigger for the initial refresh cycle.
- Cluster strong mode: only the key owner schedules background refresh. Non-owner nodes that cache a peer-fetched object do not schedule redundant refreshes.
- Invalidation:
purge,ban, and invalidating methods (POST/PUT/ DELETE) remove the object from the refresh registry. No stale refreshes fire for invalidated keys. - Shutdown: refresh-enabled handlers are drained before the store closes during shutdown, preventing use-after-close panics.
When to use it
Use refresh-before-expiry for high-traffic routes where periodic origin fetches at TTL expiry create noticeable latency spikes. It is most effective for routes with:
- Short to medium TTLs (5s–5m)
- High request rates (so every expiry event affects many clients)
- Origins that support conditional requests (ETag / Last-Modified)
- Large key cardinality (1M+ keys benefit most from zero-miss caching)
Do not use it for routes with very long TTLs (1h+) and low traffic — the background refresh goroutines add overhead with little benefit over letting SWR handle the occasional revalidation.
For routes with high key cardinality and a long tail of rarely-accessed
objects, combine refresh_min_hits with refresh_reactive_first to
ensure only popular objects consume refresh bandwidth.
Jittered TTLs
cache:
jitter_percent: 10Applies random ±10% TTL jitter to prevent all objects expiring at the same time.
Cache key headers
cache:
key:
include_headers:
- Accept-Language
- BM-MarketUse this when the origin varies by request header. Keep the list short — every extra header increases variant cardinality.
Avoid unbounded variants
Never key on raw
User-Agent, unbounded cookies, or high-cardinality request IDs. This creates cache fragmentation and can be a cache-poisoning vector.
Stripping query parameters from the key
Tracking and analytics parameters (utm_source, fbclid, gclid, _ga, …)
make otherwise-identical URLs into distinct cache entries, fragmenting the
cache. Strip them from the key while still forwarding them to the origin:
cache:
key:
strip_query_params: [utm_source, utm_medium, utm_campaign, fbclid, gclid, _ga]With this config, /page?id=1&utm_source=email and /page?id=1&utm_source=twitter
resolve to the same cache entry. The origin still receives the full query
string (including the tracking params) on a MISS.
Purge note:
POST /v1/purgecomputes the key from the URL you send, without route context. When usingstrip_query_params, send purge URLs without the stripped parameters so they match the stored key (same behaviour as Varnish).
Excluding headers from the cache key
Origins sometimes include per-request headers in Vary that should not
fragment the cache — tracing IDs (X-Request-Id, X-Trace-Id),
forwarding headers (X-Forwarded-For), or A/B testing cookies. Each
unique value creates a separate cache entry, wasting storage and reducing
hit rate.
exclude_headers strips the listed request header names from the
Vary-based variant key. The origin’s Vary response header is left
intact and forwarded to the client — only the key computation skips
the excluded headers:
cache:
key:
exclude_headers:
- x-request-id
- x-trace-id
- x-forwarded-forWith this config, two requests that differ only in X-Request-Id share
the same cache entry and produce a HIT:
Request A X-Request-Id: abc → MISS (stored)
Request B X-Request-Id: xyz → HIT (same entry)Collapse to primary key
When exclusion removes all fields from the Vary list, the variant key collapses to the primary key. This means every request with the same scheme, host, path, query, and method shares a single cache entry — regardless of the excluded header’s value.
For example, if the origin sends Vary: X-Request-Id and x-request-id
is in exclude_headers, the variant key equals the primary key. All
requests to the same URL get a HIT after the first fetch.
Case insensitivity
Header names in exclude_headers are matched case-insensitively. Both
x-request-id and X-Request-ID in the config will match X-Request-Id
in the origin’s Vary header.
When NOT to use exclude_headers
Do not exclude headers that genuinely affect the response body — such as
Accept-Encoding, Accept-Language, or Accept. Excluding a
content-negotiation header causes bouine to serve the wrong variant to
clients (cache poisoning). Only exclude headers you are certain do not
change the response content.
Advanced URL normalization
Beyond strip_query_params, bouine supports several additional cache key
normalisation features to maximise hit ratio when origins or clients
produce URL variants that should resolve to the same cached object.
Keep only specific query parameters
keep_query_params is the inverse of strip_query_params — only the
listed parameter names are included in the cache key; all others are
excluded. This is useful when the origin has a small set of meaningful
parameters and everything else is noise:
cache:
key:
keep_query_params: [id, page, sort]Mutually exclusive with strip_query_params and strip_query_prefix.
Equivalent to Varnish qs.keep().
Strip by prefix
strip_query_prefix removes all query parameters whose names start with
any of the listed prefixes. This covers wildcard stripping without
enumerating every variant:
cache:
key:
strip_query_prefix: [utm_, fb_, _ga]Capped at 16 entries in validation.
Remove empty-value parameters
strip_empty_params removes query parameters with empty values
(?foo=&bar=1 → ?bar=1). Does not apply to params in keep_query_params
— allowlisted params are always kept, even with empty values.
cache:
key:
strip_empty_params: trueDeduplicate repeated parameters
dedup_query_params keeps only the first value for duplicate query
parameters (?a=2&a=1 → ?a=2). “First” is first in request order,
matching Varnish qs.unique(). Values are not sorted when dedup is
enabled.
cache:
key:
dedup_query_params: trueCanonicalise path
canonicalize_path normalises the path component at parse time:
percent-decodes unreserved characters, uppercases remaining percent-encoded
hex, and resolves dot-segments (/a/../b → /b). This prevents
path-variant cache fragmentation from encoding inconsistencies.
cache:
key:
canonicalize_path: trueApplies at the listener level: if any route on a listener enables this, all requests on that listener get canonical paths.