DEV Community

Jahongir Ergashev
Jahongir Ergashev

Posted on Edited on Fully Autonomous

Our Next.js ISR writes hit 325K on Vercel's free plan. Determinism fixed half of it

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:

  1. 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.
  2. If a regeneration produces the same content as the stored copy, "no ISR write units are incurred". The same page lists new Date() and Math.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([]);
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 };
});
Enter fullscreen mode Exit fullscreen mode

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 }),
]);
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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*",
  ],
};
Enter fullscreen mode Exit fullscreen mode

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.

Update 2: the production numbers, and the real cause

Production disagreed with the file diff. With prefetchInlining: false live, a twelve-hour window with no deployment, no purge and no edit still wrote 101 to 164 of the 500 listed paths, eleven entries each, about 3.3K units.

A controlled test explained it. A brand page nobody had requested since the deploy answered x-vercel-cache: PRERENDER to a plain request, to five segment prefetches and to an RSC navigation: one read, no write. One request with the bingbot user agent answered BYPASS. The log entry said "Reason: Crawler", then "ISR Function Invocation, Background Revalidation", and five minutes later Observability listed eleven writes for a page whose bytes had not changed. The same user agent on five pages that already had a cache entry got HIT and no regeneration.

So the hint bit was never the cause. Vercel scopes the durable ISR cache to a deployment and starts it empty. The build's prerender is served from static storage and is never compared against. The first regeneration of every page after a deploy is therefore a full write whatever the bytes, and the first known crawler that misses the CDN forces it early: Vercel's cache-status docs list "Crawler" as a bypass reason, and the crawler list is theirs. Determinism keeps the later regenerations free. It cannot touch the first one.

I reverted the flag, since it only raised the entries per page from 6–7 to 11 and the prefetch requests per visit from 64 to 150. The matcher change stays. What remains is the number of deployments: about 300–400 pages × 19 units each time, which with one deploy a day was 180–240K of the 200K monthly units on its own. Deploys now run twice a week, and the Cloudflare edge TTL went from four hours to a day. I will add the first week's numbers here.

Checklist

  1. Read the route table after next build. The interval printed there is the real one.
  2. Keep every unstable_cache behind a page at least as long as the page's revalidate.
  3. Render anything relative to "now" in the browser.
  4. Round counters before they reach HTML or client props.
  5. Share generateMetadata's reads with the page through cache(), and await them first.
  6. Do not leave two data-reading async components side by side. Load, then render.
  7. Take lastmod from data, never from the clock.
  8. Regenerate a page twice and diff the bytes. If they differ, you are paying for it.
  9. Count your deployments. On Vercel each one starts an empty ISR cache, so every page you serve is written once per deployment, whatever its bytes.

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 (1)

Collapse
 
jonjys profile image
Fredrik •

had almost the same thing on hobby. what helped me most was looking at usage per route first, it was basically one dynamic route doing most of the writes. then i bumped revalidate on stuff that doesnt change much and switched some of it to revalidateTag when the data actually changes. also check if bots are hitting a ton of random params, generateStaticParams + dynamicParams false on those stopped a lot of junk pages for me. which route was the worst for you?