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)

Rust
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

Rust
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:

InputETag derivation
String / &strSHA-256 of the string bytes (strong)
i64 / i32SHA-256 of the integer (strong)
(NaiveDateTime, i64)SHA-256 of timestamp + version (strong)
ETagidentity
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:

Rust
#[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:

Rust
Ok(fresh_when(&headers, (post.updated_at, post.lock_version)).or(html! { ... }))

Or use the generated shorthand:

Rust
Ok(fresh_when(&headers, post.etag()).or(html! { ... }))

htmx polling pattern

Html
<!-- 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 OK with new ETag.

htmx 1.x does not forward If-None-Match automatically — you need a small request modifier:

Javascript
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:

Rust
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:

Rust
use autumn_web::etag::EtagLayer;
use axum::Router;

let app: Router = Router::new()
    // ... routes ...
    .layer(EtagLayer::new());

Behaviour:

  • Only applies to GET requests returning 200 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, returns 304 with empty body.
  • Otherwise injects the ETag header and returns the original response.
  • Responses that already carry an ETag header (handler-set) are checked against If-None-Match but not overridden.
  • Set-Cookie is stripped from 304 responses to prevent stale auth tokens on intermediary caches.
  • Cache-Control, Vary, and Content-Location are preserved on 304 responses.

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:

LayerMechanismWhat it skips
Networkfresh_when / EtagLayerBody transmission
Server render#[cached]Handler execution + template render
Static filesISR / #[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_version do not leak model internals — SHA-256 is a one-way function.
  • EtagLayer uses 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-Since conditional writes are handled separately via #[lock_version] — see the optimistic locking guide.