Leptos 0.8.22SSR on Axum, a TypeScript client, no WASM

How this storefront is built

Leptos renders every page on the server. A small TypeScript client changes state in the browser and asks the server only for data it doesn't have. Each card below says what a piece does and why, runs it live, links to where the shop uses it, and quotes the code.

  • 16cards, each with a live demo
  • 0bytes of WebAssembly
  • 0.9 KBof JavaScript, raw, minified
  • Nileshop, picked from the Host header
One request, end to end
  1. 1BrowserAsks for /showcase with its Host and cookies.GET /showcase
  2. 2AxumPicks the shop from the Host, serves /js, /media and the /__prefs and /__sim helpers, and compresses.tower-http · moka
  3. 3Leptos SSRRenders the route in order into one complete document; status and headers go first.SsrMode::InOrder
  4. 4Page data#app-config and #app-strings: the shop, the language, the endpoints and every UI string the client needs.application/json
  5. 5TypeScriptOne module boots: read the page, sync the stores, mount the features, start the router./js/app-….js

Leptos on the server

Every page is a Leptos component rendered on the server into one complete HTML document. No Rust runs in the browser.

01

In-order SSR on every page

Leptos on the server

Every route is rendered with SsrMode::InOrder. Leptos renders the page top to bottom and streams it in that order, waiting at each <Suspense> for its data, so every byte that leaves the server is final HTML. No script has to move anything into place.

Why it matters herePages must work without JavaScript, and the TypeScript router swaps whole server-rendered pages: both need complete HTML. The status code and cache headers are set before the first byte, so a 404 is a real 404.

Live demo

RouteRendering
/InOrder
/c/:slugInOrder
/searchInOrder
/p/:slugInOrder
/cartInOrder
/cart/editInOrder
/checkoutInOrder
/ordersInOrder
/orders/:idInOrder
/showcaseInOrder
/uiInOrder
/core-scratchInOrder
/showcase/streamOutOfOrder

Read from crates/app/src/lib.rs when the server was built.

This page's first byte
—
Last byte
—
HTML, as sent and unpacked
—
Script needed to see the page
None

Timings are the browser's own (Navigation Timing) for the document this tab loaded first.

Source
  • crates/app/src/lib.rs
  • crates/app/src/components/shell.rs
crates/app/src/lib.rs:92Show code · 20
<Routes fallback=|| view! { <pages::NotFound/> }>
    // Every page renders in order: one complete document that works without JS
    // and that the client router can swap in (out-of-order chunks need scripts a
    // fetched document never runs). The out-of-order demo has its own route.
    <Route path=path!("") view=pages::HomePage ssr=SsrMode::InOrder/>
    <Route path=path!("c/:slug") view=pages::CategoryPage ssr=SsrMode::InOrder/>
    <Route path=path!("search") view=pages::SearchPage ssr=SsrMode::InOrder/>
    <Route path=path!("p/:slug") view=pages::ProductPage ssr=SsrMode::InOrder/>
    <Route path=path!("cart") view=pages::CartPage ssr=SsrMode::InOrder/>
    <Route path=path!("cart/edit") view=pages::cart::CartEditPage ssr=SsrMode::InOrder/>
    <Route path=path!("checkout") view=pages::CheckoutPage ssr=SsrMode::InOrder/>
    <Route path=path!("orders") view=pages::OrdersLookupPage ssr=SsrMode::InOrder/>
    <Route path=path!("orders/:id") view=pages::OrderPage ssr=SsrMode::InOrder/>
    <Route path=path!("showcase") view=pages::ShowcasePage ssr=SsrMode::InOrder/>
    <Route path=path!("ui") view=pages::UiKitPage ssr=SsrMode::InOrder/>
    // The core agent's scratch route (dev checks of core behaviour and components).
    <Route path=path!("core-scratch") view=pages::core_scratch::CoreScratchPage ssr=SsrMode::InOrder/>
    // Showcase agent (agreed with the lead): the one out-of-order route, the demo.
    <Route path=path!("showcase/stream") view=pages::showcase::StreamDemoPage ssr=SsrMode::OutOfOrder/>
