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', give the cached result a short revalidation window, and the next thousand requests for the same SKU and size answer themselves from a regional cache 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' so repeat requests for the same SKU and size are served from the Runtime Cache.
Hands-on exercise 3.2
Two file changes. The cache directive on the function, and a single config flag to enable cache components.
Requirements:
- Open
next.config.tsand addcacheComponents: trueat the top of the config object. The'use cache'directive requires this flag in Next.js 16. - Open
app/api/stock-check/route.ts - Import
unstable_cacheTagandunstable_cacheLifefromnext/cache, aliasing them tocacheTagandcacheLife - Add
'use cache'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'directive is function-scoped. Putting it at the top offetchStockFromWarehousecaches that function's results keyed by its arguments. The cache is regional, each Vercel region maintains its own. 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/api/stock-check/route.tsimportsunstable_cacheTagandunstable_cacheLifefetchStockFromWarehousebegins with'use cache'- 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
TypeScript complains that 'use cache' 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.
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 on the complete branch if you want to check your version against it.
The updated function:
import { NextResponse } from "next/server";
import {
unstable_cacheTag as cacheTag,
unstable_cacheLife as 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";
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 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?