Skip to main content
Help Center
General · Admin & Safety

Fixing excessive ISR writes on Vercel

Why bot traffic on our public bookmark pages caused a storm of Vercel ISR writes, how we fixed it (dynamic rendering + edge shape-checks and rate limiting), and how to verify and prevent it.

7 min read · Updated 7/24/2026

On this page

This guide explains a hosting problem we hit on Vercel — excessive ISR writes — what caused it, how we fixed it in the code, and how to keep it from coming back. It's written for whoever runs the Bommel deployment. You don't need to be an expert; we go slowly and explain every term.

Short version: one public page was cached the wrong way for a page that can have unlimited web addresses. Bots hitting random addresses made Vercel save a brand-new cached copy every single time, forever. We changed that page to render fresh each request instead, so there's nothing to save. Bots are now also turned away cheaply at the edge.

What is ISR (in plain words)?

ISR stands for Incremental Static Regeneration. It's a Next.js feature that makes pages fast: instead of building a page from scratch on every visit, Next.js builds it once, saves the finished HTML to a cache, and serves that saved copy to everyone. Every so often (for us it was once an hour) it quietly rebuilds a fresh copy in the background.

Saving that finished copy is called an ISR write. On Vercel, ISR writes are a metered resource — a huge number of them shows up on your usage/billing as "excessive ISR writes."

A normal, healthy page (like the home page) writes at most once per hour, no matter how many people visit. That's fine. The trouble starts when a page can have a huge, open-ended number of web addresses.

What caused it here

Bommel's public bookmark page lives at an address like:

text
/@alice/aB3xk9Q2-my-favourite-recipe

In Next.js this is a dynamic route: /[handle]/[shareId]. The important, easy-to-miss detail is that those two blanks match any two-part address. Nothing in the routing says "only real handles" — the page itself checks whether the bookmark exists and shows a "not found" if it doesn't.

Now combine that with ISR:

  1. A bot or scanner requests some address that doesn't exist — /foo/bar, /@x/wp-login, a random 8-character id, anything.
  2. Because the address is new, Next.js generates a page for it and writes it to the ISR cacheeven when the answer is "not found." A cached 404 is still a write.
  3. The next random address is different, so it writes again. And again.

There is an effectively unlimited number of addresses a bot can invent, so this produced a continuous stream of ISR writes — exactly the "excessive ISR writes" warning on Vercel. It wasn't real traffic; it was automated noise landing on a catch-all page that insisted on caching every miss.

The other two ISR pages — the home page (/) and the internal /test-search playground — were never the problem: they each have a single fixed address, so they write at most once per refresh window.

How we fixed it

Two changes, working together:

1. The bookmark page now renders dynamically (no cache to fill)

We switched /[handle]/[shareId] from ISR to dynamic rendering:

ts
// Before: cached with ISR — every unique URL wrote a cache entry.
export const revalidate = 3600;

// After: rendered per request — nothing is written to the ISR cache.
export const dynamic = "force-dynamic";

With dynamic rendering, a nonexistent address costs a cheap "not found" response with no saved copy. Real bookmark pages are still quick — they do a few database reads, which we now de-duplicate within a request (the page and its social-preview metadata share one lookup instead of two). This matches how the sibling pages (/@handle and /@handle/c/…) already worked.

2. Bots are turned away at the edge, before any work

Every public page lives under /@…. In our edge layer (src/proxy.ts) we now check the address shape before the page even runs:

  • A handle must look like a handle (3–30 characters of lowercase letters, digits, or underscores, starting with a letter).
  • A share id must be exactly 8 URL-safe characters.
  • Anything else under /@… gets a cheap 404 at the edge — no database reads, nothing cached.

Well-formed bookmark requests additionally pass through a per-IP rate limit (120 requests/minute) so a single machine can't rapidly enumerate ids. This uses the same Upstash-backed limiter as our sign-in routes and only activates when Upstash is configured (otherwise it safely does nothing).

Why not just block all bots? Search engines like Google are bots, and we want them to read public pages for SEO. So we reject only malformed addresses and throttle bursts — real visitors and polite crawlers are never affected.

The firewall rule at the perimeter (already applied)

The code fix removes the ISR-write problem completely. To also spare the servers the compute of answering a flood of bot requests, we added a rate-limit rule at the Vercel firewall (WAF) on the /@ path. It's already live in production:

  • Condition: Request Path starts with /@.
  • Action: Rate Limit — 100 requests / 60s per IP (fixed window); when exceeded, Deny for 15 minutes.

It was created with the Vercel CLI (which stages a draft you then publish):

bash
vercel firewall rules add "Rate limit public profiles (/@)" \
  --condition '{"type":"path","op":"pre","value":"/@"}' \
  --action rate_limit --rate-limit-requests 100 --rate-limit-window 60 \
  --rate-limit-keys ip --rate-limit-action deny --duration 15m --yes
vercel firewall publish --yes

You can review or change it in the Vercel dashboard (Project → Firewall) or with vercel firewall rules ls. If Cloudflare ever fronts the site, add an equivalent rule there instead — don't run two hard deny limiters on the same path or you'll double-count.

How to verify it's working

  • Vercel usage: in the Vercel dashboard, open Usage and watch the ISR Writes metric. After deploying the fix it should fall to near-zero (only / and /test-search still write, and only occasionally).
  • The cache header: request a bookmark page and look at the x-nextjs-cache response header. A dynamic page won't report MISS/STALE cache activity the way the old ISR page did.
  • The build output: npm run build should list ƒ /[handle]/[shareId] — the ƒ means Dynamic (server-rendered on demand). If you ever see it marked with a revalidate time again, ISR has crept back in.

How to avoid it in the future

  • Be careful adding export const revalidate = … (or on-demand revalidatePath/revalidateTag) to any route whose address can vary without limit — user handles, ids, slugs, search queries. Those are the routes that turn into write storms under bot traffic.
  • If such a page genuinely benefits from caching, prefer dynamic rendering + a CDN/edge cache (or on-demand revalidation triggered only when content actually changes) over time-based ISR on an open-ended address space.
  • Keep the edge shape-checks in src/proxy.ts in sync with the id/handle formats in src/lib/sharing/handle.ts if those ever change.

That's it — the meter should be quiet again, and the public pages are just as fast for the people who matter.