MediumFrom npm to pnpm: making frontend CI faster at Doctolib | by Bruno Bodian | Doctolib | Oct, 2026 | Medium
Sitemap
Doctolib

Since 2013, Doctolib has been transforming healthcare for thousands of health professionals and millions of patients across Europe.

From npm to pnpm: making frontend CI faster at Doctolib

How we treated a platform migration like a product feature, improving dependency setup and cutting Jest compute per PR by 43% at Doctolib.

10 min readOct 26, 2026

--

We reduced Jest runner time per pull request by 43% in Doctolib’s main repository, representing roughly 3,000 runner-hours saved per month at our PR volume. Dependency setup also improved, with P90 dropping from 206 to 133 seconds: a 35% reduction for the slower setup runs.

The work started with a migration from npm to pnpm. We treated it like a product feature: introduce a switch, validate alongside the existing setup, and roll out progressively. Cleaning up dependencies along the way unlocked another opportunity: making Nx caching and Jest test selection reliable.

Press enter or click to view image in full size

Giving the migration a feature switch

Our main repository combines React and Rails, with hundreds of frontend packages alongside JavaScript code that still lives in the monolith. Almost all Doctolib tech teams work in it. Changing the package manager meant changing a shared foundation underneath their everyday work.

PR volume was growing, from around 2,500 merged PRs in January 2026 to more than 4,000 in June. With AI coding tools becoming part of our workflow too, we wanted to improve dependency installation and test execution without interrupting the teams relying on CI.

We already had a shared Setup Node.js action used by all our workflows across repositories. We extended it with a package-manager input so that it could support both npm and pnpm. Then we introduced a use-pnpm PR label to select pnpm for a particular change. That label became our feature switch.

Controlling installation was only one part of the problem. The repository also contained scripts and workflows that called npm directly. If those commands kept using npm after setup had selected pnpm, the switch would be incomplete.

We introduced bin/pkg, a wrapper that translated our npm commands into their pnpm equivalents. The wrapper and initial workflow conversions were in place before the progressive rollout. The translation had to preserve command meaning:

  • bin/pkg install becomes pnpm install
  • bin/pkg install dayjs becomes pnpm add dayjs
  • bin/pkg ci becomes pnpm install --frozen-lockfile

Workspace flags needed translation too. In npm,-w selects a workspace, while pnpm's -w means the workspace root. The translator explicitly mapped the workspace argument to a filter:

// Excerpt from the argument translator.
case '--workspace':
case '-w':
flags.push(`--filter=${value()}`)
break

Commands without a supported equivalent could pass through to npm with a warning. With installation and command execution behind the switch, we could validate pnpm before asking developers to change their local setup.

Finding failures before the cutover

We ran pnpm in shadow mode alongside npm, checking builds, linting, type-checking, and tests against real repository activity. One recurring failure exposed dependencies that the code used but its manifests did not declare.

Under our npm layout, a package could import dayjs without declaring it if another part of the repository installed it and npm hoisted it into an ancestor's node_modules. The code worked, but its manifest was incomplete.

The stricter pnpm layout exposed those gaps. We already had Nx, but could not confidently rely on its dependency graph while declarations were missing or incorrect.

Fixing each missing dependency by hand would have repeated the same investigation hundreds of times. We instead wrote a script around our custom undeclared-dependency lint rule, based on ESLint’s no-extraneous-dependencies rule, which we adapted for our repository.

The script ran Oxlint on each target package and grouped diagnostics by dependency and importing files. Those records became manifest edits. Here is the flow, omitting reporting and error handling:

// Abridged orchestration from the cleanup script.
for (const pkg of targetPackages) {
const output = runOxlintForPackage(pkg)
const depToFiles = parseOxlintOutput(output)
const missingDeps = []
for (const [name, importedFrom] of depToFiles) {
missingDeps.push({
name,
importedFrom,
type: classifyDepType(importedFrom),
version: await resolveVersion(name, lockfile),
})
}
updatePackageJson(pkg, missingDeps)
}

Analyzing each package separately identified the manifest to update. The importing files determined the section: only dependencies used exclusively by development files belonged in devDependencies:

function classifyDepType(
importedFrom: string[]
): 'dependencies' | 'devDependencies' {
const allDev = importedFrom.every((file) => isDevFile(file))
return allDev ? 'devDependencies' : 'dependencies'
}

isDevFile matched our test, story, and tooling patterns. Any application import made the dependency a production dependency. Classification depended on those patterns; declarations already in the wrong section were surfaced for review.

For versions, we preferred information already in the repository, with the registry as a fallback:

async function resolveVersion(depName: string, lockfile: LockfileV3): Promise<string> {
const workspaceVersion = findWorkspaceVersion(depName)
if (workspaceVersion) return workspaceVersion
const siblingVersion = findVersionInSiblings(depName)
if (siblingVersion) return siblingVersion
const lockfileVersion = findVersionInLockfile(depName, lockfile)
if (lockfileVersion) return lockfileVersion
return fetchVersionFromNpm(depName)
}

