DocupDocup
Esc

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.

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.

Documentation written with Docup