#4772·leptos

leptos_axum: concurrent regeneration of a `SsrMode::Static` route can serve one render's body with another render's headers

Author: AlexeyMatskevichCreated Jun 10, 2026Updated Jun 30, 2026
Labelsbug

Describe the bug

When several requests hit a not-yet-generated SsrMode::Static route concurrently, leptos_axum can serve a response whose HTML body comes from one render but whose ResponseOptions headers/status come from a different render.

Every concurrent miss runs its own SSR render and then:

  1. writes its HTML to disk and inserts its captured ResponseOptions into the process-global STATIC_HEADERS map (integrations/axum/src/lib.rs#L1707 on leptos_0.9),
  2. applies the request-local ResponseOptions captured by its own render (#L1793),
  3. but serves the body by re-opening the file from disk via ServeFile (#L1822).

Nothing pairs steps 2 and 3: between request A's disk write and its ServeFile open, a concurrent request B can overwrite the file. A then serves body B under headers A. The cache-hit path has the same shape (headers read from STATIC_HEADERS at #L1796, body via a separate ServeFile open, no common lock).

This matters whenever a static route's headers are derived from the rendered content — e.g. a Link/preload header naming a hashed asset, a CSP nonce, a custom status — the served body then contradicts its own headers. The hardening in #4739 (merged into leptos_0.9) does not address this: it bounds the cache with an LRU but the regeneration branch still pairs the request-local snapshot with a separately re-opened file.

leptos_actix has the same code shape (NamedFile::open + request-local ResponseOptions on the regeneration path), so it is most likely affected as well; I only built a runnable reproduction for axum.

Leptos Dependencies

toml
leptos = { version = "0.8.19", features = ["ssr"] }
leptos_axum = "0.8"            # resolves to 0.8.9
leptos_meta = { version = "0.8", features = ["ssr"] }
leptos_router = { version = "0.8", features = ["ssr"] }
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt", "fs"] }
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
futures = "0.3"

To Reproduce

A SsrMode::Static route stamps a fresh epoch number into both the body and an x-render-epoch header on every render. If body and headers always came from one render, the two values would match in every response. The test fires 16 concurrent first requests at the route without pre-generating it, so they all take the on-demand regeneration branch:

rust
#[cfg(test)]
mod tests {
    use std::sync::atomic::{AtomicU64, Ordering};

    use axum::body::Body;
    use axum::http::Request;
    use axum::Router;
    use http_body_util::BodyExt;
    use leptos::config::LeptosOptions;
    use leptos::prelude::*;
    use leptos_axum::{generate_route_list_with_ssg, LeptosRoutes};
    use leptos_meta::{provide_meta_context, MetaTags};
    use leptos_router::{
        components::{Route, Router as LeptosRouter, Routes},
        path,
        static_routes::StaticRoute,
        SsrMode,
    };
    use tower::ServiceExt;

    #[component]
    fn EpochApp() -> impl IntoView {
        provide_meta_context();
        view! {
            <LeptosRouter>
                <main>
                    <Routes fallback=|| view! { <h1>"Not Found"</h1> }>
                        <Route
                            path=path!("/epoch")
                            ssr=SsrMode::Static(StaticRoute::new())
                            view=|| {
                                static EPOCH: AtomicU64 = AtomicU64::new(0);
                                let epoch = EPOCH.fetch_add(1, Ordering::Relaxed);
                                if let Some(res) =
                                    use_context::<leptos_axum::ResponseOptions>()
                                {
                                    res.insert_header(
                                        axum::http::header::HeaderName::from_static(
                                            "x-render-epoch",
                                        ),
                                        axum::http::header::HeaderValue::from_str(
                                            &epoch.to_string(),
                                        )
                                        .unwrap(),
                                    );
                                }
                                let marker = format!("epoch-{epoch}-marker");
                                view! { <h1>{marker}</h1> }
                            }
                        />
                    </Routes>
                </main>
            </LeptosRouter>
        }
    }

    fn shell(options: LeptosOptions) -> impl IntoView {
        view! {
            <!DOCTYPE html>
            <html lang="en">
                <head>
                    <meta charset="utf-8"/>
                    <MetaTags/>
                </head>
                <body>
                    <EpochApp/>
                </body>
            </html>
        }
    }

    #[tokio::test]
    async fn concurrent_static_regeneration_pairs_body_with_headers() {
        let site_root = std::env::temp_dir().join(format!(
            "leptos_axum_static_race_{}",
            std::process::id()
        ));
        std::fs::create_dir_all(&site_root).unwrap();

        let options = LeptosOptions::builder()
            .output_name("static-race-repro")
            .site_root(site_root.to_string_lossy().to_string())
            .site_pkg_dir("pkg")
            .build();

        // Deliberately do NOT run the StaticRouteGenerator: the `.html` must
        // be missing so the first requests race down the on-demand
        // regeneration branch concurrently.
        let (routes, _generator) = generate_route_list_with_ssg(EpochApp);

        let app: Router = Router::new()
            .leptos_routes(&options, routes, {
                let options = options.clone();
                move || shell(options.clone())
            })
            .with_state(options);

        let responses = futures::future::join_all((0..16).map(|_| {
            app.clone().oneshot(
                Request::builder()
                    .uri("/epoch")
                    .body(Body::empty())
                    .unwrap(),
            )
        }))
        .await;

        let mut mismatches = Vec::new();
        for (i, resp) in responses.into_iter().enumerate() {
            let resp = resp.unwrap();
            let status = resp.status();
            let header_epoch = resp
                .headers()
                .get("x-render-epoch")
                .and_then(|v| v.to_str().ok())
                .map(str::to_string);
            let body = resp.into_body().collect().await.unwrap().to_bytes();
            let html = String::from_utf8_lossy(&body).into_owned();
            let body_epoch = html
                .split("epoch-")
                .nth(1)
                .and_then(|tail| tail.split("-marker").next())
                .map(str::to_string);
            if header_epoch != body_epoch {
                mismatches.push(format!(
                    "response {i}: status={status} header_epoch={header_epoch:?} \
                     body_epoch={body_epoch:?}"
                ));
            }
        }

        let _ = std::fs::remove_dir_all(&site_root);

        assert!(
            mismatches.is_empty(),
            "body and x-render-epoch header must come from one render; \
             mismatched responses:\n{}",
            mismatches.join("\n")
        );
    }
}

Run cargo test. Observed output (reproduced 3/3 runs on macOS, single-threaded #[tokio::test] runtime, leptos_axum 0.8.9):

response 4:  status=200 OK header_epoch=Some("4")  body_epoch=Some("6")
response 5:  status=200 OK header_epoch=Some("5")  body_epoch=Some("6")
response 7:  status=200 OK header_epoch=Some("7")  body_epoch=Some("6")
...
response 15: status=200 OK header_epoch=Some("15") body_epoch=Some("6")

12 of 16 responses served the last writer's body (epoch 6) under their own render's headers.

Screenshots

n/a — see test output above.

Next Steps

  • I will make a PR
  • I would like to make a PR, but need help getting started
  • I want someone else to take the time to fix this
  • This is a low priority for me and is just shared for your information

Additional context

Found while maintaining an ntex port of this integration (leptos_ntex), which inherited the same code shape; the equivalent test there failed 17/20 runs before the fix and 0/10 after.

The fix that worked for us, in case it is useful here: have the writer hold a per-file lock (a small striped mutex set, to keep it bounded) across both the file write and the STATIC_HEADERS insert, and have the serving side open the file and read the cached headers under that same lock — on the cache-hit path and on the post-regeneration re-open path (instead of applying the request-local ResponseOptions there). That guarantees body and headers always come from one render epoch; the last writer simply wins wholesale. Happy to share more details if helpful.