Internal workspace manifests came first because unpublished packages could not be resolved through the public registry. After applying edits, the script installed dependencies and reran lint. CODEOWNERS-based targeting let us review changes with each team’s package owners.

We also corrected self-imports, where a package referenced its own code through its package name, and resolved compatibility issues caused by differences in dependency resolution.

The lint rule detected gaps, the script handled repetitive repairs, and PR checks helped prevent regressions. The dependency graph was becoming more trustworthy.

Throughout the rollout, we communicated what was changing, what teams needed to do, and when. Keeping the migration technically controlled was only part of the work: developers also needed time to prepare, rather than discover a new setup when their next command failed.

Despite those precautions, we still encountered issues. What was encouraging was that teams picked up the remaining edge cases without us having to ask. The final fixes became a shared effort across the repository.

Rolling out before changing the developer workflow

We treated the rollout like a product experiment. Instead of relying only on developers opting in, we randomly assigned the use-pnpm label to a percentage of PRs. Selected PRs ran with pnpm, while the others continued using npm, giving us both paths running side by side on real CI traffic.

We increased this percentage to 20%, then to 50%, and finally to 100%, correcting errors as they appeared. Each selected PR got an automatic comment explaining that it had been chosen to run pnpm, and that developers could remove the label if it blocked them. The label acted as a feature switch, and random assignment broadened validation beyond the PRs we deliberately chose to test. Developers could keep their existing local setup throughout: we expanded exposure in CI before asking everyone to switch.

After the CI migration, we removed package-lock.json, completed the remaining npm and npx call-site conversions, and finished the production-build and local-environment changes.

Mise, our development tool manager, handled tool versions and dependency installation. Its environment-entry hook installed the required tools, and the dependency task authenticated with our private registry before running pnpm:

# Relevant entries from mise.toml.
[settings]
idiomatic_version_file_enable_tools = ["ruby", "node", "python", "pnpm"]
[hooks]
enter = "mise install --quiet"
[deps.pnpm]
run = "pnpm install"

Developers ran mise deps install. Mise handled the pnpm version, and the wrapper absorbed command differences across scripts. Each team could adopt the new setup without reproducing the migration logic.

A faster install was only part of the setup

The pnpm install itself was faster. But when we measured the entire Setup Node.js action, the result went in the other direction: median duration increased from 47 to 153 seconds.

Cache restoration and saving had become more expensive. We solved this by electing a single cache writer, running pnpm store prune to remove unreferenced packages and keep cache transfers smaller, and fixing restoration so an exact node_modules cache hit skipped installation.

Reusing security verification across shards

Newer pnpm versions also verified locked packages against supply-chain policies such as minimumReleaseAge, which rejects versions published too recently. Verification could take 30 to 75 seconds on our lockfile. Each shard could pay it on its first pnpm command, even with the same lockfile and policy.

But pnpm already memoized successful verification in a separate file, lockfile-verified.jsonl, inside its cache directory. We extended our shared action to restore that file too. The cache key used the runner architecture and lockfile content hash:

# Relevant inputs from the verification-cache restore step.
path: ${{ env.PNPM_CACHE_DIR }}/lockfile-verified.jsonl
key: pnpm-lockfile-verified-${{ runner.arch }}-${{ hashFiles(env.CACHE_LOCK_FILE) }}
restore-keys: pnpm-lockfile-verified-${{ runner.arch }}-

Every job restored the record; only the elected writer saved it after an exact-cache miss. Even so, pnpm still checked the lockfile and policy before trusting a restored result, so an incompatible entry triggered verification again. We reused successful checks without disabling them.

Get Bruno Bodian’s stories in your inbox

Join Medium for free to get updates from this writer.

The action’s tests cleared this file as well as dependencies to keep cold-cache scenarios cold. With the integration fixes, setup P90 fell from 206 to 133 seconds.

What the dependency cleanup unlocked

The cleaner dependency declarations now gave us a basis for revisiting Nx caching and Jest selection.

We had already used Datadog Test Impact Analysis on the smaller mobile repository, where it reported an estimated 44% reduction in Jest compute in a recent 30-day window. But its instrumentation consumed too much memory for the monolith workload. We needed an approach that fit that repository.

Jest already provided --findRelatedTests: pass changed source files, and it identifies related tests through its dependency graph. Nx could reuse cached results for unchanged project inputs; on a cache miss, Jest could narrow execution within the project. Both tools still needed to see dependencies crossing package boundaries.

Letting Jest see more code without running more tests

One incident explained why selection had previously been difficult to trust. A test in a workspace package imported a component from the older monolith code through an alias. The component lived outside that package’s configured Jest roots, so Jest could not see it when tracing dependencies.

A change to the component selected zero related tests. The PR passed, and the main branch failed afterward.

Our configuration had conflated two scopes: where a package’s tests lived, and where their source dependencies lived.

We created a shared @doctolib/jest-config package with a defineConfig helper and adopted it across 293 package Jest configurations.

const { name } = require('./package.json')
const { defineConfig } = require('@doctolib/jest-config')

