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.

6 min read

#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 time
All 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 only
Next.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.ts
const 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:

FileSize
resvg.wasm1346 KB
index.edge.js797 KB
yoga.wasm87 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:

typescript
// next.config.ts
webpack: (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.

typescript
// src/app/blog/[slug]/page.tsx — before
export async function generateStaticParams() { ... }
typescript
// src/app/blog/[slug]/page.tsx — after
export const dynamic = 'force-static'
export const revalidate = false
export const dynamicParams = false // unknown slugs → 404, not a Worker render attempt
export 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:

typescript
// next.config.ts
const nextConfig: NextConfig = {
turbopack: {}, // dev uses Turbopack — no custom config needed
webpack: config => {
// only runs during `next build`, not `next dev`
config.resolve.alias = {
...config.resolve.alias,
'next/og': false,
}
return config
},
}

#Result

MetricBeforeAfter
Total upload17,300 KiB8,495 KiB
Gzipped bundle3,627 KiB1,996 KiB
Free plan limit3,072 KiB3,072 KiB
Worker startup28 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

typescript
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.

Subscribe to my space 🚀

Stay updated on my Blogs about Automation Test, Swift & iOS, Software Engineering, and book reviews.

100% free. Unsubscribe at any time.