# Caching

The three caches in front of a docs site, how keys are built, and how a change invalidates them safely.

Drawing a docs page takes 20 to 70 ms of CPU, and the Workers Free plan allows 10. So pages are drawn once and served from a copy afterwards. Redis holds the copies.

## The cache layers

| Layer | Key | Lifetime | Holds |
| --- | --- | --- | --- |
| Whole pages | `pages:<subdomain>:<deploy id>` (a hash) | 12 hours | Finished responses |
| Live site | `site:<subdomain>` | 10 minutes | Settings, brand, and navigation of a published site |
| Custom domain | `domain:<domain>` | 10 minutes | The subdomain address pointing to a custom domain |
| Site id | `site-id:<subdomain>` | 24 hours | The project behind an address, for analytics |

### Whole pages

`withPageCache` runs before the app. For a `GET` with no query string (other than `_rsc`), it looks up the field `page:<pathname>` or `rsc:<hash>:<pathname>` inside the Redis hash. For a docs site, this is the `pages:<subdomain>:<deploy id>` hash. For Docup's own marketing pages, it uses the `pages:app:<deploy id>` hash and prefixes the field with `in:` or `out:` depending on whether the visitor is signed in. The `<hash>` is a 12-byte hex representation of the SHA-256 digest of vinext's router and prefetch headers, representing the payload fetched when a reader clicks or hovers inside the site. A hit is returned with `x-docup-cache: hit` and a `server-timing` header. A miss renders the page and, for a `200` response with no `set-cookie` header, stores a copy after the response is sent.

To prevent a slow Redis from delaying readers, cache reads are capped at a 400 ms deadline. If the read times out or fails, the Worker draws the page instead. A page drawn because the read timed out is still stored in the cache afterwards, so the next visitor gets the copy.

What is cached: docs pages, their navigation payloads, a site's generated files, and the marketing pages (separately for signed-in and signed-out visitors). What is not: paths under `_next/`, `api/` and `docup-*` on docs hosts (images and Ask AI), anything with a query (other than `_rsc`), and anything that is not a `GET`.

Each deploy has its own hash, because a page names the script files of its deploy. A deploy therefore starts with no cached pages, and old hashes expire on their own.

When a client requests a page as Markdown (using the `Accept: text/markdown` header), the Worker serves its Markdown representation. For docs sites, the request is rewritten to `/docup-md/...` and handled by the database, bypassing the Redis page cache entirely because paths starting with `/docup-` are excluded from page caching. These negotiated responses are served with a downstream CDN cache header (`public, max-age=300`). For marketing pages, Markdown is fetched from marketing markdown files and can be cached in Redis (with the `.md` extension in the cache field). To keep HTML and Markdown versions separate in downstream caches, negotiated responses are returned with a `Vary: accept` header.

### Live site

`loadLiveSite` reads `site:<subdomain>` from Redis first. On a miss it queries the database for the site, its live pages (without their text), and its uploaded assets, computes a `version` hash of everything that can change a page, and stores it for ten minutes. The `version` is also the cache-buster for generated social images. Addresses that do not exist are stored as `none`.

### Custom domain

`siteForDomain` resolves which site a custom domain belongs to using the `domain:<domain>` key. On a cache miss, it queries the database and caches the subdomain or `none` if no site is associated with it. To avoid redundant Redis lookups, each Worker isolate also remembers the mapping in memory for up to 1 minute.

## Invalidation

`forgetSite` runs when a site changes: publishing, unpublishing, deleting a page, an address change, settings, and asset changes. A task that puts pages live after a push does the same through `forgetSiteFromTask`. They:

1. set `site:<subdomain>` to the marker `changing` for 10 seconds,
2. set `site-changed:<subdomain>` to the current timestamp (in milliseconds) for 1 hour,
3. delete `site-id:<subdomain>`,
4. delete the page copies of the running deploy. A task cannot know the deploy, so it scans for and deletes every `pages:<subdomain>:*` hash instead; older deploys' copies expire on their own.

While the marker is set, requests read the database and **do not fill the caches**. Additionally, when rendering a page, the start time is compared against the `site-changed:<subdomain>` timestamp. If the render started before the last change, the page is not saved to the cache. This prevents a slow, outdated render from overwriting new content even after the 10-second `changing` marker has expired. Once the marker expires, the next read fills the cache with fresh data.

Changes to custom domains trigger `forgetDomain`, which sets `domain:<domain>` to `changing` for 10 seconds to invalidate domain mapping caches.

> **Note:** If Redis is unavailable or too slow, pages are slower but still correct: the Worker falls back to rendering the page and logs the failure.

## Other cache headers

| Response | `Cache-Control` |
| --- | --- |
| Sitemap, robots, llms and Markdown files | `public, max-age=300` |
| Uploaded logo, favicon and social image | `max-age=86400`, with a version in the URL |
| Generated social cards | `public, max-age=86400` |
| Redirect to custom domain | `public, max-age=3600` |

## Analytics counting

Cache hits are still counted as views. `withPageCache` calls `recordView` through `waitUntil` once the response is known, so counting never delays a page. For client-side navigation, a beacon on the docs site reports movements to `/docup-view`, which triggers view recording and returns a `204` status code. You can learn more about view tracking in [Analytics](/guides/analytics).
