Deploy logs spit back Error: The Serverless Function exceeds the maximum size limit of 50 MB (Vercel), Functions size exceeds maximum allowed, or Pages output exceeds 25 MiB per file / 20,000 files total (Cloudflare Pages). These are hard host limits — retrying won’t help. Each platform has its own ceiling: Vercel Serverless 50 MB per function, Cloudflare Workers 1 MB (Free) / 10 MB (Paid), Cloudflare Pages 25 MiB per file, Netlify Functions 50 MB. This article gives a “find the offender, cut it, gate it in CI” fix path.
Common causes
Ordered by hit rate.
1. One dependency bundles huge
puppeteer (ships Chromium ~170 MB), canvas, sharp, @ffmpeg/core, tensorflow.js, playwright — native / browser-engine deps are the usual culprits. Looks like you only imported one function; in reality the entire binary ends up in the bundle.
Typical error:
Error: The Serverless Function "api/screenshot" is 187 MB which exceeds the maximum size limit of 50 MB.
How to spot it: du -sh node_modules/* | sort -h | tail -20 and compare the top 20 against what shipped.
2. Static assets not compressed or duplicated
Unoptimized images (4 MB JPGs), entire font families with all weights and all character sets (hundreds of KB per woff2), or the same image referenced from both public/ and src/assets/ ending up twice in the final bundle.
How to spot it: ls -lhS dist/ | head and eyeball anything over 500 KB that probably shouldn’t be.
3. tsconfig doesn’t exclude tests / examples / dev tooling
tsconfig.json is missing exclude, so the build compiles *.test.ts, __fixtures__/, storybook/, examples/ into the server bundle. Symptom: business code is small but the artifact is hundreds of MB.
How to spot it: After build, find dist -name "*.test.*" -o -name "*fixture*". Any hit means you missed something.
4. Server vs client bundle boundary is leaky
You imported a server-only dep (pdf-parse, @aws-sdk/client-s3) into a client component, and the bundler shipped it to the browser. Or the reverse: markdown / template strings that should live server-side get inlined into every page.
How to spot it: Run the bundle analyzer and look for server-only package names in client chunks.
5. Source maps leaked into the production artifact
vite.config.ts / next.config.js defaults often emit source maps in production too. Each chunk gets a .map file of roughly equal size, doubling the artifact.
How to spot it: ls dist/assets/*.map | wc -l > 0 and the total .map size is close to the total code size.
Shortest path to fix
Step 1: Find what’s biggest
Start with physical size:
npm run build
du -sh dist/
du -sh dist/* | sort -h | tail -10
Then run a bundle visualizer to see which dep dominates each chunk:
# Vite / Astro
npx vite-bundle-visualizer
# Next.js
ANALYZE=true npm run build
# Requires @next/bundle-analyzer wired into next.config.js
# Webpack
npx webpack-bundle-analyzer dist/stats.json
Open the HTML report and pick the 1-3 biggest blocks. Those are what you’re going to cut.
Step 2: Swap heavy deps or push them server-only
| Heavy | Light |
|---|---|
moment (~290 KB) | date-fns (~13 KB tree-shaken) or dayjs (~7 KB) |
lodash (~70 KB) | lodash-es with named imports, or native ES methods |
axios (~30 KB) | native fetch |
puppeteer | @sparticuz/chromium + puppeteer-core (serverless-friendly) |
full chart.js | named import of just the modules you use |
If the dep is heavy and required (sharp, puppeteer), lift it out of the serverless function: move to a dedicated worker, an external service (Browserless, Cloudinary), or do the work at build time.
Step 3: Tighten tsconfig and build include / exclude
// tsconfig.json
{
"compilerOptions": { "...": "..." },
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/__tests__/**",
"**/__fixtures__/**",
"examples/**",
"storybook/**"
]
}
Vercel also lets you exclude precisely in vercel.json:
{
"functions": {
"api/**/*.ts": {
"includeFiles": "lib/**",
"excludeFiles": "{tests,fixtures}/**"
}
}
}
Step 4: Compress static assets and disable source maps
Images:
# Compress all PNG / JPG under public/
npx @squoosh/cli --mozjpeg auto public/**/*.{jpg,jpeg}
npx @squoosh/cli --oxipng auto public/**/*.png
# Or convert to WebP / AVIF
npx @squoosh/cli --webp auto public/**/*.{jpg,png}
Kill production source maps:
// vite.config.ts
export default defineConfig({
build: {
sourcemap: false, // or 'hidden' to upload to Sentry without shipping publicly
},
});
Step 5: Gate bundle size in CI
# .github/workflows/bundle-size.yml
name: Bundle Size
on: [pull_request]
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci && npm run build
- name: Check size
run: |
SIZE=$(du -sm dist | cut -f1)
echo "dist size: ${SIZE} MB"
if [ $SIZE -gt 40 ]; then
echo "Bundle exceeds 40 MB threshold"
exit 1
fi
Set the threshold 20% below the platform’s hard limit so you have headroom for future growth.
Prevention
- Before adding any new dep, check minified + gzipped size on bundlephobia.com; anything > 50 KB needs a justification or a lighter alternative
- Add a bundle-size CI check pinned at 70-80% of the platform’s hard limit
- Separate server-only and client-only deps clearly; never import server packages from a client component
- Push large binaries (PDFs, videos, model weights) to object storage and fetch at runtime instead of bundling
- Quarterly: run
npx depcheckto remove unused deps and rerun the bundle visualizer to revisit the top 5 heaviest deps