module.exports = defineConfig({
rootDir: __dirname,
roots: ['<rootDir>/src/'],
displayName: name,
testEnvironment: '@happy-dom/jest-environment',
})

When selection is enabled, the helper widens the tracing roots to include the older monolith JavaScript and JavaScript within Rails engines, which are modular areas of the application.

Widening roots alone would also discover monolith tests in every package run. We kept tracing broad and execution local: testMatch scoped default discovery to the package, and testPathIgnorePatterns excluded the added monolith directories:

// Excerpt from defineConfig; monolithRootsToAdd contains shared source directories.
const ownRoots = jestConfig.roots || ['<rootDir>/src/']
const testMatch =
jestConfig.testMatch ||
ownRoots.flatMap((root) =>
DEFAULT_TEST_MATCH.map((pattern) =>
root.endsWith('/') ? `${root}${pattern}` : `${root}/${pattern}`
)
)
return {
...jestConfig,
roots: [...ownRoots, ...monolithRootsToAdd],
testMatch,
testPathIgnorePatterns: [
...(jestConfig.testPathIgnorePatterns || ['/node_modules/']),
...monolithRootsToAdd,
],
}

DEFAULT_TEST_MATCH contains the test filename patterns. Existing package matching rules are preserved, and the helper is a no-op when selection is disabled. We verified that the incident's component change selected the expected test without changing package test counts or duplicating monolith runs.

For Nx, we registered the older monolith JavaScript as a project so package imports from that code participated in cache invalidation too.

Keeping caching and selection aligned

An import graph cannot explain every change that affects a test. Updating shared Jest setup or the TypeScript configuration can change test behavior without changing an application source import.

We defined a shared jestGlobal list covering Jest configuration and utilities, the root manifest, base TypeScript configuration, and shared Jest package. The configuration also evolved to track external dependencies used by the test infrastructure. Nx includes these inputs in the test target's cache key:

"test:unit": {
"inputs": ["default", "^production", "jestGlobal"],
"cache": true
}

default covers project inputs; ^production covers production inputs from dependency projects. Our selection script consults jestGlobal too, choosing full-suite mode when a global input changes or the check fails.

The resulting flow has two opportunities to save work:

Press enter or click to view image in full size
Selection mode is decided first (full suite or related tests), then Nx reuses a cached result on a hit and Jest runs the chosen scope on a miss.

Deleted source files exposed another limit. Jest could not trace from a file that no longer existed on the PR branch. A conservative starting policy is to run the full suite whenever a JavaScript or TypeScript source file is deleted. Within a selection-preparation function, that policy can be as small as:

# Conservative alternative: return before enabling related-test selection.
if [[ -n "$deleted_files" ]]; then
echo "Deleted sources detected; running the full suite"
return
fi

Our implementation queried related tests at the merge base, where the source still existed, then combined the surviving tests with the normal selection. A 30-second timeout triggered a full run. This preserved selection for deletions at the cost of additional complexity.

We tested these boundaries explicitly: source changes selected expected tests, global changes invalidated caches, and deleted sources still brought their tests into the run. A faster green pipeline was useful only once we could explain what it had tested.

The results

Datadog CI measurements for our PR test pipeline showed the effect of the setup work:

Dependency setup

  • P90: 206s → 133s, a 35% reduction.
  • Median: 47s → 49s.
  • Mean: 69s → 61s.

Jest execution

  • Runner time per PR: 30.5 → 17.4 minutes, a 43% reduction.
  • Monolith median: 560s → 67s, an 88% reduction.
  • Monolith P90: unchanged at 619s.

The 43% reduction in combined Jest runner time represented approximately 3,000 runner-hours per month at roughly 14,000 PR pipelines.

The unchanged Jest P90 was consistent with the full-suite fallback: changes affecting global inputs still needed all tests. The improvement came from making ordinary changes cheaper to validate.

Runner-hours sum compute across parallel jobs. They measure compute savings, while the step durations show improvements in particular parts of the feedback loop. Other CI projects shipped during the same period, so these results focus on dependency setup and Jest execution.

What changed after the migration

Some less visible maintenance costs also surfaced along the way. The lockfile needed periodic deduplication to keep redundant dependency resolutions from accumulating and making it harder to maintain.

We also encountered more lockfile conflicts than with npm. Catalogs and the more explicit lockfile structure changed how concurrent dependency updates interacted, so resolving those conflicts became part of the ongoing maintenance work.

These were tradeoffs we had to account for after adoption, alongside the installation and test-selection gains.

Conclusion: A platform migration is a product rollout

The users of a platform migration are developers, and their experience deserves the same care as any other product experience. They need to keep working while the foundation underneath them changes.

For us, that meant a feature switch, shadow validation, random assignment, and a gradual rollout before the developer cutover. It also meant measuring the complete experience: a faster install command was not enough if the surrounding setup became slower.

The pnpm migration ultimately unlocked more than we initially set out to improve. Making dependencies explicit gave us the foundation to trust our tooling and run fewer tests. But getting there started with a familiar product question: how do we introduce something better without disrupting the people who rely on what already works?

--

--