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).
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.