I run promokoduz.uz, a promo-code catalogue for Uzbekistan in three languages. It is a small site: 111 live codes across 75 brands and about 60 visits a day. It runs on Next.js 16 (App Router) on Vercel's Hobby plan, the same stack as my two other projects, promobozor.uz and taklifnomam.uz.
In mid-September the usage page showed this for the previous 30 days:
- ISR Writes: 325K of the 200K included
- Fluid Active CPU: 4h 28m of 4h
Sixty visits a day should not write that much. This post is what I found, what I changed, and where the number stands now. The short version: most of the writes came from pages that rendered differently although their data had not changed. Fixing that cut the daily writes by more than half. It did not bring the site under the limit.
What an ISR write is
Vercel's ISR pricing page has two facts that matter here:
- Writes are measured in 8 KB units. A write is not "one page", it is the page's size divided by 8 KB. On a local production build my homepage is 450 KB of HTML plus RSC payload, so one write of it costs 57 units. A brand page is 187 KB, 24 units.
- If a regeneration produces the same content as the stored copy, "no ISR write units are incurred". The same page lists
new Date()andMath.random()as the first things to check.
So the question is not "how often do pages regenerate" but "why does a page with unchanged data produce different bytes".
Where I looked
Observability → ISR on the Hobby plan shows the last 12 hours. Mine had 490 time-based revalidations and 0 tag-based ones. The top writers were the three homepages: 1K units for /uz, 727 for /en, 616 for /ru, in half a day.
Cause 1: the pages regenerated every five minutes
The pages declared export const revalidate = 1800. They also read data through unstable_cache(..., { revalidate: 300 }). A page regenerates on the shortest window among its own and every data cache it reads, so the real window was five minutes. The route table that next build prints said so all along: 5m next to the homepage.
Now every public page and every data cache behind one has the same window, a day. Freshness comes from on-demand purges: admin writes, a daily cron that expires offers, and deploys. A route file cannot import the constant, because Next.js reads revalidate statically, so a test keeps the literals in step:
it("never gives a data cache behind a public page a shorter window than the page", () => {
const shorter = ["app/[locale]/(user)", "components", "lib"]
.flatMap((dir) => sourceFiles(join(root, dir)))
.flatMap((file) =>
[...readFileSync(file, "utf8").matchAll(/\brevalidate:\s*(\d[\d_]*)/g)].map((match) => ({
file: relative(root, file),
seconds: Number(match[1].replace(/_/g, "")),
}))
)
.filter(({ seconds }) => seconds < PUBLIC_REVALIDATE_SECONDS);
expect(shorter).toEqual([]);
});
Cause 2: time in the output
The homepage rendered <time dateTime={new Date().toISOString()}>. Every offer card rendered a countdown with minute precision. Both change on every render, so every regeneration was a write.
Anything relative to "now" is rendered in the browser instead, through one hook that is null on the server and during hydration:
export function useNow(): Date | null {
const minute = useSyncExternalStore<number | null>(subscribe, currentMinute, serverMinute);
return minute === null ? null : new Date(minute * MINUTE_MS);
}
serverMinute returns null, and subscribe ticks on the minute boundary. A server component may still decide whether an offer has expired, because that flips once. It never renders how long is left.
One surprise on the way: Chrome ships without Uzbek month names. toLocaleDateString("uz-UZ") prints "16-sentabr, 2026" in Node and "2026 M09 16" in the browser, so a date that moves to the client has to be spelled by hand in that language.
Cause 3: live counters
Each card showed its exact number of views and copies. Those change with every visit. They were in the HTML, and also in the props of client components, which are serialised into the RSC payload.
Counters are now rounded down to their leading digit before they reach a page or a prop:
export function approximateCount(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0;
const whole = Math.floor(value);
if (whole < 10) return whole;
const magnitude = 10 ** (String(whole).length - 1);
return Math.floor(whole / magnitude) * magnitude;
}
637 becomes 600 and is shown as "600+", which stays true until the counter reaches 700. The output changes only when a counter crosses a step.
Cause 4: the same rows in a different order
This one took longest to see. Two regenerations of a brand page with identical data came out with the same size and different bytes: the rows of the RSC payload were reordered and renumbered.
There were two sources.
Metadata. In the Next.js version I run (16.3), metadata is streamed when a page is regenerated, so the rows from generateMetadata land wherever its database reads happen to finish relative to the page's own. The fix is a React cache() loader that both share, and that the page body awaits before any of its own queries:
const getBrandMetadataInputs = cache(async (locale: Locale, slug: string) => {
const brandData = await findBrand(locale, slug);
if (!brandData) return null;
const counts = await getCachedBrandPromocodeCounts(brandData.brand.id);
// ...
return { brandData, totalPromocodes: counts?.total ?? 0, allTranslations };
});
Sibling async components. The homepage had three sections, each an async server component fetching its own data. React writes a pending component's output when its reads finish, so the sections came out in whichever order the database answered. Now the page loads all the data first, then awaits the section components itself and places the finished elements in the tree:
const sections = await loadHomeSections(locale);
const [featuredSection, newSection, popularSection] = await Promise.all([
FeaturedPromocodes({ locale, promocodes: sections.featured }),
NewPromocodes({ locale, promocodes: sections.newPromocodes }),
PopularStoresCategories({ locale, data: sections.popular }),
]);
Rendered three times with three different finishing orders, the old page gave three different payloads and the new one gives one.
Cause 5: a sitemap that printed the render time
In the sitemap, the homepage, the listings and the static pages had lastModified: new Date(). Sitemaps regenerate hourly and each one is about 20 units. lastmod now comes only from rows, and pages that change only with a deploy carry none. The test renders the sitemap twice, an hour apart:
it("should render the same sitemap an hour later when no row changed", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-17T08:00:00.000Z"));
mockSelects(rowsAt(EDITED_UZ, EDITED_RU, STALE));
const first = await sitemap({ id: Promise.resolve("uz") });
vi.setSystemTime(new Date("2026-09-17T09:00:00.000Z"));
mockSelects(rowsAt(EDITED_UZ, EDITED_RU, STALE));
const second = await sitemap({ id: Promise.resolve("uz") });
expect(second).toEqual(first);
});
How I checked it
A local production build against a database, then: request a page, force a regeneration, compare the stored files. After the changes the homepage, the listings and the sitemap regenerate byte for byte. One difference was left, between the build's prerendered copy and the first regeneration after a deploy. I first put it down to a build-id comment. That was wrong, and it turned out to be the other half of the bill: see the update below.
The CPU half
proxy.ts (Next 16's middleware) runs as a function on every request that reaches Vercel, and it accounted for 1h 30m of the 4h 28m. It imported one helper from a module that also imported the database client, so the whole Postgres stack rode along in its bundle. The helper moved to a file with no such import, a test walks the proxy's import graph, and paths the proxy has nothing to do for (sitemaps) are excluded in the matcher rather than by an early return, which would still cost the invocation.
Where it stands
| mid-September | 10 October | |
|---|---|---|
| Time-based revalidations, 12 h | 490 | 62 |
| ISR write units per day | about 23K | about 10K |
| Fluid Active CPU, 30 days | 4h 28m | 3h 45m |
| ISR writes, 30 days | 325K | 307K |
The daily figure for September is from four days before the fixes (19.5K to 27K). The October one is 69K units over 3–10 October. The 30-day total still includes a week from before the fixes, but that is not the whole story: 10K a day is 300K a month. The site is still over the included 200K.
What I know about the remainder:
- Deploys. Every deploy stores each page again once (the update below explains why). I already cut deploys to one a day, for a different limit (deployment storage).
- Real edits. Adding one offer changes its brand page, its category page, the listings and the homepage, in three locales. Those writes are legitimate. Purges now name exact URLs and narrow tags instead of whole route families, so an edit regenerates only the pages that show the changed row.
-
Bytes per write. This is the multiplier I have not reduced. I tried
experimental.inlineCss: first contentful paint improved by 890 ms in a real browser, but the stylesheet is embedded three times in the HTML and twice in the RSC payload, so the homepage grew from 450 KB to 898 KB and a brand page from 187 KB to 636 KB. I did not ship it.
When I first published this I had not attributed the remaining 10K a day. A few hours later I had.
Update: the other half of the bill
I went through Observability → ISR → Paths for the last twelve hours. Of the 500 paths requested, 277 had been written, and 56% of those writes were on pages the day's edit could not have changed: other categories, their brand pages and offers, 30 blog posts.
Two facts explain it.
A page is not one cache entry. Next 16 stores the HTML, the RSC payload and four .segments/ files for every page: the route tree, the full payload again, the layout segment and the page segment. One regeneration is six writes, about 17 units on my pages.
The build's copy never equals a regeneration. Next 16.3 turns experimental.prefetchInlining on by default. At build time the hints it needs are measured after the prerender, so every segment in the page's route tree is marked InliningHintsStale. A runtime regeneration writes the real hints. In the payload it looks like this:
build: ["__PAGE__",{},"$undefined","$undefined",4608]
regeneration: ["__PAGE__",{},"$undefined","$undefined",4256]
Seventeen bytes differ in the HTML, the RSC payload and the full segment file, with identical data. So after every deploy the first regeneration of every page is a billed write, whatever the page shows. A purge or the one-day timer only decides when each page pays it. With 726 URLs in the sitemap that is about 12K units per deployment.
I checked it on a local production build: purge with no data change, then compare the files on disk.
| inlining on (default) | prefetchInlining: false |
|
|---|---|---|
| Build copy vs first regeneration | differ, hint numbers only | identical, every segment |
| First vs second regeneration | identical | identical |
| Cache entries per page | 6 | 11 |
| Prefetch requests: homepage, scroll, two navigations | 64 | 150 |
The flag has a price. A page becomes eleven entries, so a page whose data really changed costs more. And the browser prefetches each segment separately: 150 requests instead of 64, for the same bytes. On Vercel each of those ran my proxy, so I also took the router's prefetches out of its matcher. They ask for stored files of prerendered pages and need nothing from it (the source pattern is shortened here):
export const config = {
matcher: [
{
source: "/((?!api|_next|_vercel).*)",
missing: [{ type: "header", key: "next-router-prefetch" }],
},
// The admin tree keeps every request: its layout reads a header the proxy sets.
"/:locale(uz|ru|en)/johasuper/:path*",
],
};
On the local build a prefetch now answers with the stored segment file, byte for byte, without running the proxy, while a page visit and a navigation still go through it.
Both changes are written and measured locally. They are not in production yet, so I do not know whether Vercel's comparison agrees with my file diff. I will add the production numbers here when I have them.
Checklist
- Read the route table after
next build. The interval printed there is the real one. - Keep every
unstable_cachebehind a page at least as long as the page'srevalidate. - Render anything relative to "now" in the browser.
- Round counters before they reach HTML or client props.
- Share
generateMetadata's reads with the page throughcache(), and await them first. - Do not leave two data-reading async components side by side. Load, then render.
- Take
lastmodfrom data, never from the clock. - Regenerate a page twice and diff the bytes. If they differ, you are paying for it.
- After a Next.js upgrade, diff a build's prerendered file against its first regeneration too, not only two regenerations against each other.
Disclosure: this post was written with an AI coding assistant (Claude Code), which I also use on the project. It was drafted from the repository's notes, the code and the dashboards.
Top comments (0)