Eventual invalidation
In the last lesson you wired the read-your-writes path: a user edits an invoice, lands back on the list, and reads their own change on that very render because the actions call updateTag after commit. That primitive is exactly right when someone is sitting on the redirect waiting. This lesson handles the one read in this project where nobody is.
The goal in plain terms: recompute the per-org totals on a background job, and let the fresh number land on the user’s next visit rather than mid-request — because no specific user is waiting on the recompute, blocking a render to refresh it would be paying a cost for nothing.
You confirm it from the inspector. “Run summary task” runs the recompute job against the active org and redirects back to /inspector, and on that redirected render summaryFetchedAt is unchanged — the cache served the stale value. Refresh /inspector once more and summaryFetchedAt advances and the recomputed totals show. That two-step sequence — stable on this render, fresh on the next — is stale-while-revalidate, the eventual half of the invalidation split this chapter has been building toward.
Your mission
Section titled “Your mission”The summary aggregate is the one read in this project that a background job owns, not a user action, and that single fact picks the invalidation primitive. When you fired updateTag from the four actions last lesson, the justification was a specific user sitting on the redirect demanding read-your-writes. There is no such user here. The recompute job runs on its own — on a schedule, or after a batch import — and whoever next opens the dashboard is fine reading a slightly stale total for one render. So the job calls revalidateTag: the framework keeps serving the cached value to the in-flight render and refreshes on the next read. Same decision tree as the previous chapter, opposite branch, and the question that chose the branch is the only one that matters — who is waiting on the result.
You’ll implement the recompute as a plain server-only async function. In a production codebase this would be a Trigger.dev schemaTask with its own code-defined queue and a concurrency limit, so a burst of recomputes can’t stampede the database — the shape you met in Defining and triggering Trigger.dev tasks. That shape is named here, not built: the in-process function stands in for it, so there’s no Trigger.dev account, no tasks.trigger, and no env key to wire. The job carries one piece of that production shape forward regardless — it validates its { orgId } payload against a Zod schema at the boundary, the same Zod-at-the-edge discipline from the Server Actions work, so a misconfigured caller surfaces as a parse error instead of silently recomputing the wrong org. The invalidation tag, as everywhere in this chapter, comes only through the tags.ts helper, never a hand-written string.
There’s a contrast worth noticing as you build, because the API makes it concrete. updateTag is unavailable in this context — call it outside a Server Action and the framework throws — while revalidateTag works in background work. The runtime itself gatekeeps which path a non-interactive recompute is allowed to take, which means the architectural rule isn’t something you have to remember and enforce by discipline; it’s enforced for you. The inspector’s “Force updateTag from a Route Handler” island is the provided proof of that throw — you observe it, you don’t build it.
Out of scope: scheduling the job on a real cron, and any change to the action-side invalidation you wrote last lesson. Everything that path does stays exactly as it is; 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 — the one file in the project still throwing. Replace its throw with the real body: validate the payload, recompute over the active rows, upsert the row, and invalidate the summary tag. Build it against the brief above and the lesson’s test suite, then open the reference once you’ve made your attempt.
Reference solution and walkthrough
The whole job is one short file. It’s short because every line is load-bearing — there’s no glue, just the four decisions the brief asked you to make, in order. Here it is in full, then we’ll walk it.
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; it must never end up in 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. The Zod schema is declared at module scope and parse runs before any recompute, write, or log — a typoed or empty orgId from a misconfigured caller throws here, so the store is never touched with bad input. This is the schemaTask payload contract, standing in.
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 same tenant-scoped read path the queries use, and .active() already excludes 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 goes into the summaries Map — create or replace, 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.
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, placed after revalidateTag returns and sourced as job so it stands apart from the interactive action entries.
The file opens with import 'server-only'. This job reads the store and reaches into the framework’s cache scope; both are server-side by construction, and that import makes the constraint enforced rather than assumed — pull this module into a client component by accident and you get a build error, not a silent leak of server internals into the browser bundle.
The first statement in the body is inputSchema.parse(input), and its placement is the whole point. The schema — z.strictObject({ orgId: z.string().min(1) }) — is declared once at module scope, and parsing happens before a single row is read or written. An empty orgId, a missing one, or an extra unexpected field throws here at the boundary. That matters because the alternative is silent: a caller that fat-fingers the payload would otherwise recompute and overwrite the wrong org’s summary, a bug with no error and no obvious symptom until someone notices the numbers are off. This is the same Zod-at-the-boundary discipline you applied to FormData in Crossing the FormData boundary; a background job earns it for the same reason a public action does — its input comes from somewhere you don’t fully control. The schema here stands in for the schemaTask payload schema a Trigger.dev task would declare.
The recompute reuses the exact read path the queries use rather than touching store.invoices directly:
One line in the recompute looks unusual at a glance and is worth a beat: scopedInvoices(orgId).active().take(Number.MAX_SAFE_INTEGER). The fluent builder is lazy — .active() describes a view but hands you back a query, not an array. .take(n) is its terminal: it’s how you materialize rows out of the builder, and it’s the same call the paginated list uses with a real page size. Here the recompute deliberately wants every active row, not a page, because it’s summing the org’s entire active set — so you pass the largest safe integer as the take. It reads oddly only if you expect a “give me all” method; in this builder, “all” is just a take with no real ceiling.
From there the totals are ordinary: totalCount is the array length, totalAmount is a reduce summing each row’s total. Those two numbers, plus a fresh ISO updatedAt, go into upsertSummaryRow, which writes the org’s single aggregate row into the summaries Map — creating it on the first run, replacing it in place on every run after. That Map is the in-memory analog of an org_invoice_summaries table; upsert is INSERT ... ON CONFLICT DO UPDATE in the DB-backed framing.
Then the invalidation, which is the line this lesson exists for:
Two details on that call are easy to drop. The tag string comes through invoiceTags.summary(orgId) — there is no raw org:...:summary literal anywhere in this file, the same single-source-of-truth rule that lets the read site and this write site agree on what the summary tag means. And revalidateTag takes a second argument, 'max'. In Next.js 16 the single-argument revalidateTag(tag) form is deprecated; every call must pass a cacheLife profile, and 'max' is the sensible default for an invalidation that just wants the entry refreshed on next read. The chapter-end note covers this; the tests assert it.
Last, logCacheInvalidation(summaryTag, 'job') runs after revalidateTag returns, never before — the same ordering discipline the actions used. If the invalidation ever threw, the log would correctly show no entry rather than a row claiming a success that didn’t happen. The source argument is 'job', which is the one thing distinguishing this entry from the action-sourced rows the previous lesson writes: in the inspector’s invalidation-log tail, that label is how you tell a background recompute apart from an interactive edit. The function then returns { orgId, totalCount, totalAmount }, which the inspector’s runSummaryJob action surfaces back to the page.
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 drives it directly against the in-memory store, deriving the expected answer straight off the raw seeded rows so a bug in your query path can’t mask a bug in the recompute. It asserts the recompute returns the org’s active count and total and stays isolated to the payload org, that it upserts exactly one summary row — creating then replacing it with a fresh timestamp — that an empty or missing orgId is rejected at the boundary without touching the store, and that the job fires revalidateTag (not updateTag) against the summary tag with the 'max' profile and logs exactly one job-sourced entry.
Then confirm by hand what the suite can’t see — the cache and render timing in the live app, and the provided 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 are real end-to-end and the four-call decision tree from the previous chapter is now concrete: updateTag from the actions for read-your-writes, where a user sits on the redirect, and revalidateTag from the recompute job for the eventual path, where no one is waiting — each chosen by the same question. The inspector reads every one of those cache-state changes off the fetchedAt strip. The local cache backend that makes this work here becomes the cross-process variant on Vercel deployments in the next chapter, backed by Upstash Redis, so a revalidateTag fired on one instance reaches the caches on every other.