Skip to content

adapter-vercel: serverless functions lack tree-shaking (esbuild is already used for edge, but not for serverless) #15675

Description

@jbergeron03-tb

Describe the bug

I intend to submit a PR for this issue if the team agrees on the direction.

adapter-vercel uses two completely different bundling strategies depending on the runtime:

  • Edge functions → bundled with esbuild (bundle: true, tree-shaking, single file output)
  • Serverless functions → traced with @vercel/nft only (no bundling, no tree-shaking, entire files are copied)

This means serverless functions ship significantly more code than necessary. @vercel/nft operates at the file level — if your code imports one function from a module, nft includes the entire file (and all of its file-level dependencies). Dead code within files is never eliminated.

This directly undermines the purpose of the split option. The whole point of split: true is to produce lean, per-route functions with fast cold starts. But since nft can't tree-shake, every split function still drags in the full dependency graph of every file it touches. You're paying the cost of splitting (more functions, more cold starts) without getting the benefit (smaller functions).

Where this happens in the code

In packages/adapter-vercel/index.js, the create_function_bundle function handles serverless:

async function create_function_bundle(builder, entry, dir, config) {
  // ...
  const traced = await nodeFileTrace([entry], { base });
  // ... copies traced files as-is, no bundling step
}

Meanwhile, generate_edge_function in the same file already does full esbuild bundling:

const result = await esbuild.build({
  entryPoints: [`${tmp}/edge.js`],
  outfile: `${outdir}/index.js`,
  bundle: true,
  platform: 'browser',
  format: 'esm',
  // ...
});

The tooling is already there — esbuild is already a dependency of adapter-vercel. It's just not used for the serverless path. And the edge runtime is being deprecated, so the only path forward is serverless — which has no tree-shaking.

Impact

  • Bloated function sizes: serverless functions include dead code that esbuild would eliminate
  • Slower cold starts: more code to load = longer initialization, which is the primary cost in serverless
  • split doesn't deliver on its promise: per-route functions are still bloated because nft can't remove unused exports within files
  • Size limit issues: users are more likely to hit the 250MB function size limit because nft includes far more code than necessary

How other frameworks handle this

No framework on Vercel does true tree-shaking for serverless — they all use @vercel/nft. But Next.js mitigates the problem with optimizePackageImports, which rewrites barrel file imports at compile time before nft traces them. Vercel reports up to 40% faster cold starts from this alone.

SvelteKit's adapter-vercel has no equivalent mitigation. The server entry goes straight to nft with no import optimization, no bundling, nothing.

Historical context

The adapter used to bundle serverless functions with esbuild but this was later replaced with nft — likely because esbuild bundling was breaking edge cases with native addons, dynamic requires, or CJS/ESM interop.

That was a reasonable decision at the time, but the tradeoff is becoming harder to justify as apps grow and cold starts become a bigger concern. At minimum, an opt-in bundling option would let users who don't rely on native addons get the performance they need.

Proposal

Use esbuild to bundle the serverless function entry point (similar to what's already done for edge functions), with platform: 'node' instead of platform: 'browser'. This would produce a tree-shaken bundle before the function is packaged.

// Instead of just nft tracing:
const traced = await nodeFileTrace([entry], { base });

// Bundle first, then package:
await esbuild.build({
  entryPoints: [entry],
  outfile: `${dir}/index.js`,
  bundle: true,
  platform: 'node',
  format: 'esm',
  // handle native addons / binaries as external
});

Considerations:

  • Native addons / binary dependencies (e.g., sharp, prisma): would need to be marked as external, similar to the existing external option for edge functions.
  • Side-effect-dependent modules: esbuild's sideEffects handling should cover most cases, but worth testing.
  • Opt-in vs default: could start as an opt-in option (e.g., adapter({ bundleServerless: true })) before becoming the default.
  • Why not ncc? esbuild is already a dependency and already used for edge in this adapter — no new deps, consistent tooling, faster than webpack.

Reproduction

This is a structural issue in the adapter, not a runtime bug. No specific app is needed to reproduce — it affects every serverless deployment.

To observe:

  1. Build any SvelteKit app with adapter-vercel
  2. Inspect .vercel/output/functions/<name>.func/ — you'll see full unshaken files from node_modules copied in by nft
  3. Compare with what npx esbuild <entry> --bundle --platform=node --format=esm produces for the same entry point

The difference in output size is the dead code that nft ships but esbuild would eliminate.

Logs

No runtime error — this is a build output optimization issue.

System Info

Node: 22.19.0
Yarn: 4.13.0
npm: 11.12.0

Severity

serious, but I can work around it

Additional Information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions