If you want to ship fast then CI/CD is the most important thing for you to consider. But have you ever thought "Does the package manager I use really matter for CI/CD?". This might not be an issue for other languages like Python, but JavaScript does have a few options to choose from: some claiming to be the fastest, others prioritizing developer experience and disk usage, and then there's good old NPM.
In this blog post we'll look into different package managers, examples of how to use each, and how well each performs when it comes to CI/CD pipelines. Of course, with everybody's favorite: Benchmarks.
The package managers we will discuss are PNPM, NPM, Yarn and Bun.
Setup
The application used is the standard NestJS starter app, running on a GitLab CI pipeline. Below are the Dockerfile for each of the package managers with a brief explanation of the flags used and some extra info.
Note: We will not focus on securing the images for now, so the examples don't include any security measures. Distroless is used to minimize the size of the final container image.
NPM
The ci option ensures that package-lock.json is used, preventing any accidental changes to dependency versions. --omit=dev ensures only production dependencies are installed.
FROM node:22.22.1-slim AS base
WORKDIR /app
FROM base AS install
COPY ./package.json ./package-lock.json ./
RUN npm ci
FROM install AS build
COPY . .
RUN npm run build
FROM base AS deps
COPY ./package.json ./package-lock.json ./
RUN npm ci --omit=dev
FROM gcr.io/distroless/nodejs24-debian13 AS prod
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules/
EXPOSE 3000
CMD [ "/app/dist/main.js" ]
PNPM
--frozen-lockfile has the same effect as npm ci — it locks the install to what's in the lockfile. The PNPM global store is placed at /pnpm inside the Docker image, configured via PNPM_HOME.
FROM node:22.22.1-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
FROM base AS install
COPY ./package.json ./pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM install AS build
COPY . .
RUN pnpm run build
FROM base AS deps
COPY ./package.json ./pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
FROM gcr.io/distroless/nodejs24-debian13 AS prod
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules/
EXPOSE 3000
CMD [ "dist/main.js" ]
Yarn
The default Yarn configuration that comes with NestJS uses the old classic version rather than the modern one with Plug'n'Play. To upgrade, run yarn set version stable, then enable PnP with yarn config set nodeLinker pnp — this skips node_modules entirely. Then install with yarn install. For the rest of this post, "Yarn" refers to this latest Plug'n'Play version.
FROM node:22.22.1-slim AS base
WORKDIR /app
RUN corepack enable
FROM base AS build
COPY package.json yarn.lock .yarnrc.yml ./
RUN yarn install --immutable
COPY . .
RUN yarn build
FROM base AS deps
COPY package.json yarn.lock ./
RUN corepack enable \
&& yarn workspaces focus --production
FROM gcr.io/distroless/nodejs24-debian13:debug AS prod
WORKDIR /app
COPY --from=deps /root/.yarn/berry/cache /root/.yarn/berry/cache
COPY --from=deps /app/.pnp.cjs /app/.pnp.loader.mjs ./
COPY --from=deps /app/.yarn ./.yarn
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD [ "--require", "./.pnp.cjs", "dist/main.js" ]
Bun
FROM docker.io/oven/bun:1.3-alpine AS base
WORKDIR /usr/src/app
FROM base AS install
COPY ./bun.lock ./package.json ./
RUN bun install --frozen-lockfile
FROM install AS build
COPY . .
RUN bun run build
FROM docker.io/oven/bun:1.3-alpine AS prod
WORKDIR /app
COPY --from=build /usr/src/app/dist ./dist
COPY --from=install /usr/src/app/node_modules ./node_modules
EXPOSE 3000
ENTRYPOINT [ "bun", "dist/main.js" ]
How Package Managers Work — and Why It Matters for CI
Before jumping to the benchmarks, it's worth understanding what actually differentiates these package managers under the hood, since their design choices directly affect both install speed and image size.
PNPM
PNPM's main goal is solving the disk space problem that node_modules are notorious for. On a typical machine, every project gets its own full copy of every dependency — meaning if you have ten projects all using React, you have ten copies of React on disk.
PNPM solves this by maintaining a single global content-addressable store (pointed to by $PNPM_HOME), where every package file is stored exactly once. Each project's node_modules then contains hard links pointing back to that central store rather than actual copies of the files. To keep the store lean, PNPM also tracks file-level changes between package versions using hashes — only storing the differing files, not a full new copy of the package.
Beyond disk savings, PNPM also addresses a subtle but important issue called phantom packages. Here's the problem: NPM has historically hoisted dependencies — meaning if your dependency express depends on lodash, NPM would move lodash up into the root node_modules folder, making it accidentally importable in your own code. Your project would work even though you never listed lodash in package.json. If express later drops it or changes its version, your code silently breaks.
PNPM avoids this by keeping each package's dependencies isolated under node_modules/.pnpm/package@version/node_modules/, and exposing only your direct dependencies as symlinks at the root of node_modules. This makes the dependency tree accurate and predictable.
What this means for Docker
While PNPM's central store is extremely useful during development — especially in monorepos — it doesn't give you the same benefit inside a Docker container. Each container is its own isolated environment with its own dependencies, so the global store never gets reused across builds the way it would on a developer machine. In practice, PNPM ends up behaving similarly to NPM in a container context.
Bun
Bun takes a fundamentally different approach to performance: it treats package installation as a systems problem rather than a JavaScript problem.
The core of this is Bun being written in Zig, a compiled systems language, rather than JavaScript. This alone removes a significant layer of overhead. But Bun also targets three specific bottlenecks that slow down Node.js-based package managers:
System calls: Node.js uses libuv, a C library, to make OS-level calls for things like reading files and managing threads. Each call involves some compatibility overhead, and Node.js adds further overhead managing its thread pools. Bun calls the OS more directly and with fewer round-trips. You can see this concretely in the strace output below, from Bun's blog:
Benchmark 1: strace -c -f npm install
Time (mean ± σ): 37.245 s ± 2.134 s [User: 8.432 s, System: 4.821 s]
Range (min … max): 34.891 s … 41.203 s 10 runs
System calls: 996,978 total (108,775 errors)
Top syscalls: futex (663,158), write (109,412), epoll_pwait (54,496)
Benchmark 2: strace -c -f bun install
Time (mean ± σ): 5.612 s ± 0.287 s [User: 2.134 s, System: 1.892 s]
Range (min … max): 5.238 s … 6.102 s 10 runs
System calls: 165,743 total (3,131 errors)
Top syscalls: openat(45,348), futex (762), epoll_pwait2 (298)
Nearly 6× fewer system calls.
Manifest parsing: NPM reads package metadata from human-readable formats like JSON and YAML, which need to be parsed on every install. Bun stores this metadata in a binary format that can be loaded directly without parsing.
Decompression: When downloading packages (which are compressed tarballs), most tools decompress on the fly, guessing at buffer sizes and reallocating memory as data streams in. Bun instead downloads the full compressed file first, determines the exact buffer size needed using the gzip format, allocates it once, and then decompresses — eliminating unnecessary memory copies.
Bun also starts DNS lookups while reading package.json rather than waiting until after, trimming latency at the very start of the install process. There are a few more optimizations covered in the references if you want to go deeper.
Yarn
Yarn's Plug'n'Play mode works differently from both NPM and PNPM. Rather than populating a node_modules folder at all, Yarn downloads packages as .zip archives and stores them in a central cache folder (viewable via yarn config get cacheFolder — in our container, this is /root/.yarn/berry/cache).
At runtime, instead of Node.js resolving modules from node_modules, Yarn injects a custom loader (.pnp.cjs) that intercepts require() calls and loads modules directly from the .zip archives. The current install state is saved in .yarn/install-state.gz, and the package cache lives in .yarn/cache.
This design means no node_modules directory is created when you run yarn install — which also means no hoisting, no phantom packages, and significantly less I/O.
Why Yarn performed so well
The architecture above translates directly into install speed. Yarn only needs to download and archive packages, then generate the loader — no creating thousands of symlinks or hard links per file, simpler hoisting logic, no repeated I/O across a deep directory tree, and proper tracking of your project's dependencies. For a fresh Docker build where none of those steps can be cached, that's a meaningful advantage.
Result
| Package Manager | Run 1 (f813c0c5) | Run 2 (ecb90ce2) | Run 3 (0938ee84) | Average Time | Average Size (MiB) |
|---|---|---|---|---|---|
| yarn | 1:20 | 1:23 | 1:14 | 1:19 | 58.26 |
| npm | 1:49 | 2:06 | 1:51 | 1:55 | 57.32 |
| pnpm | 1:55 | 1:54 | 2:12 | 2:00 | 57.32 |
| bun | 2:06 | 2:01 | 2:14 | 2:07 | 72.14 |
Even though I personally expected Bun to win this, it ended up performing on par with NPM — and the extra few MiB don't really justify the switch. So the winner here is Yarn, by a clear margin.
I did some digging trying to explain why Bun underperformed, but couldn't find a satisfying answer. I'd be very interested in hearing your thoughts in the comments, and depending on the discussion, a deeper dive might be worth its own post.

Top comments (0)