Autumn ships first-class helpers for HTTP conditional GET — the mechanism that lets clients skip re-downloading content they already have cached. This is the missing companion to #[cached]: server-side caching short-circuits the render path; conditional GET short-circuits the network path.
Why it matters
An htmx dashboard polling an endpoint every five seconds sends a full request on every tick. Without conditional GET, the server renders and retransmits the full response even when nothing has changed. With fresh_when, unchanged polls return 304 Not Modified — zero body bytes, < 2 ms end-to-end.
Quick start
HTML handler (Maud)
use autumn_web::etag::fresh_when;
use autumn_web::prelude::*;
#[get("/posts/{id}")]
async fn show(
id: Path<i64>,
headers: http::HeaderMap,
mut db: Db,
csrf: Option<CsrfToken>,
) -> AutumnResult<impl IntoResponse> {
let post = Post::find(*id, &mut db).await?;
// One-liner: returns 304 if the client already has this version,
// or the full rendered response with ETag injected if stale.
Ok(fresh_when(&headers, post.etag()).or(html! {
h1 { (post.title) }
}))
}
JSON API handler
use autumn_web::etag::fresh_when;
use autumn_web::prelude::*;
#[get("/api/posts/{id}")]
async fn show_json(
id: Path<i64>,
headers: http::HeaderMap,
mut db: Db,
) -> AutumnResult<impl IntoResponse> {
let post = Post::find(*id, &mut db).await?;
Ok(fresh_when(&headers, post.etag()).or(Json(post)))
}
ETag derivation
fresh_when accepts anything that implements IntoETag:
| Input | ETag derivation |
|---|---|
String / &str | SHA-256 of the string bytes (strong) |
i64 / i32 | SHA-256 of the integer (strong) |
(NaiveDateTime, i64) | SHA-256 of timestamp + version (strong) |
ETag | identity |
Any Hash via hash_etag(v) | SipHash → hex (weak) |
All derivations are deterministic — same input always produces the same ETag on every replica, with no dependence on wall clock, process-local state, or RNG.
Integration with #[lock_version]
When a model carries #[lock_version], Autumn generates a Model::etag() method automatically:
#[model]
pub struct Post {
#[id]
pub id: i64,
pub title: String,
#[lock_version]
pub lock_version: i64,
pub updated_at: chrono::NaiveDateTime,
}
// Generated by #[model]:
// pub fn etag(&self) -> ETag { IntoETag::into_etag(self.lock_version) }
You can then combine the auto-derived ETag with updated_at for richer caching:
Ok(fresh_when(&headers, (post.updated_at, post.lock_version)).or(html! { ... }))
Or use the generated shorthand:
Ok(fresh_when(&headers, post.etag()).or(html! { ... }))
htmx polling pattern
<!-- Poll every 5 seconds; htmx sends If-None-Match automatically on repeat requests -->
<div id="live-feed"
hx-get="/posts/1"
hx-trigger="every 5s"
hx-swap="outerHTML">
<!-- content -->
</div>
With fresh_when protecting the handler:
- First poll: handler runs →
200 OK+ETag: "abc123"header. - Subsequent polls (unchanged resource):
304 Not Modified, empty body, < 2 ms. - After mutation (lock_version or updated_at changes):
200 OKwith new ETag.
htmx 1.x does not forward If-None-Match automatically — you need a small request modifier:
document.body.addEventListener("htmx:configRequest", function(evt) {
var etag = evt.detail.elt.dataset.etag;
if (etag) evt.detail.headers["If-None-Match"] = etag;
});
document.body.addEventListener("htmx:afterRequest", function(evt) {
var etag = evt.detail.xhr.getResponseHeader("ETag");
if (etag) evt.detail.elt.dataset.etag = etag;
});
htmx 2.x includes If-None-Match natively.
last_modified support
You can attach a Last-Modified timestamp to both the 304 and 200 responses:
use chrono::Utc;
let fw = fresh_when(&headers, post.etag())
.last_modified(post.updated_at.and_utc());
Ok(fw.or(html! { ... }))
The 304 response preserves Last-Modified alongside ETag. The 200 response also includes Last-Modified for clients that prefer date-based caching.
EtagLayer — automatic weak ETags
For handlers that don't opt in manually, EtagLayer middleware auto-derives a weak ETag from response body bytes:
use autumn_web::etag::EtagLayer;
use axum::Router;
let app: Router = Router::new()
// ... routes ...
.layer(EtagLayer::new());
Behaviour:
- Only applies to
GETrequests returning200 OK. - Buffers the response body (up to 4 MiB) to compute a SipHash-based weak ETag.
- If the request carried a matching
If-None-Match, returns304with empty body. - Otherwise injects the
ETagheader and returns the original response. - Responses that already carry an
ETagheader (handler-set) are checked againstIf-None-Matchbut not overridden. Set-Cookieis stripped from304responses to prevent stale auth tokens on intermediary caches.Cache-Control,Vary, andContent-Locationare preserved on304responses.
EtagLayer is off by default — it adds buffering overhead and changes behaviour for handlers that stream large bodies. Enable it explicitly on routes where body-derived ETags make sense.
304 response correctness
Autumn's 304 responses follow RFC 7232 §4.1:
- Body: empty (always).
- Headers preserved:
ETag,Last-Modified,Cache-Control,Vary,Content-Location. - Headers stripped:
Set-Cookie(default, to avoid stale auth state on intermediaries).
Cache-Control freshness — cache_for
fresh_when handles revalidation — deciding whether a cached copy is still valid. Cache-Control handles freshness — telling caches how long a copy may be reused before they bother to revalidate. cache_for(duration) builds the header declaratively:
use autumn_web::etag::cache_for;
use autumn_web::prelude::*;
use std::time::Duration;
#[get("/pricing")]
async fn pricing() -> impl IntoResponse {
// Tuple form: attaches "Cache-Control: public, max-age=300" to the body.
(cache_for(Duration::from_secs(300)).public(), html! { h1 { "Pricing" } })
}
It composes as a tuple with any body (via IntoResponseParts), or call .wrap(body) for a single expression. Either way the header is inserted (never appended), so a response carries exactly one Cache-Control value.
The builder covers the common directives:
| Method | Directive | Meaning |
|---|---|---|
public() | public | Any cache — browser and shared/CDN — may store it (explicit opt-in) |
private() | private | Browser cache only (the default) |
max_age(d) | max-age=N | Freshness lifetime for every cache, including the browser |
s_maxage(d) | s-maxage=N | Freshness lifetime for shared caches only; overrides max-age there |
stale_while_revalidate(d) | stale-while-revalidate=N | Serve stale for up to N s while revalidating in the background |
no_store() | no-store | Forbid storage entirely (overrides all freshness directives) |
no_cache() | no-cache | May store but must revalidate before reuse |
must_revalidate() | must-revalidate | Once stale, MUST revalidate; no serving stale on origin failure |
immutable() | immutable | Body won't change over its lifetime; skip revalidation on reload |
max-age vs s-maxage
max-age is the lifetime for every cache, the end-user's browser included. s-maxage overrides it only for shared caches (CDNs, reverse proxies). Use both to let a CDN hold a page longer than the browser:
// Browser keeps it fresh 1 min; the CDN serves it for 10.
cache_for(Duration::from_secs(60)).public().s_maxage(Duration::from_secs(600))
// → "public, max-age=60, s-maxage=600"
Vary and personalized responses
A public response may be handed by a shared cache to any client. Never mark a page that embeds per-user state (a signed-in username, a cart, CSRF-bound markup) public without also setting a matching Vary header (e.g. Vary: Cookie) so the shared cache keys separate variants per user. When in doubt keep it private or no_store — a shared cache must never serve one user's page to another.
Default-private safety
cache_for(..) defaults to private; public is an explicit opt-in. Dropping cache_for(..) onto a secured / authenticated handler can therefore never silently publish that page to a shared cache — you have to ask for public on purpose.
Composing with fresh_when
fresh_when and cache_for are complementary and compose in one expression. The freshness directives ride along on both the 200 and the 304:
Ok(fresh_when(&headers, post.etag())
.or(cache_for(Duration::from_secs(60)).public().wrap(html! { h1 { (post.title) } })))
- Stale →
200 OKwith theETagand a singleCache-Control: public, max-age=60. - Fresh →
304 Not Modifiedcarrying the same singleCache-Control(preserved per RFC 7232 §4.1).
Composing with #[cached]
fresh_when and #[cached] compose cleanly — they target different layers:
| Layer | Mechanism | What it skips |
|---|---|---|
| Network | fresh_when / EtagLayer | Body transmission |
| Server render | #[cached] | Handler execution + template render |
| Static files | ISR / #[static_get] | Dynamic handling entirely |
Use #[cached] for expensive renders where the result is shared across many clients. Use fresh_when to skip retransmitting per-resource state to clients that already have it.
Security notes
- ETags derived from
lock_versiondo not leak model internals — SHA-256 is a one-way function. EtagLayeruses SipHash (not SHA-256) for body-derived weak ETags; this is sufficient for cache coordination but not for security purposes.- The
If-Match/If-Unmodified-Sinceconditional writes are handled separately via#[lock_version]— see the optimistic locking guide.