Quiz - Cache decisions as architecture
You’re deciding whether to add 'use cache' to a route. One candidate is the per-user invoices list with ?status=overdue URL filters; another is the app chrome (nav, plan badge) that every user loads on every page. Which is the better caching candidate, and why?
The app chrome — it’s a shared, stable value read by many users many times between writes, which is exactly the high read-to-write ratio that earns a cache.
The filtered invoices list — it does the most database work per request, so caching it saves the most server time.
Neither — authenticated SaaS surfaces should always stay dynamic regardless of read frequency.
An invoice detail read and an invoice list read both live in the same org. At what tag granularity should each one tag itself?
Detail tags the record (invoiceTags.record(orgId, id)); list tags the org-scoped list (invoiceTags.list(orgId)) — each tags at the grain the read actually needs.
Both tag every record they reference, so a single edited row invalidates the most precisely.
Both tag only orgTags.all(orgId), so any org change refreshes everything at once.
This cached function compiles, type-checks, and works in dev with one logged-in user. What goes wrong in production?
export async function listInvoices() { 'use cache'; const { orgId } = await auth(); cacheLife('max'); cacheTag(invoiceTags.list(orgId)); return tenantDb(orgId).select().from(invoices);}The session read inside the cached body bakes the first org’s data into one shared entry with no org in the key — every other org then reads org A’s invoices. A cross-tenant data leak.
auth() throws because request data can never be read anywhere inside a cached function’s call stack.
Nothing — cacheTag(invoiceTags.list(orgId)) scopes the entry per org, so each org gets its own cache key.
'use cache' function’s identity is built from its arguments, not from request-scoped values read inside it. orgId came from auth() inside the body, so it isn’t part of the cache key — the first org to compute the entry has its invoices served to everyone. The fix is to pass orgId in as an argument so it keys the entry. Tags are arguments, not ambient state.You’re picking a cacheLife profile for a cached read. What are the three numbers behind a profile actually describing?
How long the user tolerates slightly-old data — a product question about freshness, not a server-performance knob.
How much server CPU the recompute is allowed to consume before the cache gives up.
How many concurrent readers can share one cached entry before it’s recomputed.
'hours' because it changes rarely; feature flags want 'minutes' because rollouts move fast. And 'seconds' on a hot public page barely lifts the hit rate while paying constant revalidation churn.The single most common misconception in this topic: what does router.refresh() actually do to a cached read whose tag is still valid?
Nothing — it re-runs the route’s server render, but a cached read with a valid tag serves the same value again. Only invalidating the tag expires the read.
It expires every cached read on the current route, forcing all of them to refetch.
It expires only the cached reads whose tags match the current URL path.
router.refresh() re-renders; it does not invalidate. The render is fresh but any cached-with-valid-TTL read inside it serves the same value. This is exactly why the plan-flip success page polls: it’s the webhook’s revalidateTag that expires the read, and router.refresh() only re-renders so the client gets a chance to observe the now-fresh value.The same mutation — a user’s display-name change — can resolve to a different invalidation call depending on context. A Server Action where the user just edited their own name versus a webhook from an external identity provider pushing the change. Which calls, in that order?
updateTag for the action (read-your-writes, the user is watching the redirect), revalidateTag for the webhook (eventual, nobody’s waiting).
updateTag for both — the user’s data changed in both cases, so read-your-writes applies either way.
revalidateTag for both — display names are reference data, so stale-while-revalidate is always correct.
updateTag. The webhook has no watcher, so eventual is correct — and updateTag would throw there anyway, because there’s no in-band redirect to keep the read-your-writes promise. Name the trigger, name who’s watching, pick the corner.Inside a Server Action that edits an invoice and its line items in a db.transaction, then redirects to the detail page, where does the updateTag invalidation belong?
After the transaction commits, before the redirect.
Inside the transaction, right after the row updates, so the cache and the database change atomically.
After the redirect — the redirect’s fresh render is what triggers the cache to refresh.
A Trigger.dev nightly job rebuilds an org’s analytics summary in a separate process from the web server, then needs to invalidate the dashboard’s cached read. Which call, and does cross-process work?
revalidateTag(orgTags.all(orgId), 'max') — imported from next/cache and called directly; the framework routes it through the deployment’s shared cache backend.
updateTag(orgTags.all(orgId)) — the freshest possible read is best for the morning’s first dashboard view.
Neither works from a background process; the job must POST to a Next.js route handler that does the invalidation.
revalidateTag — no user is waiting at 3am, so stale-while-revalidate is exactly right, and updateTag would throw outside a Server Action anyway. The job imports revalidateTag from next/cache and calls it directly; the framework routes the invalidation through the shared cache backend, so a tag expired by a background process is expired for web requests too.Quiz complete
Score by topic