Wallpaperz

Behind the Build

Running Next.js 16 on the Cloudflare Workers Free Plan: What Broke and How We Fixed It

8 min read
Futuristic circuit board city with blue data streams

Wallpaperz is a Next.js 16 app served from Cloudflare Workers through the OpenNext Cloudflare adapter, on the free plan. The free plan gives each request roughly 10ms of CPU time. A fresh Next.js server render of one of our pages costs 100-500ms. So the whole setup comes down to one rule: a visitor's request must never pay for a render. Here is what broke on the way there and what fixed it. The full source is on GitHub.

1. Prerender every page at build time

Every wallpaper page lists its IDs in generateStaticParams, fed by the same single ImageKit list call that builds the sitemaps. Categories, colors, device pages, the blog and the sitemaps are all built ahead of time too. On deploy, OpenNext seeds those pages into an R2 bucket that acts as the incremental cache. A new wallpaper uploaded after a build still renders on demand on its first visit. One render like that at a time is fine; the danger is many at once, which comes up in section 3.

2. Serve cache hits without booting Next.js

Even a cache hit was expensive at first: loading the Next.js server just to return a cached page took about 10ms of CPU, the entire budget. OpenNext's cache interception fixes that by answering cached ISR and SSG responses straight from R2, before Next.js starts:

// open-next.config.ts
export default defineCloudflareConfig({
  incrementalCache: r2IncrementalCache,
  queue: memoryQueue,
  tagCache: d1NextTagCache,
  enableCacheInterception: true,
});

With interception on, the cache lookup itself costs about 1ms, and a whole request typically lands at 3-5ms.

3. Never purge the whole cache

The original publishing flow purged the cache after each upload with revalidateTag and revalidatePath, so new wallpapers showed up straight away. On Workers that is fatal. A tag purge invalidates every prerendered page at once, the next visitors all trigger foreground renders together, and each one blows through the CPU limit. We load-tested it twice with a script that fires the purge and then requests pages the way real visitors would. Both times it cascaded into exceededCpu and error 1102 responses.

The fix is to publish by redeploying instead. The build re-prerenders every page on our own machine, off the Worker, and reseeds R2. It takes two to three minutes, and no visitor ever pays for a render. The purge endpoint now returns purged: false when it detects the Workers deployment and says to redeploy instead.

4. Revalidate long-tail pages daily, not hourly

Pages still refresh in the background through ISR between deploys. But every stale hit triggers a background re-render, and that render runs past the 10ms cap too. Individual wallpaper pages are visited rarely, so they were nearly always stale when someone arrived, which meant hourly revalidation kept tripping exceededCpu. Those pages and the device pages now use revalidate = 86400. List pages stay hourly.

5. Swap the Durable Object queue for the memory queue

OpenNext's default revalidation queue runs on a Durable Object. Ours used around 4,000 to 5,000 seconds of Durable Object duration a day, a large share of the free allowance, just to de-duplicate hourly refreshes. The version we ran could also retry-loop on confirmations it misread as failures (opennextjs-cloudflare#662). The memory queue revalidates directly. The worst case without de-duplication is an occasional duplicate render, and a missed revalidation is simply retried on the next stale hit.

6. The __name bug that broke the theme script

After the move, every page rendered by the Worker threw a ReferenceError from the inline theme script. Wrangler bundles with esbuild's keep_names option, which wraps functions in a __name() helper so they keep their names. next-themes turns its anti-flash function into a string and inlines it as a <script>. In the browser, that inlined copy still calls __name, which doesn't exist there. The fix is one line in wrangler.jsonc:

"keep_names": false

7. Put redirects in the routing layer

We changed wallpaper URLs from /wallpaper/<id> to /wallpaper/<slug>-<id>. Throwing redirect() inside the ISR page did not work: OpenNext cached the result as a 200 response with a client-side redirect, not a real 308. Generating the old-to-new mappings at build time in next.config.js redirects() gives proper permanent redirects. That matters for old Pinterest pins and pages Google had already indexed.

The result

A full-site crawl after these changes (316 requests, plus some deliberate 404s) returned 0 errors and 0 exceededCpu, with CPU time per request at p50 5ms, p90 8ms and p99 80ms. The p99 is the occasional on-demand render, and isolated ones like that finish fine.

Checklist if you're doing the same

  • Prerender everything you can with generateStaticParams.
  • Turn on enableCacheInterception with the R2 incremental cache.
  • Never purge everything at once on the free plan. Publish by rebuilding.
  • Give rarely visited pages a long revalidate.
  • Consider the memory queue if Durable Object duration is adding up.
  • Set "keep_names": false if you inline stringified functions, as next-themes does.
  • Do redirects in next.config.js, not inside ISR pages.

The code is all in the Wallpaperz repository. And if you came for the wallpapers, they're all free to download.

Free HD & 4K wallpapers, zero watermarks

Browse the gallery or generate a custom wallpaper with AI - everything on Wallpaperz is free.

Keep Reading

HomeAI GeneratePacksAbout