Skip to content

Research: cheap cache invalidation vs selective warming for large sites #130

Description

@kuuak

Question

For sites with thousands of pages, revalidating every URL after a global content change is expensive. Is there a way to just invalidate the cache — the way a Varnish BAN works — and then rebuild only the pages that matter?

Where the cost actually is

It is worth separating two things this codebase currently conflates:

What it does Cost
Invalidation Mark cache entries stale. revalidateTag() / revalidatePath() O(1), cheap
Warming Force pages to re-render eagerly so the first visitor doesn't pay O(pages), expensive

The expensive part today is warming, and it is deliberate:

// next/src/app/api/revalidate/route.ts
revalidatePath(path); // Only purges the cache

const nextUrl = process.env.NEXT_URL || request.nextUrl.origin;
await fetch(`${nextUrl}${path}`); // We need to simulate the 1st request to put the new
                                  // response in the cache (so the 1st user gets the new
                                  // cached response)

…and on the WordPress side, nextjs-revalidate enqueues one URL per post and issues a wp_remote_get per item with a 60 s timeout (include/Revalidate.php:123, include/RevalidateQueue.php).

Next.js already gives us cheap invalidation with lazy, stale-while-revalidate regeneration. So the design question is not "how do we invalidate cheaply" — it's "which pages deserve warming, and how do we express that?"

Open questions

  1. Does tag invalidation propagate to the full-route cache? When a cached function tagged fse-templates is consumed during a page render, does revalidateTag('fse-templates') invalidate the already-prerendered route cache entries for pages that used it, or only the data-cache entry? This determines whether a single tagged ping is sufficient, or whether path revalidations are still needed on top. Currently unverified — needs proving against tipee.ch staging, not assumed.
  2. What is the right warming policy? Candidates: warm nothing (accept a slow first hit); warm a small allowlist (home, key landing pages); warm the top-N by traffic; warm on a trickle in the background rather than in a burst.
  3. Should warming move out of the revalidate endpoint entirely? It is currently an implementation detail of a request whose job is invalidation. A separate, rate-limited warmer would decouple the two and stop a bulk invalidation from turning into thousands of synchronous fetches.
  4. How does this interact with export const revalidate = 3600 (next/src/app/[[...uri]]/page.tsx:19) and with ISR's own stale-while-revalidate behaviour? Some of the warming may be redundant with what ISR already does.
  5. Multi-instance invalidation. On self-hosted deployments, revalidateTag propagates through the filesystem cache in .next/cache, shared by PM2 workers on the same host. Any move to more than one host behind a load balancer requires a shared cache handler. Current deployments are single-host SSH+PM2 or Vercel, so this is not a problem today — but it bounds the design.

Origin

Came out of a design session on regenerating the FSE snapshot when WordPress FSE content changes. That work moves the snapshot from a build artifact to runtime cached data invalidated by a cache tag, and the WordPress-side triggers are specified in superhuit-agency/nextjs-revalidate#30 (with superhuit-agency/nextjs-revalidate#29 and superhuit-agency/nextjs-revalidate#28 as prerequisites).

Question 1 above is a hard acceptance criterion for that work — it is filed here rather than there because the answer belongs to the frontend, and because questions 2–5 outlive the FSE feature.

Measurements for reference

From tipee.ch against admin-staging.tipee.ch (702 KB snapshot, 13 templates, 3 template parts, 1163 blocks):

network (2 GraphQL queries, parallel) = 889–1045 ms
parse + format (formatBlocksJSON)     = 12–31 ms
JSON.parse(702 KB)                    = 1.8–2.7 ms
full build script, cold               = 2.13 s wall

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions