Cloudflare Worker Bundle Size Triage

Step-by-step procedure for diagnosing and fixing a SolidStart Cloudflare Workers build that fails the 3 MB bundle limit: chunk inspection, the Nitro image-bundling root cause, the move-out/copy-back fix, and prevention rules.

Prerequisites

Before starting, confirm the following:

  • The project is a SolidStart site using the cloudflare-module preset (the shared client-site stack)
  • Node.js v18+ and npm are available; npx wrangler runs without error
  • You can run a local build (npm run build) in the project directory
  • You know whether the project deploys manually via scripts/deploy.sh or automatically via Cloudflare Workers Builds (Git integration)

Symptoms this runbook covers:

  • wrangler deploy fails with a script-size error at or near the 3 MB Workers free-tier limit
  • A push to main on an auto-deploy project (Workers Builds) silently fails at the Cloudflare build step, and the live site never picks up the change
  • The server bundle grew suddenly after adding images to the repo

Step 1: Measure the Bundle

Build locally and check the server output size before changing anything.

npm run build

# Entry bundle size
ls -lh .output/server/index.mjs

# Total server output, and the largest chunks
du -sh .output/server
du -sk .output/server/chunks/*.mjs | sort -n | tail -10

For the size Cloudflare actually evaluates, do a dry-run deploy:

npx wrangler deploy --dry-run --outdir /tmp/wrangler-dry
du -sh /tmp/wrangler-dry

The deploy scripts in this stack enforce a hard gate of 3145728 bytes (3 MB) on .output/server/index.mjs, which is a conservative proxy for the limit. If any single chunk under .output/server/chunks/ is multiple megabytes, you have found the problem area.

Verify: You have a number. Either the bundle is over (or close to) 3 MB and you continue, or it is small and the failure is something else entirely (check the Cloudflare build logs for the real error before going further).

Step 2: Confirm Base64-Bundled Images in Server Chunks

The usual culprit is image data embedded as base64 inside server-side JavaScript chunks.

# Which chunks contain embedded image data
grep -rl 'data:image/' .output/server/chunks | head

# How big are they
grep -rl 'data:image/' .output/server/chunks | xargs du -h | sort -h

Then measure what is sitting in public/:

find public -type f \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name '*.webp' -o -name '*.svg' \) -exec du -ch {} + | tail -1

Verify: The oversized chunks contain data:image/ payloads, and the raster total in public/ roughly accounts for the bundle bloat. If the chunks are large but contain no image data, the problem is a heavy dependency, not this runbook -- inspect imports instead.

Step 3: Understand the Root Cause

Nitro (SolidStart's server engine) base64-bundles assets it finds in public/ into the server chunks. Small files are harmless, but product photos, brand SVGs, and any multi-MB raster get inlined into .mjs files and blow past the Workers 3 MB limit. The images are still needed as static assets, they just must not pass through the Nitro build.

Step 4: Apply the Move-Out/Copy-Back Fix

The fix pattern, proven in scripts/deploy.sh on the reference sites (/Users/dein/Code/projects/websites/AAFireworks/scripts/deploy.sh and /Users/dein/Code/projects/websites/matts-delicious-meat/scripts/deploy.sh):

  1. Move image directories out of public/ before the build
  2. Run the build (the server bundle stays small)
  3. Copy the images into .output/public/ so they are served as static assets
  4. Restore the images to public/ for local dev
  5. Deploy with wrangler

Minimal version, adapted from the AAFireworks script:

#!/usr/bin/env bash
set -euo pipefail

TMP_IMAGES="$(mktemp -d)"

# 1. Stash image dirs out of public/ before build
if [ -d public/images ]; then mv public/images "$TMP_IMAGES/"; fi
if [ -d public/logos ];  then mv public/logos  "$TMP_IMAGES/"; fi

# 2. Build (server bundle stays small)
npm run build

# 3. Copy into .output/public/ and 4. restore for local dev
mkdir -p .output/public
if [ -d "$TMP_IMAGES/images" ]; then
  cp -r "$TMP_IMAGES/images" .output/public/
  mv "$TMP_IMAGES/images" public/
fi
if [ -d "$TMP_IMAGES/logos" ]; then
  cp -r "$TMP_IMAGES/logos" .output/public/
  mv "$TMP_IMAGES/logos" public/
fi

# Size gate before deploying
BUNDLE_BYTES=$(stat -f%z .output/server/index.mjs 2>/dev/null || stat -c%s .output/server/index.mjs)
LIMIT=$((3 * 1024 * 1024))
echo "Server bundle: $BUNDLE_BYTES bytes (limit $LIMIT)"
if [ "$BUNDLE_BYTES" -gt "$LIMIT" ]; then
  echo "ERROR: bundle exceeds 3 MB limit"; exit 1
fi

# 5. Deploy
npx wrangler deploy

Adjust the directory names to whatever the project actually keeps under public/. If the project already has a scripts/deploy.sh, fix it there rather than creating a second script.

Verify:

ls -lh .output/server/index.mjs
ls .output/public/images | head

The server bundle is under 3 MB and the images exist in .output/public/.

Step 5: Shrink the Images Themselves

The move-out trick keeps the Worker under the limit, but oversized rasters still cost page weight. Convert every raster to WebP before committing.

# Resize then convert (Selected Work thumbnail spec: 1280x815)
sips -z 815 1280 in.png --out t.png
cwebp -q 82 -m 6 -sharp_yuv t.png -o out.webp

# Convert without resizing
cwebp -q 82 -m 6 -sharp_yuv in.png -o out.webp

Delete the original raster after confirming the WebP renders correctly and updating references in the source.

Verify: Re-run the public/ size total from Step 2. Keep the raster total small -- valkyrie-nexus sits around 359 KB across all of public/, which is the kind of number to aim for.

Step 6: Deploy and Verify Live

For manually deployed sites, run the project's scripts/deploy.sh. For valkyrie-nexus, which is connected to Cloudflare Workers Builds, merge to main and the push auto-builds and deploys.

Verify:

# Page responds
curl -s -o /dev/null -w "%{http_code}" https://SITE/

# A moved image is served as a static asset
curl -s -o /dev/null -w "%{http_code}" https://SITE/images/SOME-IMAGE.webp

Both should return 200. On auto-deploy projects, also open the Cloudflare dashboard (Workers & Pages, then the worker, Settings, Builds) and confirm the latest build succeeded. This matters because a failed Workers Build is silent: the push lands on GitHub, nothing deploys, and nothing tells you.

Rollback

This procedure is not destructive, so rollback is simple:

  1. Build broken locally: The move-out script restores images to public/ even on the happy path. If a build aborted midway, check for a leftover temp directory and move the images back into public/ by hand.
  2. Bad deploy went live: Re-deploy the previous commit. Check out the last known-good commit and run scripts/deploy.sh (or revert the merge on main for auto-deploy projects).
  3. Auto-deploy stuck failing: Revert the commit that introduced the oversized asset, push, and confirm the next Workers Build goes green.

Maintenance Notes

  • Convert every raster to WebP before committing, every time. On auto-deploy projects a committed multi-MB raster does not fail loudly, it silently kills the Cloudflare build.
  • Keep the public/ raster total small and check it occasionally with the find ... du one-liner from Step 2.
  • When adding a new image directory under public/, add it to the move-out list in scripts/deploy.sh in the same commit.
  • AAFireworks is the canonical reference for the deploy script; the BBQ site has a more heavily commented variant of the same pattern.
  • Cloudflare env access in this stack goes through nativeEvent.context.cloudflare.env, not process.env -- unrelated to bundle size, but the next thing that breaks if a triage turns into a larger refactor.