Performance Engineering: Optimizing Next.js Bundle Size for Cloudflare Workers
Engineering analysis of Next.js bundle optimization strategies that reduced Cloudflare Worker size from 3.6MB to 2MB, with performance impact analysis and scalability considerations.
#The Problem: Bundle Size Constraints in Edge Computing
When deploying Next.js applications to Cloudflare Workers, bundle size becomes a critical constraint that directly impacts deployment feasibility, cold start performance, and operational costs. After hitting Cloudflare's 3MB limit during a production deployment, I embarked on a systematic optimization effort that reduced our bundle from 3.6MB to 2MB while maintaining feature parity.
This post documents the engineering analysis, optimization strategies, and performance trade-offs involved in edge computing bundle optimization.
- node_modules/next/dist/compiled/@vercel/og/index.edge.js - 796.53 KiB
- .open-next/middleware/handler.mjs - 125.81 KiB
- node_modules/next/dist/compiled/@vercel/og/yoga.wasm - 86.58 KiB
The `handler.mjs` is 14.5 MiB uncompressed, gzipping to **3.6 MiB** — just over the free plan's 3 MiB limit. Three fixes later, the bundle is **2.0 MiB** gzipped, deployed, and running fine.
Total Upload: 8495.58 KiB / gzip: 1996.50 KiB Worker Startup Time: 28 ms Uploaded hoangtaiki (18.91 sec) Deployed hoangtaiki triggers (5.79 sec)
Here is exactly what I did.---## Why is a blog Worker so large?The site is a standard Next.js App Router blog: all post pages are statically generated via `generateStaticParams`, served through OpenNext's `staticAssetsIncrementalCache`. The Worker should just be a thin router that serves pre-rendered HTML.Instead, the Worker bundle contained:- **`codehike` + `shiki`** — syntax highlighting with full TextMate language grammars- **`next-mdx-remote`** — MDX compilation pipeline with `unified`, `acorn`, `vfile`- **`@vercel/og`** — OG image generation including two WASM binaries, pulled in by Next.js even though the site has zero dynamic OG images- **`gray-matter`** — frontmatter parsing, needed only at build timeAll of these are build-time tools. At request time, the Worker only needs to serve the already-compiled HTML. But the webpack bundler doesn't know that — it includes everything that is imported anywhere in the codebase.---## Fix 1 — Tell the bundler which packages are build-time onlyNext.js has a `serverExternalPackages` config that opts packages out of automatic bundling. When OpenNext compiles the Cloudflare Worker, it respects this list and excludes those packages from the Worker script.```typescript// next.config.tsconst nextConfig: NextConfig = {serverExternalPackages: ['next-mdx-remote','codehike','shiki','@shikijs/core','@shikijs/engine-oniguruma','@shikijs/vscode-textmate','rehype-slug','remark-gfm','gray-matter','unified','vfile','acorn','esprima',],// ...}
This is safe because all blog pages are statically generated. None of these packages are ever called by the Worker at request time — the Worker only serves the pre-rendered HTML that was produced at build time.
Note
If any of these packages were used in a dynamic API route or a server component without
generateStaticParams, removing them from the bundle would cause a runtime error. Audit your
imports carefully.
As a bonus, this also silences two direct-eval warnings from acorn (used by next-mdx-remote for MDX compilation) that showed up during the Cloudflare build:
▲ [WARNING] Using direct eval with a bundler is not recommended [direct-eval].open-next/server-functions/default/handler.mjs:241:8▲ [WARNING] Using direct eval with a bundler is not recommended [direct-eval].open-next/server-functions/default/handler.mjs:20601:8
#Fix 2 — Remove the unused @vercel/og WASM
The three @vercel/og files listed in the error account for roughly 2.2 MiB uncompressed:
| File | Size |
|---|---|
resvg.wasm | 1346 KB |
index.edge.js | 797 KB |
yoga.wasm | 87 KB |
These are for Next.js's ImageResponse API used to generate dynamic OG images at the edge. The site has no ImageResponse usage and no opengraph-image.tsx routes — yet Next.js includes these files anyway as part of its App Router edge bundle.
The webpack fix is one alias:
// next.config.tswebpack: (config) => {config.resolve.alias = {...config.resolve.alias,'next/og': false,}return config},
Aliasing a module to false tells webpack to treat it as an empty module. Since nothing in the codebase imports next/og, this has no runtime effect — it just stops the WASM files from being pulled into the bundle.
#Fix 3 — Mark all static pages explicitly
The blog post page (/blog/[slug]) had generateStaticParams but no explicit static export declarations. Next.js still includes the full rendering pipeline in the Worker bundle for pages without these signals.
// src/app/blog/[slug]/page.tsx — beforeexport async function generateStaticParams() { ... }
// src/app/blog/[slug]/page.tsx — afterexport const dynamic = 'force-static'export const revalidate = falseexport const dynamicParams = false // unknown slugs → 404, not a Worker render attemptexport async function generateStaticParams() { ... }
Same treatment for /blog/tags/[tag]/page.tsx which had dynamicParams = false but was missing dynamic and revalidate.
These three exports together tell Next.js — and more importantly, the OpenNext bundler — that this page never needs to run inside the Worker.
#Fix 4 — Silence the Turbopack warning (Next.js 16+)
Next.js 16 enables Turbopack by default for next dev. Adding a webpack config without a corresponding turbopack config triggers an error on startup:
⨯ ERROR: This build is using Turbopack, with a `webpack` config and no `turbopack` config.This may be a mistake.
The webpack function only runs during next build, not during Turbopack-powered dev. Setting an empty turbopack: {} acknowledges this split:
// next.config.tsconst nextConfig: NextConfig = {turbopack: {}, // dev uses Turbopack — no custom config neededwebpack: config => {// only runs during `next build`, not `next dev`config.resolve.alias = {...config.resolve.alias,'next/og': false,}return config},}
#Result
| Metric | Before | After |
|---|---|---|
| Total upload | 17,300 KiB | 8,495 KiB |
| Gzipped bundle | 3,627 KiB | 1,996 KiB |
| Free plan limit | 3,072 KiB | 3,072 KiB |
| Worker startup | — | 28 ms |
The gzipped bundle dropped from 3.6 MiB to 2.0 MiB — a 45% reduction, staying comfortably within the free plan's 3 MiB limit without any code changes to the blog content or rendering logic.
#The full next.config.ts
import type { NextConfig } from 'next'import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare'const nextConfig: NextConfig = {pageExtensions: ['js', 'jsx', 'ts', 'tsx'],serverExternalPackages: ['next-mdx-remote','codehike','shiki','@shikijs/core','@shikijs/engine-oniguruma','@shikijs/vscode-textmate','rehype-slug','remark-gfm','gray-matter','unified','vfile','acorn','esprima',],turbopack: {},webpack: config => {config.resolve.alias = {...config.resolve.alias,'next/og': false,}return config},images: {remotePatterns: [{ protocol: 'https', hostname: 'example.com', pathname: '/**' }],},}initOpenNextCloudflareForDev()export default nextConfig
#Key takeaway
The root problem is that webpack doesn't know which imports are build-time-only. For an SSG blog, the Worker needs almost nothing — just routing logic and static file serving. Every MDX compilation library, syntax highlighter, and frontmatter parser bundled into the Worker is wasted bytes.
serverExternalPackages is the escape hatch. Use it for any package that is only ever called during next build, never at request time. Pair it with dynamic = 'force-static' on every static page, and the Worker stays lean.