Caching

By default a page's @server loader runs on every request and the page is rendered fresh each time. For pages whose content is the same for everyone and changes rarely — a marketing page, a docs article, a blog post — that work is wasted. Pyxle's page cache stores the rendered HTML and serves it back without re-running the loader or the SSR render, then refreshes it in the background when it goes stale.

The one rule: only cache pages that render no per-user data. A cached render is shared byte-for-byte with every visitor. If a page embeds a logged-in user's name, a CSRF token, or anything request-specific, do not cache it — you would serve one user's page to another. Caching is always opt-in, exactly so this stays your deliberate choice.

Making a page cacheable

Return a {"data": ..., "revalidate": <seconds>} envelope from your loader instead of a plain dict. data is the props your component receives (exactly as before); revalidate is how many seconds the cached render stays fresh.

@server
async def load_post(request):
    post = await fetch_post(request.path_params["slug"])
    return {
        "data": {"post": post},
        "revalidate": 60,   # cache this render for 60 seconds
    }
export default function Post({ data }) {
  return <article>{data.post.body}</article>;   // `data` is the inner dict
}

That's it. The first request renders and stores the page; requests within the next 60 seconds are served from the cache — no loader, no Node render.

The envelope is recognised only in its exact two-key shape (data and revalidate, nothing else). A normal loader that happens to return keys named data or revalidate is never mistaken for a cache directive.

Pages without a loader

A page with no @server loader can still opt into caching with a module-level CACHE directive in its Python section:

CACHE = {"revalidate": 3600}   # cache this static page for an hour
export default function About() {
  return <main><h1>About Us</h1></main>;
}

revalidate is the freshness window in seconds, exactly as in the loader envelope (0 means "serve cached but re-render every request"). It is validated at compile time: an invalid CACHE directive (non-numeric or negative value, or a malformed shape) is reported as a compile diagnostic and the page is treated as uncached — whether that diagnostic blocks the build depends on your strict/non-strict diagnostic mode. If a page declares both a loader envelope and a CACHE directive, the loader's revalidate wins.

Incremental regeneration (stale-while-revalidate)

When a cached page passes its revalidate window, the next request still gets the cached (stale) bytes immediately — no one waits for a re-render — and a single background re-render refreshes the cache for everyone after it. This is incremental static regeneration (ISR): fast responses, fresh-enough content, and never a thundering herd (only one refresh runs per page at a time, even under load).

revalidate: 0 is valid and means "serve the cached copy but re-render on every request" — useful when you want to absorb bursts without ever serving content older than one render.

Static pre-rendering (pyxle build --static)

Pages with no @server loader and no dynamic route parameters render the same HTML for everyone, every time. pyxle build --static renders them once at build time and stores the result, so the first request after a deploy is already a cache hit — no cold SSR render, even for the very first visitor.

pyxle build --static

This pre-renders every loader-less, non-dynamic page into the build output's prerendered/ subdirectory (dist/prerendered/ by default; dist here means the configured build/output directory, i.e. pyxle build -o/--out-dir); on startup pyxle serve warms its page cache from that directory. Pages with a loader (or a {param} route) are skipped — they still render live and cache at runtime as before.

A static page's layout loader still runs at build time — its result is baked into the pre-rendered HTML, so a layout loader used by static pages should return the same data for everyone (global status, navigation), not per-user state. Build- time loaders run with the same plugin context a request has, so a layout (or page) loader that reads from a plugin like pyxle-db pre-renders correctly — the static builder opens your plugins' connections at build time, just as a server would.

Pre-rendered entries have no expiry until you cache.invalidate(...) the route. With the default in-memory backend they are re-warmed from the new dist/prerendered/ on every restart; with a shared file/redis backend, warmed copies persist in that store, so if a later deploy stops passing --static (or adds a loader to a previously-static route), flush the cache or invalidate those routes — replacing dist/ does not clear them.

Invalidating the cache

When the underlying data changes — you publish a post, edit a page — purge the cached render so the next request re-renders immediately instead of waiting out the revalidate window:

from pyxle import cache

@action
async def publish_post(request):
    body = await request.json()
    await save_post(body)
    await cache.invalidate(f"/posts/{body['slug']}")   # drop that page's cache
    return {"ok": True}
  • await cache.invalidate(path) purges one route's cached render. Returns True if something was cached, False otherwise — safe to call either way.
  • await cache.invalidate_all() purges every cached render.

How it interacts with the edge cache

A route listed in your pyxle.config.json cache block (the edge cache, which sets Cache-Control: public, s-maxage=… for a CDN) is also served from the server-side page cache automatically, using that same TTL — you don't need to repeat yourself. When both apply, a loader's revalidate wins over the edge s-maxage.

Cached page responses carry a strong ETag, so a conditional request (If-None-Match) gets a 304 Not Modified, and an x-pyxle-cache response header reports HIT, STALE, or MISS for debugging.

Client navigation cache

Separately from the server-side page cache, the browser keeps a per-URL navigation cache of loader payloads so back/forward navigation is instant. Its lifetime mirrors a page's real cacheability:

  • A page with a cache entry (or CACHE directive) reuses that TTL.
  • A dynamic page — a @server loader with no declared cache lifetime — is not navigation-cached by default (TTL 0), so a fresh navigation always refetches and a just-made mutation is visible immediately rather than hidden behind a stale window. Opt in by giving the route a cache entry.
  • A static, loader-less page uses the client default (2 minutes).

See Navigation cache TTL for the full client-side details.

Where the cache lives

The cache is enabled automatically for production serves (pyxle serve) and disabled in pyxle dev so a cached render never hides an edit while you work. Choose where rendered HTML is stored with the PYXLE_PAGE_CACHE_BACKEND environment variable:

PYXLE_PAGE_CACHE_BACKEND Stores in Use when
memory (default) bounded in-process memory (LRU) single process, or any deploy where per-worker caches are fine
file local disk (PYXLE_PAGE_CACHE_DIR) one host, multiple workers sharing a cache that survives restarts
redis shared Redis (PYXLE_PAGE_CACHE_REDIS_URL) multiple hosts, or cross-worker invalidation
off nothing — caching disabled you want it off in production (none and disabled are accepted aliases for off)

The in-memory backend is bounded by entry count (PYXLE_PAGE_CACHE_MAX_ENTRIES, default 512) and total body bytes (PYXLE_PAGE_CACHE_MAX_BYTES, default 64 MiB) with LRU eviction, so it never grows without limit. The Redis backend needs the optional extra: pip install 'pyxle-framework[redis]'.

Invalidation and workers. With the in-memory or file backend under pyxle serve --workers N, each worker keeps its own store, so cache.invalidate(...) reaches the worker that handled the action; entries still expire everywhere on their own via revalidate. The Redis backend is shared across every worker and host, so an invalidation fans out to all of them — choose it when you need cross-worker purges.

When *not* to cache

  • Pages that show the signed-in user's data, a per-user CSRF token, or anything that varies by request. Leave these as plain loaders (return {...}), and they are never cached.
  • Pages that must always reflect the absolute latest data with zero staleness — use a plain loader, or revalidate: 0 plus cache.invalidate(...) on every write.

Query strings: the cache keys on the route path only, so a request that carries a query string (/search?q=…, ?page=2) is always served live — never cached and never served a cached entry. A page whose content varies by query is therefore never collapsed onto one shared render.

Next steps