</Routes>
crates/app/src/components/shell.rs:178Show code · 6
let config = serde_json::to_string(&config)
    .unwrap_or_default()
    .replace('<', "\\u003c");
view! {
    <script type="application/json" id="app-config" inner_html=config></script>
    <script type="application/json" id="app-strings" inner_html=crate::strings::client_json(rq.prefs.lang, &rq.path_and_query)></script>
02

Out-of-order streaming, on its own route

Leptos on the server

Leptos can also send the page's frame first and then each <Suspense> the moment its data resolves. Each section arrives as a <template> plus a small inline script that moves it into place. The demo route /showcase/stream is the only page here that streams this way.

Why it matters hereIt gives the fastest first paint, but sections only land with JavaScript on, and a router that fetches the page as HTML gets it only once the slowest section is done. That's why the demo links carry data-router="off": only a full page load lets you watch the stream.

Live demo

Simulated latency is off: the blocks would all arrive at once. Pick a latency first.

Open the streaming demoWith the deals failing

Both open as a full page load (data-router="off"). The client router would fetch the page as HTML and get it only after the slowest block, so you'd see nothing stream.

Used in

Only the demo route streams out of order; every shop page renders in order (the card above).

Source
  • crates/app/src/pages/showcase/stream.rs
  • crates/app/src/lib.rs
crates/app/src/lib.rs:110Show code · 1
<Route path=path!("showcase/stream") view=pages::showcase::StreamDemoPage ssr=SsrMode::OutOfOrder/>
crates/app/src/pages/showcase/stream.rs:202Show code · 26
let data = Resource::new(move || (), move |_| api::home_rail(rail, 3));
let name = tr(lang, label.0, label.1);
// The Suspend below may run more than once; the block takes its arrival rank once.
let seen: Arc<std::sync::OnceLock<(u64, u32)>> = Arc::new(std::sync::OnceLock::new());
view! {
    <section class="sc-sblock" aria-label=name data-stream-block="">
        <header class="sc-sblock__head">
            <strong>{name}</strong>
            <code class="sc-tag mono" dir="ltr">{format!("home_rail({rail:?})")}</code>
        </header>
        <Suspense fallback=|| view! { <BlockSkeleton/> }>
            <ErrorBoundary fallback=|errors| view! { <SectionError errors compact=true/> }>
                {
                    let clock = clock.clone();
                    move || {
                        let clock = clock.clone();
                        let seen = seen.clone();
                        Suspend::new(async move {
                            let r = data.await;
                            let (ms, rank) = *seen.get_or_init(|| clock.arrived());
                            r.map(|cards| view! {
                                <p class="sc-sblock__meta" data-streamed="">
                                    <span class="sc-rank mono">{format!("#{}", format::count(rank, num))}</span>
                                    <span class="mono">{match lang {
                                        Lang::En => format!("finished after {} ms", format::int(ms as i64, num)),
                                        Lang::Ar => format!("خلص بعد {} مللي ثانية", format::int(ms as i64, num)),
03

Server functions are the data API

Leptos on the server

#[server(endpoint = "cache/stats", input = GetUrl)] turns an async Rust function into an HTTP endpoint with a fixed URL. Server components call it like any function. The TypeScript client fetches the URL and gets JSON back, with money already formatted.

Why it matters hereOne Rust function serves the server's own reads and the client's fetches, so there's no second REST layer to keep in step. Fixed names keep URLs the same from build to build (default names carry a hash), so the client and the CDN can rely on them.

Live demo

Open the JSON

Status
—
Time
—
Cache-Control
—
{ … }

The endpoints this page's client may call, from #app-config:

  • GET /api/cache/stats
  • POST /api/cache/bump
Source
  • crates/app/src/api/mod.rs
  • client/src/core/net.ts
crates/app/src/api/mod.rs:94Show code · 14
#[server(endpoint = "cache/stats", input = leptos::server_fn::codec::GetUrl)]
pub async fn cache_stats() -> Result<CacheInfo, ServerFnError> {
    let rq = crate::request::init();
    crate::server::cache::private();
    let st = state();
    let s = st.reads().stats();
    Ok(CacheInfo {
        entries: s.entries,
        hits: s.hits,
        misses: s.misses,
        generation: st.reads().generation(&rq.tenant.id),
        base_latency_ms: crate::server::sim::base_ms(),
    })
}
client/src/core/net.ts:61Show code · 8
export async function getJSON<T>(url: string, opts: RequestOpts = {}): Promise<T> {
  const res = await request(url, { ...opts, init: { ...opts.init, headers: { Accept: "application/json" } } });
  try {
    return (await res.json()) as T;
  } catch {
    throw new NetError("parse");
  }
}
04

Error boundaries, one per section

Leptos on the server

Each section that reads data has its own <ErrorBoundary>. When a read fails, that section says what failed and offers Retry, and the rest of the page renders normally. A failure that is known before the first byte becomes a full error page with a real 500 or 503.

Why it matters hereA slow or broken recommendations service shouldn't take the product page down with it. ?fail=<widget> breaks one read on purpose, so every error state can be checked.

Live demo

Whole page404500503

Each block reads through its own <Suspense> and <ErrorBoundary>, rendered in order. With one failing, the other still shows, and so does the rest of this page.

Source
  • crates/app/src/pages/errors.rs
  • crates/app/src/server/sim.rs
crates/app/src/pages/errors.rs:226Show code · 6
pub fn SectionError(
    #[prop(optional)] errors: Option<ArcRwSignal<Errors>>,
    #[prop(optional, into)] what: Option<String>,
    #[prop(optional, into)] retry_href: Option<String>,
    #[prop(optional)] compact: bool,
) -> impl IntoView {
crates/app/src/server/sim.rs:103Show code · 10
pub fn check_fail(w: Widget) -> Result<(), ServerFnError> {
    let wanted = query_param("fail").or_else(|| cookie("fail")).unwrap_or_default();
    if wanted.split([',', '+', ' ']).any(|x| x == w.code() || x == "all") {
        return Err(ServerFnError::ServerError(format!(
            "simulated failure in '{}' (remove ?fail= to recover)",
            w.code()
        )));
    }
    Ok(())
}
05

The moka read cache

Leptos on the server

Catalogue reads go through one moka cache keyed tenant:generation:kind:key. Entries expire after their TTL, and bumping a shop's generation drops all of its entries at once, without a scan.

Why it matters hereListings, rails and search results are computed once and served from memory to every visitor who asks the same thing. A catalogue edit is one counter bump, plus a CDN purge by Cache-Tag in production.

Live demo

Entries
9
Hits
6,465
Misses
5,677
Hit rate
53%
This shop's generation
0

Bumping drops every cached read of this shop: the next page you open misses, then hits again.

Source
  • crates/app/src/server/cache.rs
  • crates/app/src/api/mod.rs
crates/app/src/server/cache.rs:133Show code · 9
let full = format!("{tenant}:{}:{kind}:{key}", self.generation(tenant));
let mut missed = false;
let value = self
    .cache
    .get_with(full, async {
        missed = true;
        let miss_ms: u64 = std::env::var("CACHE_MISS_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(0);
        if miss_ms > 0 {
            tokio::time::sleep(Duration::from_millis(miss_ms)).await;
crates/app/src/server/cache.rs:120Show code · 3
pub fn bump(&self, tenant: &str) {
    self.generations.insert(tenant.to_owned(), self.generation(tenant) + 1);
}
06

Tenancy by Host

Leptos on the server

One binary serves both shops. The shop is the first label of the Host header (nile.…, souq.…). It is resolved before rendering, so the logo, the name and the default language are in the first byte.

Why it matters hereAdding a shop is data, not a deployment. Hosts without a shop label, like a bare IP on the phone, fall back to a tenant cookie.

Live demo

Host
nile.leptos-preview.duckdns.org
Picked by
the Host's first label (the production path)
Shop
nile · Nile / نايل
Default language
English
Catalogue
13 categories · 191 items
Delivery
EGP 45.00 · free over EGP 1,500.00
Order codes
NIL-48213
Cache-Tag prefix
tnile

This page at SouqAnother subdomain is another origin: a full page load, with its own cookies.

Source
  • crates/app/src/tenant.rs
  • crates/app/src/request.rs
crates/app/src/tenant.rs:59Show code · 7
pub fn tenant_from_host(host: &str) -> Option<&'static str> {
    let hostname = strip_port(host.trim());
    let mut labels = hostname.split('.');
    let first = labels.next()?.to_ascii_lowercase();
    labels.next()?;
    TENANT_IDS.iter().copied().find(|id| *id == first)
}
crates/app/src/request.rs:85Show code · 9
let (tenant_id, via) = match tenant_from_host(&host) {
    Some(id) => (id, TenantVia::Subdomain),
    None => match cookie(COOKIE_TENANT)
        .and_then(|c| TENANT_IDS.iter().copied().find(|id| *id == c))
    {
        Some(id) => (id, TenantVia::Cookie),
        None => (DEFAULT_TENANT, TenantVia::Default),
    },
};
07

Head and meta

Leptos on the server

<Title>, <Meta> and <Link> from leptos_meta are written synchronously from in-memory data. Title, description, canonical, OpenGraph and JSON-LD are all in the <head> of the first chunk.

Why it matters hereCrawlers and link previews read the first response and never run scripts. With in-order SSR and no head that waits for data, they see exactly what the shopper sees.

Live demo

<title>
How this storefront is built
robots
noindex

Read live from this document's <head>. After an in-app navigation it shows the new page's head, which the router brings along.

Source
  • crates/app/src/pages/product/head.rs
  • crates/app/src/pages/home.rs
crates/app/src/pages/product/head.rs:221Show code · 14
<Title text=h.title.clone()/>
<Meta name="description" content=h.description.clone()/>
<Link rel="canonical" href=h.canonical.clone()/>
{preload}
<Meta property="og:type" content="product"/>
<Meta property="og:site_name" content=h.site_name.clone()/>
<Meta property="og:title" content=h.title.clone()/>
<Meta property="og:description" content=h.description.clone()/>
<Meta property="og:url" content=h.canonical.clone()/>
<Meta property="og:locale" content=h.locale/>
<Meta property="og:locale:alternate" content=h.alt_locale/>
{og_image}
<Meta property="product:price:amount" content=h.price.clone()/>
<Meta property="product:price:currency" content="EGP"/>
08

Simulated latency and faults

Leptos on the server

/__sim?ms=350 sets a cookie for you alone. Every widget's read then waits a multiple of that base: deals 2.6×, related products 1.8×, search 0.25×. ?fail=<widget> breaks one read for one request, and /__sim?fail=deals breaks it for the whole session.

Why it matters hereOn localhost everything answers at once, which hides loading states, slow phones and partial failures. It's off by default, and it's how every skeleton, timeout and error state here was checked.

Live demo

Base latency

Simulated latency is off: the blocks would all arrive at once. Pick a latency first.

Clear every fault

A fault makes that widget's read fail on every page until you clear it. Try “deals”, then open the home page.

Source
  • crates/app/src/server/sim.rs
  • crates/server/src/main.rs
crates/app/src/server/sim.rs:86Show code · 7
pub fn base_ms() -> u64 {
    cookie("sim")
        .and_then(|v| v.parse().ok())
        .or_else(|| std::env::var("SIM_LATENCY_MS").ok().and_then(|v| v.parse().ok()))
        .unwrap_or(0)
        .min(10_000)
}
crates/app/src/server/sim.rs:95Show code · 6
pub async fn delay(w: Widget) {
    let ms = (base_ms() as f64 * w.factor()) as u64;
    if ms > 0 {
        tokio::time::sleep(Duration::from_millis(ms)).await;
    }
}

The TypeScript client

One small ES module, bundled by esbuild. It changes state on the spot and asks the server only for data it doesn't have.

09

The router and the page cache

The TypeScript client

core/router.ts takes over same-origin links and forms. It fetches the next page's HTML, swaps the page's regions, and mounts the features in what came in. Pages are cached with a TTL and stale-while-revalidate, a hover (65 ms) or a finger-down prefetches, and refresh() skips the cache.

Why it matters hereNavigation feels instant without rendering pages in the browser. The server still renders every page, and the router only moves HTML. Without JavaScript the same links are ordinary page loads.

Live demo

Open it

routing.pending
false
routing.to
null
Navigations in this tab
0

    Prefetch, then open: the page comes out of the page cache. Hovering a link for 65 ms or putting a finger on it does the same.

    Source
    • client/src/core/router.ts
    • client/src/core/cache.ts
    • client/src/main.ts
    client/src/core/router.ts:62Show code · 1
    const PAGE_CACHE = { ttl: 30_000, swr: 5 * 60_000 };
    client/src/core/router.ts:493Show code · 12
    // ── Prefetch: the gesture model ─────────────────────────────────────────────────────────────────
    // - a mouse or pen hover warms after HOVER_DELAY_MS (a pass-over warms nothing); one pending
    //   hover at a time — entering another target or leaving cancels it;
    // - keyboard focus (`:focus-visible`) waits the same; blur cancels; focus moved by code warms
    //   nothing;
    // - pointerdown warms at once (a phone's only signal; on desktop it buys the ~100 ms to the click)
    //   and is never throttled;
    // - the same href is warmed at most once per DEDUPE_MS; hovers and focus warm at most
    //   HOVER_BUDGET_PER_S targets a second; nothing is warmed on Save-Data or 2G.
    // Warming is the router's own GET into the page cache, so the click finds it there (or joins it
    // mid-flight). /__ links set preferences (state-changing GETs): never warmed.
    const HOVER_DELAY_MS = 65, DEDUPE_MS = 2000, HOVER_BUDGET_PER_S = 6;
    client/src/main.ts:39Show code · 11
    readPage();
    syncFromCookie();
    mountToaster();
    mountAll(document);
    startRouter();
    
    onNavigate(() => {
      readPage();
      syncFromCookie();
      mountAll(document);
    });
    10

    One store per concern

    The TypeScript client

    createStore(initial) is the only state primitive: a value with subscribers. Each concern has exactly one store, created once in stores/*.ts: the cart, the toast queue, the router's pending state. Every feature that needs one imports that same store.

    Why it matters hereThe header badge, the drawer and the cart page can't disagree when they read the same store. There's also one path per action: the cart changes only through stores/cart.ts.

    Live demo

    cart
    —
    toasts
    —
    routing
    —

    Each line is a live subscription. Fire a few toasts: the queue keeps three, newest in front, and the toaster draws them from the same store.

    Source
    • client/src/core/store.ts
    • client/src/stores/toasts.ts
    • client/src/stores/cart.ts
    client/src/core/store.ts:12Show code · 19
    export function createStore<T>(initial: T): Store<T> {
      let value = initial;
      const subs = new Set<(v: T) => void>();
      const set = (next: T) => {
        if (Object.is(next, value)) return;
        value = next;
        for (const fn of [...subs]) fn(value);
      };
      return {
        get: () => value,
        set,
        update: (fn) => set(fn(value)),
        subscribe(fn) {
          subs.add(fn);
          fn(value);
          return () => subs.delete(fn);
        },
      };
    }
    client/src/stores/toasts.ts:31Show code · 6
    export function toast(msg: string, kind: ToastKind = "info", action?: Toast["action"]): number {
      const t: Toast = action ? { id: nextId++, msg, kind, action } : { id: nextId++, msg, kind };
      toasts.update((list) => [...list, t].slice(-MAX_VISIBLE));
      schedule(t);
      return t.id;
    }
    11

    The cart lives in a cookie

    The TypeScript client

    The browser owns the cart: lines like 2307:1:2 in the cart cookie, written only by stores/cart.ts. Adding, removing and changing a quantity happen on the spot, with no request. The server reads the same cookie only when it has to price it: on /cart, for the drawer's quote and at checkout.

    Why it matters hereTaps never wait for the network, the cart survives reloads and stays in step across tabs, and no-JS forms change the very same cookie. Prices, stock and totals still come from the server, formatted.

    Live demo

    cart cookie
    —
    Units in the cart
    —
    Requests for that change
    —
    Server's quote
    —

    This is your real cart: the header badge and the drawer follow it. Adding and removing make no request; only “Ask the server” does, and it sends nothing but the cookie.

    Source
    • client/src/stores/cart.ts
    • crates/domain/src/cart.rs
    client/src/stores/cart.ts:96Show code · 8
    function commit(lines: CartLine[]): void {
      const value = serialize(lines);
      document.cookie = value
        ? `${COOKIE()}=${encodeURIComponent(value)}; Path=/; Max-Age=${MAX_AGE}; SameSite=Lax`
        : `${COOKIE()}=; Path=/; Max-Age=0; SameSite=Lax`;
      cart.set({ lines, count: count(lines) });
      channel?.postMessage("changed");
    }
    crates/domain/src/cart.rs:44Show code · 22
    pub fn parse(raw: &str) -> Vec<CartLine> {
        let mut lines: Vec<CartLine> = Vec::new();
        for part in raw.split('|') {
            let mut f = part.split(':');
            let (Some(p), Some(v), Some(q)) = (f.next(), f.next(), f.next()) else { continue };
            let (Ok(p), Ok(v), Ok(q)) = (p.parse::<u32>(), v.parse::<u32>(), q.parse::<u32>()) else { continue };
            if q == 0 {
                continue;
            }
            let mut addons: Vec<String> = f.next().map(|a| a.split('+').filter(|a| addon_ok(a)).map(str::to_owned).collect()).unwrap_or_default();
            addons.sort();
            addons.dedup();
            let line = CartLine { product: ProductId(p), variant: VariantId(v), qty: q.min(MAX_QTY), addons };
            let key = line.key();
            if let Some(l) = lines.iter_mut().find(|l| l.key() == key) {
                l.qty = (l.qty + line.qty).min(MAX_QTY);
            } else if lines.len() < MAX_LINES {
                lines.push(line);
            }
        }
        lines
    }
    client/src/stores/cart.ts:266Show code · 4
    export function quote(onUpdate?: (q: CartQuote) => void): Promise<CartQuote> {
      const lines = cart.get().lines;
      const sig = serialize(lines);
      const want = lines.map((l) => `${lineKey(l)}=${l.qty}`).join("|");
    12

    Templates filled by TypeScript

    The TypeScript client

    When the client has to show data, such as a toast, a cart line or a search suggestion, it clones a server-rendered <template id="tpl-…">, and fill() sets each [data-slot] as text. The markup and its translated labels are written once, in Rust.

    Why it matters hereThere's no second copy of the UI in the client, no HTML built from strings, and nothing inserted as HTML. A change to a component's markup is made in one place, and the client picks it up.

    Live demo

      The row's markup is the <template> above, rendered by Rust in your language. The client only puts the data in, as text.

      Source
      • client/src/core/dom.ts
      • crates/app/src/components/toast.rs
      client/src/core/dom.ts:32Show code · 15
      export function fill(name: string, values: Record<string, string | number | null | undefined>): DocumentFragment {
        const tpl = document.getElementById(`tpl-${name}`);
        if (!(tpl instanceof HTMLTemplateElement)) throw new Error(`missing <template id="tpl-${name}">`);
        const frag = tpl.content.cloneNode(true) as DocumentFragment;
        patch(frag, values);
        for (const el of $$("*", frag)) {
          for (const a of Array.from(el.attributes)) {
            if (!a.name.startsWith("data-attr-")) continue;
            const v = values[a.value];
            if (v !== null && v !== undefined) el.setAttribute(a.name.slice("data-attr-".length), String(v));
            el.removeAttribute(a.name);
          }
        }
        return frag;
      }
      crates/app/src/components/toast.rs:25Show code · 15
      <template id="tpl-toast">
          <li class="toast">
              <div class="toast__inner">
                  <Icon name="circle-check" size=20 class="toast__icon toast__icon--success"/>
                  <Icon name="circle-x" size=20 class="toast__icon toast__icon--error"/>
                  <Icon name="info" size=20 class="toast__icon toast__icon--info"/>
                  <p class="toast__msg" data-slot="msg"></p>
                  <button type="button" class="toast__action" data-toast-action="" data-slot="action"></button>
                  <button type="button" class="toast__close" data-toast-close=""
                      aria-label=crate::strings::get("toast.dismiss", lang)>
                      <Icon name="x" size=16/>
                  </button>
              </div>
          </li>
      </template>
      13

      The client data cache

      The TypeScript client

      core/cache.ts keeps API reads and pages in memory: fresh for a TTL, then stale-while-revalidate (shown at once, refreshed in the background), then gone. Reads of one key at the same time share one request, and every key includes the shop, the language and the numerals.

      Why it matters hereGoing back to a page or reopening the drawer shows data immediately, and a language switch can never show a cached page in the other language. A new catalogue generation from the server (x-catalog-gen) drops everything.

      Live demo

      Key
      —
      Age
      —
      cache.ts stats()
      —

        The value is the server's cache stats, cached here for 5 s fresh and 10 s more stale. Click a few times and watch the three cases.

        Source
        • client/src/core/cache.ts
        client/src/core/cache.ts:38Show code · 15
        export async function cached<T>(
          k: string,
          opts: CacheOpts,
          fetcher: () => Promise<T>,
          onUpdate?: (value: T) => void,
        ): Promise<T> {
          const hit = entries.get(k) as Entry<T> | undefined;
          const age = hit ? Date.now() - hit.at : Infinity;
          if (hit && age <= opts.ttl) {
            counts.hits++;
            return hit.value;
          }
          if (hit && age <= opts.ttl + (opts.swr ?? 0)) {
            counts.stale++;
            void load(k, opts, fetcher).then((v) => onUpdate?.(v), () => {});
        14

        Springs, from core/motion.ts

        The TypeScript client

        SpringValue animates a number with a damped spring, solved exactly on every frame. It can be interrupted: a new target starts from the live value and velocity. A flick hands over its release velocity, project() says where it would come to rest, and rubberband() resists past an edge.

        Why it matters hereDrawers, sheets and the gallery follow the finger and leave the way they came, as on iOS. There's one implementation for every gesture, so nothing moves on a different curve.

        Live demo

        Value
        0
        Velocity
        0
        Projected rest
        —

        Drag the dot and let go. A slow release settles on the nearest stop; a flick is projected and lands where it would have come to rest. Past either end it rubber-bands.

        Source
        • client/src/core/motion.ts
        client/src/core/motion.ts:40Show code · 8
        export const project = (velocity: number, deceleration = 0.998): number =>
          ((velocity / 1000) * deceleration) / (1 - deceleration);
        
        /** How far an element follows a drag `overshoot` px past an edge (Apple's constant 0.55). */
        export const rubberband = (overshoot: number, dimension: number, constant = 0.55): number => {
          const d = Math.max(dimension, 1);
          return (overshoot * d * constant) / (d + constant * Math.abs(overshoot));
        };
        client/src/features/showcase.ts:463Show code · 9
        // Release: hand the finger's velocity to the spring and aim where the flick would come to rest.
        const release = () => {
          if (!drag) return;
          drag = null;
          const v = Math.max(-4000, Math.min(4000, tracker.velocity()));
          const rest = spring.value + project(v);
          patch(root, { proj: t("showcase.px", { n: digits(Math.round(rest)) }) });
          aim(nearest(Math.min(Math.max(rest, 0), max)), v);
        };
        15

        The size of the client

        The TypeScript client

        All the browser code is one ES module. It is type-checked by tsc, bundled by esbuild from crates/app/build.rs, embedded in the server binary and served at a content-hashed /js/app-….js that browsers keep for good.

        Why it matters hereThe budget for the whole client is 30 KB gzip. The WASM islands it replaced cost 379–769 KB brotli on every page, before any page-specific chunk.

        Live demo

        File
        /js/app-4EHXELV3.js
        Raw, as built
        0.9 KB
        Code only (no source map), gzip
        —
        As your browser received it
        —
        This client, gzip—
        The budget, gzip30 KB
        WASM islands, split build, brotli379 KB
        WASM islands, one bundle, brotli769 KB

        A release build: minified, no source map.

        Source
        • client/build.mjs
        • crates/app/build.rs
        • crates/app/src/client_js.rs
        crates/app/build.rs:31Show code · 7
        node(&["node_modules/typescript/bin/tsc", "--noEmit", "-p", "."]);
        let js_dir_s = js_dir.to_string_lossy().into_owned();
        let mut args = vec!["build.mjs", "--outdir", js_dir_s.as_str()];
        if release {
            args.push("--release");
        }
        node(&args);
        client/build.mjs:58Show code · 13
        const probe = (await build({ ...options, write: false })).metafile;
        const abs = (p) => path.resolve(p);
        const entryOut = Object.keys(probe.outputs).find((f) => probe.outputs[f].entryPoint && abs(probe.outputs[f].entryPoint) === entry);
        const firstLoad = new Set([entryOut]);
        for (const f of firstLoad) for (const i of probe.outputs[f].imports) if (i.kind === "import-statement" && !i.external) firstLoad.add(i.path);
        const firstLoadModules = [...firstLoad].flatMap((f) => Object.keys(probe.outputs[f].inputs)).map(abs).filter((m) => m !== entry);
        const onDemand = new Set(Object.values(probe.outputs).map((o) => o.entryPoint && abs(o.entryPoint)).filter((m) => m && m !== entry));
        
        // Pass 2: every on-demand feature also reaches the first-load modules → one shared chunk.
        const firstLoadChunk = {
          name: "first-load-chunk",
          setup(b) {
            b.onLoad({ filter: /\.tsx?$/ }, (args) => {

        Decisions

        What we built first, what it cost, and why we changed course.

        16

        Why we left WASM islands

        Decisions

        The first version used Leptos islands: #[island] components compiled to WebAssembly and hydrated in the browser, with the islands router doing the navigation. It worked, and we measured it in Chromium against the release server.

        Why it matters hereEvery page paid 379–769 KB of brotli WASM for a storefront that is mostly static, and hydration had race bugs that broke whole pages. Here are the measurements.

        Live demo

        One bundle, brotli
        769 KB
        Split build, main module
        379 KB
        Rebuild after a one-line change
        36–48 s (server alone: 15 s)
        PageWASM filesWASM, brotli
        Home1379 KB
        Cart, checkout1379 KB
        Listing, search83578 KB
        Product107581 KB
        /ui37431 KB
        /showcase210713 KB
        • An island still waiting for data when hydration ran panicked after an in-app navigation, and the panic stopped every island hydrated after it, the header's cart badge included.
        • A lazy island built without --split made the JS glue import a placeholder module, so no island on any page hydrated.
        • Values created for the browser inside a component body could be dropped on another server thread, which panicked and cut the HTML short, at random.
        • The islands router needed about 400 lines of shim: head alignment, <noscript>, the browser cache, the pressed submit button, scroll and back.

        What replaced it is measured live in “The size of the client”. See it

        Used in

        Measured on 26 October 2026 with Leptos 0.8.22: release builds, bytes as sent. The full report is in README.md.

        Source
        • README.md

        Your cart

        Subtotal
        You save
        Delivery
        Delivery
        Total

        Prices include 14% VAT.

        CheckoutView cart

        Edit item