---
title: "Cache loaders and renders"
audience: "anyone who has hand-tuned a cache before"
description: "You'll make pages fast by letting FSR do the caching. FSR works out which routes read nothing of the request, renders those once and answers them from a…"
snippet_caption: "config/app.toml"
snippet: "[cache]\ncapacity = 1000\nttl = \"1m\""
---

import { Install } from "@src/ui/Install";

You'll make pages fast by letting FSR do the caching. FSR works out which routes read nothing of the request, renders those once and answers them from a file. For the ones that do read the request, it caches the expensive half and leaves the rest live.

<Install captured="0.x" />

## Turn on the render memo

Without this section nothing is cached at all.

```toml title="config/app.toml"
[cache]
capacity = 1000
ttl = "1m"
```

That's the in-memory memo for rendered documents.

## Prerender the pages that never change

A route with no parameter, whose every loader is lowered and reads nothing of the request, renders the same for everybody. FSR already knows which those are. Ask it:

```shell-session
$ fsr dev myapp/app --verbose
prerender /about                 not configured
          /pricing               not configured
warm      layout.promo           not warmed
```

Now point the config at somewhere to put them and render them:

```toml title="config/app.toml" {2}
[server]
prerender = "dist/prerender"
```

```shell-session
$ fsr prerender myapp/app | grep -v static/js/fsr/
loads.json             myapp/app/dist/prerender/loads.json
/about                 myapp/app/dist/prerender/about/index.html
/about                 myapp/app/dist/prerender/about/index.payload
/pricing               myapp/app/dist/prerender/pricing/index.html
/pricing               myapp/app/dist/prerender/pricing/index.payload
prerender.build        myapp/app/dist/prerender/prerender.build
prerendered.json       myapp/app/dist/prerender/prerendered.json
```

`index.payload` is what a client-side navigation to the page reads. The host loads the files into memory at boot. From then on a `GET` for those paths is answered from there with an `x-sf-prerendered: 1` header. Your session cookie and your middleware still run. One file is written per locale, so `/about` and `fr_FR/about/index.html` sit side by side and the request's locale picks.

## What makes a route dynamic

A loader that touches a parameter, the query, the session or the clock keeps its route live. So does a Rust source, because the host can't see what a Rust function reads.

A route whose only request read is the identity is an exception: it gets prerendered for anonymous visitors. The `--verbose` report says so:

```shell-session
prerender /posts                 dist/prerender for anonymous visitors
```

Signed-out visitors get the file. Signed-in ones are rendered live and memoized under their own subject. Previews work the same way. The editor signs in and their loader's call carries their token, while the public still gets the file.

## Cache just the slow part

One layout is enough to make a whole subtree dynamic. A console whose header shows who you're watching means every page under it reads the request, however fixed the page itself is. So the route can't be prerendered, even though most of the cost is in the loader.

So FSR classifies per source as well as per route. `fsr prerender` runs every source under `warm` once with no request behind it, then writes the results beside the documents.

```shell-session
$ fsr prerender console/app | grep loads.json
loads.json             console/app/dist/prerender/loads.json
```

At boot the host reads that file and its report says what it took:

```text
warm      layout.promo           2 loads memoized
          widths
``` A request reaching one of those sources takes the loaded value instead of running the loader. The console's header is still rendered per session.

## When the memo refreshes

Only `fsr prerender` fills the memo. A request never writes to it, so it can't grow without bound. A source the prerender did not reach runs its loader on every request, which is slower but still correct. Run `fsr prerender` again to refresh the memo.

## Cache a backend's answers

A loader that calls a third-party API on every request wastes the API's rate limit. Here you'll show your repository's GitHub star count and fetch it once a day.

Describe the one endpoint you need in `clients/github.openapi.json`. Name only the field you read, since FSR drops the rest of GitHub's response before your loader sees it. `x-sf-cache` says how long an answer is good for:

```json title="app/clients/github.openapi.json"
"/repos/{owner}/{repo}": {
  "get": {
    "operationId": "getRepo",
    "x-sf-cache": { "ttl": "24h", "scope": "shared", "stale": "7d" },
    "parameters": [
      { "name": "owner", "in": "path", "required": true, "schema": { "type": "string" } },
      { "name": "repo", "in": "path", "required": true, "schema": { "type": "string" } }
    ],
    "responses": {
      "200": { "content": { "application/json": { "schema": {
        "type": "object",
        "required": ["stargazers_count"],
        "properties": { "stargazers_count": { "type": "integer" } }
      } } } }
    }
  }
}
```

`shared` serves every visitor the same answer. `stale` keeps serving the last count for a week after it expires while a refresh runs, so only the first request ever waits on GitHub. Then point the client at GitHub and turn the data cache on:

```toml title="config/app.toml"
[cache.data]
enabled = true

[clients.github]
base_url = "https://api.github.com"
user_agent = "my-site"
timeout = "5s"
```

GitHub refuses a request without a `User-Agent`. The `timeout` keeps a slow GitHub from holding your page for the default 30 seconds.

Call it from the loader and fall back when it fails:

```ts title="app/routes/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ services }: Ctx<"/">) {
  let stars: number | null = null;
  try {
    const repo = await services.github.getRepo({ owner: "excsn", repo: "snapfire" });
    stars = Number(repo.stargazers_count);
  } catch {
    stars = null;
  }
  return { stars };
}
```

The boot report lists the method as `cached    github.getRepo  ttl 24h shared, stale 7d`. Load the page twice and the second request's `call` span in `/__fsr/traces` says `cache: hit`. A failed call is never cached, so the next request tries GitHub again.

## Drop cached answers when the data changes

With a `ttl` alone, a change waits out the lifetime before visitors see it. For data you write yourself, give the cached method a tag and name the same tag on the method that changes the data:

```json title="app/clients/shop.openapi.json"
"/products/{id}": {
  "get": {
    "operationId": "getProduct",
    "x-sf-cache": { "ttl": "1h", "scope": "shared", "tags": ["products"] },
    ...
  },
  "put": {
    "operationId": "updateProduct",
    "x-sf-writes": ["products"],
    ...
  }
}
```

When `updateProduct` succeeds, every cached answer tagged `products` is dropped and the next `getProduct` goes to the backend. A failed write drops nothing. A tag is a plain name, so one write drops every product's answer, including the ones it didn't change. The boot report lists the method on a `writes` row.

A change that doesn't arrive through a method, such as a webhook your Rust code receives, drops the same answers with `host.invalidate_tags(["products"])`.

Next up: [120. Find out what won't compile](https://snapfirers.com/fsr/learn/120-find-out-what-wont-compile), which is how FSR tells you a loader just became dynamic.
