Tools: Picking a Package Manager for Your CI/CD Pipeline

Tools: Picking a Package Manager for Your CI/CD Pipeline

How Package Managers Work — and Why It Matters for CI

What this means for Docker

Why Yarn performed so well

Result

References 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. 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. 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. --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. 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. 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'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. 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 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: 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'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. 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. 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Code Block

Copy

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" ] 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" ] 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" ] 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" ] 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" ] 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" ] 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" ] 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" ] 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" ] 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" ] 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" ] 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" ] 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) 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) 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) - Yarn Plug'n'Play - Bun's performance improvements - How pnpm works - How pnpm does hoisting - Setting up Yarn for NestJS - Source code