Runtime Cache
It's drop day. Ten thousand sneakerheads have your stock-check endpoint queued in browser tabs, fingers on the refresh button. The Foundry just went live in size 10 with 47 pairs available. Your function runs 10,000 times, calls the warehouse 10,000 times, and the warehouse says the same thing 10,000 times: yes, 47 of size 10. Then 46. Then 45.
You don't need to ask the warehouse 10,000 times for the same answer. You need to ask it once and remember the result for a few seconds. That's what the Runtime Cache does. Wrap the slow call in 'use cache: remote', give the cached result a short revalidation window, and the next thousand requests for the same SKU and size answer themselves from a durable cache shared across server instances instead of invoking the warehouse path.
The function still runs. It just stops doing the expensive thing on every call.
Outcome
fetchStockFromWarehouse in app/api/stock-check/route.ts is wrapped with 'use cache: remote' so repeat requests for the same SKU and size are served from the Runtime Cache.
Hands-on exercise 3.2
Four file changes. Remove the legacy route-segment revalidation setting, enable Cache Components, move the home page's date-dependent banner out of the prerender, and add the remote cache directive to the function.
Requirements:
- Open
app/drops/page.tsxand deleteexport const revalidate = 60. Route Segment Config options such asrevalidateare disabled when Cache Components is enabled; Lesson 3.3 replaces this timer with tag-based revalidation. - Open
next.config.tsand addcacheComponents: trueat the top of the config object. The'use cache: remote'directive requires this flag in Next.js 16. - Open
app/page.tsx. The NEXT DROP banner callsgetNextDrop(), which reads today's date, and Cache Components won't prerender a page that does. Move the banner into an asyncNextDropcomponent that starts withawait connection()(imported fromnext/server), and render it inside<Suspense>. - Open
app/api/stock-check/route.ts - Import
cacheTagandcacheLifefromnext/cache - Add
'use cache: remote'as the first line insidefetchStockFromWarehouse - Tag the cached result with
cacheTag(`stock-${sku}`)so we can invalidate by SKU later - Set the cache lifetime with
cacheLife({ revalidate: 60, expire: 300 })(refresh after a minute, hard expire after five) - Commit, push, redeploy
Implementation hints:
- The
'use cache: remote'directive is function-scoped. Putting it at the top offetchStockFromWarehousecaches that function's results keyed by its arguments in shared storage. Use plain'use cache'for in-memory or static-shell work; the remote directive is the fit here because multiple server instances need to shield the same slow backend. connection()tells Next.js the banner depends on the incoming request, so it renders at request time while the rest of the home page stays prerendered. The banner is the only part of the page that changes from day to day, so it's the only part that waits.- Enabling Cache Components disables legacy Route Segment Config options. Removing the drops page's
revalidateexport before you deploy avoids mixing the old and new caching models; Lesson 3.3 finishes the replacement with a tag. revalidate: 60doesn't mean the cache is purged every 60 seconds. It means: on the first request after 60 seconds, serve the stale value and refresh in the background. The user sees an instant response.expire: 300is the hard ceiling. After 5 minutes the cached value is gone and the next request waits for a fresh warehouse call.- The tag (
stock-${sku}) is your invalidation handle. From anywhere in the app you can callrevalidateTag('stock-sat-foundry-onyx')to dump the cached entry for that one SKU. That's how a CMS webhook would tell the site about new inventory.
Try It
After the redeploy lands, send the same stock-check request twice in a row:
time curl -s -X POST https://YOUR_DOMAIN/api/stock-check \
-H "Content-Type: application/json" \
-d '{"sku":"sat-foundry-onyx","size":10}'
time curl -s -X POST https://YOUR_DOMAIN/api/stock-check \
-H "Content-Type: application/json" \
-d '{"sku":"sat-foundry-onyx","size":10}'The first call should look like this:
{ "available": true, "count": 5 }
real 0m0.847s
The second call should look like this:
{ "available": true, "count": 5 }
real 0m0.082s
The first request fills the cache. The second hits the cache and skips the 800ms warehouse simulation. Same response body. Tenth of the time.
Now try a different size:
time curl -s -X POST https://YOUR_DOMAIN/api/stock-check \
-H "Content-Type: application/json" \
-d '{"sku":"sat-foundry-onyx","size":11}'That's a fresh argument tuple, so it's a fresh cache key, so it's a slow first call:
{ "available": true, "count": 11 }
real 0m0.831s
The size-10 cache stays warm. The size-11 cache is now also warm. This is how caching by arguments works in practice.
Done-When
next.config.tshascacheComponents: trueapp/drops/page.tsxno longer exportsrevalidate- The home page's NEXT DROP banner renders from a
NextDropcomponent inside<Suspense>, and the deploy builds app/api/stock-check/route.tsimportscacheTagandcacheLifefetchStockFromWarehousebegins with'use cache: remote'- Second curl request to the same SKU/size returns in under 200ms
- Observability > Functions shows fewer invocations of
/api/stock-checkrunning the slow path during repeat traffic
Troubleshooting
The build fails with "Next.js encountered the unstable value new Date() while prerendering."
This is expected if Cache Components is on and the home page still calls getNextDrop() at the top of HomePage. Next.js tries to prerender the whole page, finds a value that depends on when the request happens, and stops rather than freeze yesterday's date into the HTML. Move the banner into the NextDrop component from step 3 and the build goes through.
TypeScript complains that 'use cache: remote' isn't a valid directive.
You're missing the cacheComponents: true flag in next.config.ts, or your Next.js version is below 16. Check package.json and confirm next is ^16.0.0. If it's older, upgrade. If the build points at export const revalidate, remove that legacy export from app/drops/page.tsx as required above.
The second request still takes 800ms.
A few possibilities. The deployment might not have rebuilt yet, check the deployment timestamp. The cache might be partitioned by region, and your second request might have hit a different one, try several requests in a row from the same location. Or your tag and lifetime might not have applied because of a syntax error above them; check the build logs for warnings.
The cache returns stale data for too long.
The combination of revalidate: 60 and expire: 300 means stale data can be served for up to 5 minutes. If that's too long for inventory, lower expire. If you want truly fresh data on every request, you don't want caching for this route at all, and that's a fair call for some endpoints. The win here only exists because stock counts changing every 60 seconds is acceptable for a small indie brand.
Solution
The finished app/api/stock-check/route.ts is in the complete folder if you want to check your version against it.
The updated function:
import { NextResponse } from "next/server";
import { cacheTag, cacheLife } from "next/cache";
import { getProductById } from "@/app/data/products";
import type {
StockCheckRequest,
StockCheckResponse,
} from "@/app/lib/types";
async function fetchStockFromWarehouse(
sku: string,
size: number
): Promise<StockCheckResponse> {
"use cache: remote";
cacheTag(`stock-${sku}`);
cacheLife({ revalidate: 60, expire: 300 });
await new Promise((resolve) => setTimeout(resolve, 800));
const product = getProductById(sku);
if (!product || !product.sizes.includes(size)) {
return { available: false, count: 0 };
}
const deterministicCount = ((sku.length * size * 7) % 12) + 1;
return { available: true, count: deterministicCount };
}
export async function POST(request: Request) {
const body = (await request.json()) as Partial<StockCheckRequest>;
if (!body.sku || typeof body.size !== "number") {
return NextResponse.json(
{ error: "sku and size are required" },
{ status: 400 }
);
}
const result = await fetchStockFromWarehouse(body.sku, body.size);
return NextResponse.json(result);
}And the config flag:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;The home page banner, moved behind <Suspense> (the finished app/page.tsx has the full markup):
import { Suspense } from "react";
import { connection } from "next/server";
import { getNextDrop } from "@/app/data/drops";
import { getProductBySlug } from "@/app/data/products";
// ...other imports unchanged
export default function HomePage() {
// ...liveProducts unchanged
return (
<div>
{/* ...hero section unchanged */}
<Suspense fallback={null}>
<NextDrop />
</Suspense>
{/* ...IN STOCK section unchanged */}
</div>
);
}
// The next drop depends on today's date, so it renders at request time instead of at build time.
async function NextDrop() {
await connection();
const nextDrop = getNextDrop();
const nextProduct = nextDrop ? getProductBySlug(nextDrop.productSlug) : null;
if (!nextProduct || !nextDrop) return null;
return (
<section className="border-y border-line bg-white">
{/* ...the NEXT DROP markup that used to sit in HomePage */}
</section>
);
}Also remove export const revalidate = 60 from app/drops/page.tsx before enabling Cache Components. The next lesson adds the replacement cache tag and webhook invalidation.
The cache shields the warehouse, the firewall shields the function. Together they handle Saturday's drop-day traffic shape, many requests, few unique answers.
Up next: the drops page itself is on a timer. Let's fix that.
Was this helpful?