Eventual invalidation
Last lesson you wired the read-your-writes path: a user edits an invoice, lands on the list, and sees their own change on that render because the action calls updateTag after commit.
That primitive fits when someone is on the redirect waiting for the result.
This lesson handles the one read in the project where nobody is.
Here, the per-org totals are recomputed on a background job. No specific user is blocked on that recompute, so blocking a render to refresh the number would pay a cost for nothing. Instead, let the fresh total land on the user’s next visit.
You can watch this from the inspector.
“Run summary task” recomputes against the active org and redirects to /inspector; on that render summaryFetchedAt is unchanged, because the cache still served the stale value.
Refresh /inspector again and summaryFetchedAt advances to show the recomputed totals.
Stable on one render, fresh on the next: that is stale-while-revalidate.
Your mission
Section titled “Your mission”The summary aggregate is the one read in this project owned by a background job rather than a user action, and that fact picks the invalidation primitive.
Last lesson, the four actions called updateTag because a user sat on the redirect demanding read-your-writes.
No user is waiting here: the recompute job runs on its own, and whoever next opens the dashboard can read a slightly stale total for one render.
So the job calls revalidateTag, which serves the cached value to the in-flight render and refreshes on the next read.
You’ll implement the recompute as a plain server-only async function.
In production it would be a Trigger.dev schemaTask with its own queue and concurrency limit, so a burst of recomputes can’t stampede the database, the shape from Defining and triggering Trigger.dev tasks.
That shape is named here, not built: the in-process function stands in for it, with no Trigger.dev account, tasks.trigger, or env key.
One piece carries forward regardless: the job validates its { orgId } payload against a Zod schema at the boundary, so a misconfigured caller surfaces as a parse error instead of silently recomputing the wrong org.
As everywhere in this chapter, the invalidation tag comes only through the tags.ts helper, never a hand-written string.
The runtime enforces this split for you.
Call updateTag outside a Server Action and the framework throws; revalidateTag works in background work.
The inspector’s “Force updateTag from a Route Handler” island is provided proof of that throw, to observe, not build.
Out of scope: scheduling the job on a real cron, and any change to last lesson’s action-side invalidation. This lesson adds only the eventual counterpart.
updatedAt.orgId is rejected at the job boundary, before any recompute, write, or log, rather than recomputing the wrong org.job, distinct from the action entries the previous lesson emits./inspector with summaryFetchedAt unchanged on that render — the stale value is served./inspector after the job advances summaryFetchedAt and shows the recomputed totals.updateTag from a Route Handler” island shows a thrown framework error with a clear message — updateTag is unavailable outside a Server Action.Coding time
Section titled “Coding time”Only src/server/jobs/summary-recompute.ts changes.
Replace its throw with the real body: validate the payload, recompute over the active rows, upsert the aggregate row, and invalidate the summary tag.
Build it against the brief above and the test suite, then open the reference.
Reference solution and walkthrough
The job is one short file — four decisions, in order, no glue. Here it is in full, then the walkthrough.
import 'server-only';
import { revalidateTag } from 'next/cache';import { z } from 'zod';import { logCacheInvalidation } from '@/lib/cache/log';import { invoiceTags } from '@/lib/cache/tags';import { scopedInvoices } from '@/lib/invoices/scoped-query';import { upsertSummaryRow } from '@/server/store';
// The in-process summary-recompute "background job". In the DB-backed framing this// is a Trigger.dev `schemaTask` with a Zod payload schema and its own queue —// named, not built. The only concept it carries is `revalidateTag` from a// non-action context, where `updateTag` would throw. The inspector's "Run// summary task" button invokes it directly.
const inputSchema = z.strictObject({ orgId: z.string().min(1) });
export const recomputeOrgSummary = async (input: { orgId: string;}): Promise<{ orgId: string; totalCount: number; totalAmount: number }> => { const { orgId } = inputSchema.parse(input);
// Recompute count + sum(total) over the active (non-archived, non-deleted) // rows for this org, then upsert the aggregate row. const active = scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER); const totalCount = active.length; const totalAmount = active.reduce((sum, inv) => sum + Number(inv.total), 0); upsertSummaryRow({ orgId, totalCount, totalAmount, updatedAt: new Date().toISOString(), });
// `revalidateTag` (not `updateTag`) because no user is waiting — the eventual, // stale-while-revalidate primitive is correct here. The required `'max'` profile // arg is the second argument (the single-arg form is deprecated). The tag string // comes only through the `tags.ts` helper. const summaryTag = invoiceTags.summary(orgId); revalidateTag(summaryTag, 'max'); // Log AFTER the real invalidation returns so a throwing call never leaves a // log row claiming success; `'job'` distinguishes it from the `action` rows. logCacheInvalidation(summaryTag, 'job');
return { orgId, totalCount, totalAmount };};The server-only guard. This job touches the store and the cache scope, so it must never reach a client bundle. The bare import 'server-only' turns an accidental client import into a build error instead of a runtime leak.
import 'server-only';
import { revalidateTag } from 'next/cache';import { z } from 'zod';import { logCacheInvalidation } from '@/lib/cache/log';import { invoiceTags } from '@/lib/cache/tags';import { scopedInvoices } from '@/lib/invoices/scoped-query';import { upsertSummaryRow } from '@/server/store';
// The in-process summary-recompute "background job". In the DB-backed framing this// is a Trigger.dev `schemaTask` with a Zod payload schema and its own queue —// named, not built. The only concept it carries is `revalidateTag` from a// non-action context, where `updateTag` would throw. The inspector's "Run// summary task" button invokes it directly.
const inputSchema = z.strictObject({ orgId: z.string().min(1) });
export const recomputeOrgSummary = async (input: { orgId: string;}): Promise<{ orgId: string; totalCount: number; totalAmount: number }> => { const { orgId } = inputSchema.parse(input);
// Recompute count + sum(total) over the active (non-archived, non-deleted) // rows for this org, then upsert the aggregate row. const active = scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER); const totalCount = active.length; const totalAmount = active.reduce((sum, inv) => sum + Number(inv.total), 0); upsertSummaryRow({ orgId, totalCount, totalAmount, updatedAt: new Date().toISOString(), });
// `revalidateTag` (not `updateTag`) because no user is waiting — the eventual, // stale-while-revalidate primitive is correct here. The required `'max'` profile // arg is the second argument (the single-arg form is deprecated). The tag string // comes only through the `tags.ts` helper. const summaryTag = invoiceTags.summary(orgId); revalidateTag(summaryTag, 'max'); // Log AFTER the real invalidation returns so a throwing call never leaves a // log row claiming success; `'job'` distinguishes it from the `action` rows. logCacheInvalidation(summaryTag, 'job');
return { orgId, totalCount, totalAmount };};The boundary parse, first thing in the body. parse runs before any recompute, write, or log, so a typoed or empty orgId from a misconfigured caller throws here and the store is never touched with bad input. This stands in for the schemaTask payload contract.
import 'server-only';
import { revalidateTag } from 'next/cache';import { z } from 'zod';import { logCacheInvalidation } from '@/lib/cache/log';import { invoiceTags } from '@/lib/cache/tags';import { scopedInvoices } from '@/lib/invoices/scoped-query';import { upsertSummaryRow } from '@/server/store';
// The in-process summary-recompute "background job". In the DB-backed framing this// is a Trigger.dev `schemaTask` with a Zod payload schema and its own queue —// named, not built. The only concept it carries is `revalidateTag` from a// non-action context, where `updateTag` would throw. The inspector's "Run// summary task" button invokes it directly.
const inputSchema = z.strictObject({ orgId: z.string().min(1) });
export const recomputeOrgSummary = async (input: { orgId: string;}): Promise<{ orgId: string; totalCount: number; totalAmount: number }> => { const { orgId } = inputSchema.parse(input);
// Recompute count + sum(total) over the active (non-archived, non-deleted) // rows for this org, then upsert the aggregate row. const active = scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER); const totalCount = active.length; const totalAmount = active.reduce((sum, inv) => sum + Number(inv.total), 0); upsertSummaryRow({ orgId, totalCount, totalAmount, updatedAt: new Date().toISOString(), });
// `revalidateTag` (not `updateTag`) because no user is waiting — the eventual, // stale-while-revalidate primitive is correct here. The required `'max'` profile // arg is the second argument (the single-arg form is deprecated). The tag string // comes only through the `tags.ts` helper. const summaryTag = invoiceTags.summary(orgId); revalidateTag(summaryTag, 'max'); // Log AFTER the real invalidation returns so a throwing call never leaves a // log row claiming success; `'job'` distinguishes it from the `action` rows. logCacheInvalidation(summaryTag, 'job');
return { orgId, totalCount, totalAmount };};Recompute over the active set. scopedInvoices(orgId).active() is the tenant-scoped read path the queries use, and .active() already drops archived and soft-deleted rows, so the count and sum are correct by construction. .take(Number.MAX_SAFE_INTEGER) is the terminal that materializes every row.
import 'server-only';
import { revalidateTag } from 'next/cache';import { z } from 'zod';import { logCacheInvalidation } from '@/lib/cache/log';import { invoiceTags } from '@/lib/cache/tags';import { scopedInvoices } from '@/lib/invoices/scoped-query';import { upsertSummaryRow } from '@/server/store';
// The in-process summary-recompute "background job". In the DB-backed framing this// is a Trigger.dev `schemaTask` with a Zod payload schema and its own queue —// named, not built. The only concept it carries is `revalidateTag` from a// non-action context, where `updateTag` would throw. The inspector's "Run// summary task" button invokes it directly.
const inputSchema = z.strictObject({ orgId: z.string().min(1) });
export const recomputeOrgSummary = async (input: { orgId: string;}): Promise<{ orgId: string; totalCount: number; totalAmount: number }> => { const { orgId } = inputSchema.parse(input);
// Recompute count + sum(total) over the active (non-archived, non-deleted) // rows for this org, then upsert the aggregate row. const active = scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER); const totalCount = active.length; const totalAmount = active.reduce((sum, inv) => sum + Number(inv.total), 0); upsertSummaryRow({ orgId, totalCount, totalAmount, updatedAt: new Date().toISOString(), });
// `revalidateTag` (not `updateTag`) because no user is waiting — the eventual, // stale-while-revalidate primitive is correct here. The required `'max'` profile // arg is the second argument (the single-arg form is deprecated). The tag string // comes only through the `tags.ts` helper. const summaryTag = invoiceTags.summary(orgId); revalidateTag(summaryTag, 'max'); // Log AFTER the real invalidation returns so a throwing call never leaves a // log row claiming success; `'job'` distinguishes it from the `action` rows. logCacheInvalidation(summaryTag, 'job');
return { orgId, totalCount, totalAmount };};The upsert. One aggregate row per org, created or replaced, stamped with a fresh ISO updatedAt.
import 'server-only';
import { revalidateTag } from 'next/cache';import { z } from 'zod';import { logCacheInvalidation } from '@/lib/cache/log';import { invoiceTags } from '@/lib/cache/tags';import { scopedInvoices } from '@/lib/invoices/scoped-query';import { upsertSummaryRow } from '@/server/store';
// The in-process summary-recompute "background job". In the DB-backed framing this// is a Trigger.dev `schemaTask` with a Zod payload schema and its own queue —// named, not built. The only concept it carries is `revalidateTag` from a// non-action context, where `updateTag` would throw. The inspector's "Run// summary task" button invokes it directly.
const inputSchema = z.strictObject({ orgId: z.string().min(1) });
export const recomputeOrgSummary = async (input: { orgId: string;}): Promise<{ orgId: string; totalCount: number; totalAmount: number }> => { const { orgId } = inputSchema.parse(input);
// Recompute count + sum(total) over the active (non-archived, non-deleted) // rows for this org, then upsert the aggregate row. const active = scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER); const totalCount = active.length; const totalAmount = active.reduce((sum, inv) => sum + Number(inv.total), 0); upsertSummaryRow({ orgId, totalCount, totalAmount, updatedAt: new Date().toISOString(), });
// `revalidateTag` (not `updateTag`) because no user is waiting — the eventual, // stale-while-revalidate primitive is correct here. The required `'max'` profile // arg is the second argument (the single-arg form is deprecated). The tag string // comes only through the `tags.ts` helper. const summaryTag = invoiceTags.summary(orgId); revalidateTag(summaryTag, 'max'); // Log AFTER the real invalidation returns so a throwing call never leaves a // log row claiming success; `'job'` distinguishes it from the `action` rows. logCacheInvalidation(summaryTag, 'job');
return { orgId, totalCount, totalAmount };};The eventual invalidation. revalidateTag, not updateTag, because no user is waiting; the tag comes through the helper; 'max' is the required second argument, since the single-argument form is deprecated.
import 'server-only';
import { revalidateTag } from 'next/cache';import { z } from 'zod';import { logCacheInvalidation } from '@/lib/cache/log';import { invoiceTags } from '@/lib/cache/tags';import { scopedInvoices } from '@/lib/invoices/scoped-query';import { upsertSummaryRow } from '@/server/store';
// The in-process summary-recompute "background job". In the DB-backed framing this// is a Trigger.dev `schemaTask` with a Zod payload schema and its own queue —// named, not built. The only concept it carries is `revalidateTag` from a// non-action context, where `updateTag` would throw. The inspector's "Run// summary task" button invokes it directly.
const inputSchema = z.strictObject({ orgId: z.string().min(1) });
export const recomputeOrgSummary = async (input: { orgId: string;}): Promise<{ orgId: string; totalCount: number; totalAmount: number }> => { const { orgId } = inputSchema.parse(input);
// Recompute count + sum(total) over the active (non-archived, non-deleted) // rows for this org, then upsert the aggregate row. const active = scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER); const totalCount = active.length; const totalAmount = active.reduce((sum, inv) => sum + Number(inv.total), 0); upsertSummaryRow({ orgId, totalCount, totalAmount, updatedAt: new Date().toISOString(), });
// `revalidateTag` (not `updateTag`) because no user is waiting — the eventual, // stale-while-revalidate primitive is correct here. The required `'max'` profile // arg is the second argument (the single-arg form is deprecated). The tag string // comes only through the `tags.ts` helper. const summaryTag = invoiceTags.summary(orgId); revalidateTag(summaryTag, 'max'); // Log AFTER the real invalidation returns so a throwing call never leaves a // log row claiming success; `'job'` distinguishes it from the `action` rows. logCacheInvalidation(summaryTag, 'job');
return { orgId, totalCount, totalAmount };};The log call, after revalidateTag returns and sourced as job so it stands apart from the interactive action entries.
The order is the point.
parse runs before any row is read or written, so a caller that fat-fingers the payload throws at the boundary instead of recomputing and overwriting the wrong org’s summary — a bug with no error and no symptom until the numbers look off.
One line repays a second look: scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER).
The builder is lazy, so .active() hands back a query, not an array, and .take(n) is its terminal — the same call the paginated list uses with a real page size.
The recompute wants the whole active set, not a page, so the take is the largest safe integer.
From there the totals are ordinary: a length and a reduce.
The exact call this job makes — the 'max' profile and why the single-argument form is deprecated.
The Server-Action-only counterpart that throws here — the contrast this lesson is built on.
The profiles 'max' draws from — stale, revalidate, and expire spelled out.
The production shape this in-process function stands in for — a Zod-validated payload at the task boundary.
Moment of truth
Section titled “Moment of truth”Run the lesson’s test suite:
pnpm test:lesson 4Expect every assertion green.
The suite imports your recomputeOrgSummary and runs it directly against the in-memory store, deriving each expected answer from the raw seeded rows so a bug in your query path can’t hide a bug in the recompute.
Then confirm by hand what the suite can’t see: the cache and render timing in the live app, and the force-throw surface.
/inspector shows summaryFetchedAt unchanged on that render — the stale value is served./inspector again: summaryFetchedAt advances and the recomputed totals are visible.job, distinct from the action rows.updateTag from a Route Handler” island shows a thrown framework error with a clear message — updateTag is unavailable outside a Server Action.With this job wired, both invalidation paths run end to end: updateTag from the actions for read-your-writes, and revalidateTag from the recompute job for the eventual path.
The next chapter swaps the local cache backend for a cross-process one on Vercel, backed by Upstash Redis, so a revalidateTag fired on one instance reaches the caches on every other.