Build Output Exceeds Platform Size Limit

Vercel / Cloudflare Pages all have a size cap — one bloated dependency can blow it.

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

HeavyLight
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.jsnamed 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 depcheck to remove unused deps and rerun the bundle visualizer to revisit the top 5 heaviest deps

Tags: #Build error #Hosting #Troubleshooting