Skip to content

esbuild bundler produces non-deterministic zip entry order → unstable function checksums defeat upload dedup #7086

@lucasgarsha

Description

@lucasgarsha

Package: @netlify/zip-it-and-ship-it (packages/zip-it-and-ship-it)

Note: this was originally found against the standalone netlify/zip-it-and-ship-it repo, which is now archived and points here. The code is unchanged after the move, so this is filed against the monorepo.

Affected: verified in @netlify/zip-it-and-ship-it 9.40.2 and 10.1.1, and still present on netlify/build@main (currently 14.7.0). Seen via @netlify/build on Netlify deploys with node_bundler = "esbuild".

Summary: Netlify content-addresses function uploads, so an unchanged commit should produce identical bundle hashes and skip upload. With the esbuild bundler it doesn't: the bundled file content is identical between builds, but the order entries are written into the zip varies, so the zip sha256 changes every build and all functions re-upload on every deploy.

Impact: Every deploy (including no-op redeploys) re-uploads the entire function set — dominating deploy time/bandwidth on large sites. Measured on a ~239-function Next.js site: an identical redeploy uploaded all ~239 functions; forcing deterministic entry order dropped it to 1 (only ___netlify-server-handler, which legitimately changes via Next's BUILD_ID).

Root cause: packages/zip-it-and-ship-it/src/runtimes/node/bundlers/esbuild/index.ts returns srcFiles: [...supportingSrcFiles, ...bundlePaths.keys()] without sorting. supportingSrcFiles comes from getSrcFiles() (esbuild/src_files.ts), whose getSrcFilesForDependencies() builds the list via new Set(dependencies.flat()) over concurrent (Promise.all) filesystem traversal — so the order isn't stable across runs/environments. The zip writer (utils/zip.ts, createZipArchive) appends entries in srcFiles order; it even comments that the add loop is kept synchronous "so that the archive's checksum is deterministic" — but it still relies on the incoming srcFiles already being in a deterministic order.

The zisi and nft bundlers both .sort(), with comments explaining why:

  • zisi/src_files.ts: // We sort so that the archive's checksum is deterministic.
  • nft/index.ts: // Sorting the array to make the checksum deterministic.

The esbuild bundler omits this sort.

Reproduction: Call zipFunction twice on the same fixture with { config: { '*': { nodeBundler: 'esbuild', externalNodeModules: ['<dep>'] } }, archiveFormat: 'zip' }. The bundled file content is identical, but the order in which entries are written into the zip is whatever the dependency traversal produced — which is not stable across build environments (different machine, cache, Node/esbuild version), so the zip sha256 differs. nodeBundler: 'nft' is stable because it sorts.

(Note: two consecutive bundles in a single warm process can happen to be byte-identical because the filesystem traversal order is incidentally stable in-process; the non-determinism shows up across build environments. A regression test therefore needs to exercise order-independence explicitly rather than just bundling twice in one process.)

Fix: sort the file list in the esbuild bundler (one line), matching zisi/nft. PR incoming.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions