Reduce parallel request count only for internal registry in pnpm 12.9

Reduce parallel request count only for internal registry in pnpm 12.9

From pnpm 12.9.0, you can set networkConcurrency per registry entry. I confirmed in 12.9.1 and 12.12.0 that only the limited registry has 1 concurrent request while the other remains at 4.
2026.10.23

This page has been translated by machine translation. View original

Hi! I'm Nishimura Yuji from the Operations team!

When you lower the overall concurrency in pnpm to reduce simultaneous requests to an internal registry, it also restricts communication to other registries. In pnpm 12.9.0, you can now set networkConcurrency per registries entry.

I set up two local registries simulating an internal registry and a public registry, and verified whether limiting the concurrency to 1 for one of them would preserve the concurrency of the other. The result showed that only the restricted side went down to a maximum of 1, while the other remained at 4.

Place networkConcurrency under the URL you want to restrict

According to the official documentation on network settings, the top-level networkConcurrency is the upper limit on the number of simultaneous HTTP(S) requests processed. On the same page, it is noted that if you want to restrict only a specific registry, you should place networkConcurrency in the registries entry. According to the Registries page, the per-entry setting is the upper limit on simultaneous requests to that registry's origin. An origin is the combination of protocol, host, and port. The same page also states that per-entry requests are counted toward the overall limit, so specifying a value larger than the global limit has no effect (this point was not tested this time).

For verification, I used the following configuration (omitting enableGlobalVirtualStore and cacheDir added for testing purposes). The port numbers were assigned when the verification server started and change with each run.

pnpm-workspace.yaml
networkConcurrency: 4
registries:
  "http://127.0.0.1:45287/":
    scopes: ["@corp"]
    networkConcurrency: 1
  "http://127.0.0.1:33967/":
    scopes: ["@public"]

Only the registry fetching @corp packages has its limit set to 1. The @public side has no per-registry specification, so it uses the global setting of 4. When applying this to an internal environment, match the URLs and scopes to your actual registries.

According to the 12.9.0 release notes, this setting can be placed in both pnpm-workspace.yaml and the global config.yaml.

Measure simultaneous requests by installing 16 packages

The verification environment is Ubuntu 24.04.5 LTS (aarch64) in a Docker container, with Node.js v24.21.0. pnpm versions 12.8.1, 12.9.1, and 12.12.0 were each installed in separate directories using npm i pnpm@<version>. I confirmed the output of pnpm --version before use. 12.12.0 is the latest release on GitHub as of October 11, 2026 (the latest tag on npm is 12.11.2, while 12.12.0 carries the next-12 tag).

The two local registries (different ports on 127.0.0.1) each had 8 dummy packages with no dependencies or install scripts. The server waits 200ms before returning each response for metadata and tarballs, and counts the number of requests being processed simultaneously during that wait. The maximum concurrency in the table below is the per-server maximum combining metadata and tarball requests.

Each case used a new project, store, and cache to avoid the influence of existing caches. HOME and XDG-related directories were also separated into temporary directories, and enableGlobalVirtualStore: false was specified. Measurements were taken with the container's network disconnected. The command executed within the project was as follows:

pnpm install --ignore-scripts --store-dir ./store --reporter append-only

In actual runs, absolute paths were used for each case's store specification. Successful cases were each run 3 times, and the values were identical across all 3 runs.

pnpm Per-registry specification corp max concurrency public max concurrency Result
12.8.1 corp only: 1 no communication no communication Exited with configuration error
12.9.1 corp only: 1 1 4 Installed 16 packages
12.9.1 none 4 4 Installed 16 packages
12.12.0 corp only: 1 1 4 Installed 16 packages
12.12.0 none 4 4 Installed 16 packages

Here are the execution results with 12.12.0 and the configuration files used. limited is the case where only the corp side has a limit of 1, and control is the case with no specification.

Execution results showing that with pnpm 12.12.0, only the corp side has a maximum concurrency of 1 while the public side remains at 4

In both 12.9.1 and 12.12.0, only the corp side reached a maximum of 1, while the public side remained at a maximum of 4. In the control case with no restriction, both sides reached a maximum of 4.

In the successful cases, 8 metadata and 8 tarball requests arrived at each server, and the exit code was 0. The restriction does not change the number of requests; only the number flowing simultaneously changes.

Since the conditions in this test are small, this setting should be read strictly as an upper limit. If the number of packages or response times change, the actual number of simultaneous requests may not reach the limit. These results verify the communication upper limit and are not benchmarks showing installation time improvements or reduced load on production registries.

Re-running the local verification

The verification code below combines dummy package generation, two HTTP servers, and the execution and aggregation of pnpm install. It uses Node.js and the tar command. Pass three arguments: the path to the pnpm executable, the mode (limited or control), and the output directory. HOME, XDG-related directories, and CI=true are set by the verification code in the child process. The example paths are from the verification container's layout, with pnpm at node_modules/.bin/pnpm in each version's directory.

node verify.mjs /work/bin/12.12.0/node_modules/.bin/pnpm limited /work/runs/12.12.0-limited-1

The output directory will contain the configuration file, install.log, and result.json. The HTTP servers stop after installation completes.

verify.mjs
// Sets up two local registries (corp / public) and counts the maximum simultaneous requests during pnpm install.
// Usage: node verify.mjs <path to pnpm> <limited|control> <output directory>
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { execFileSync, spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';

const [pnpm, mode, outDir] = process.argv.slice(2);
const limited = mode === 'limited';
const PACKAGES = 8;
const DELAY_MS = 200;

// Create a dummy package as a tar.gz (using the tar command)
function makeTarball(dir, name, i) {
  const pkgDir = join(dir, 'src', name, 'package');
  mkdirSync(pkgDir, { recursive: true });
  writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name, version: '1.0.0', main: 'index.js' }));
  writeFileSync(join(pkgDir, 'index.js'), `module.exports = ${i};`);
  return execFileSync('tar', ['-czf', '-', '-C', join(dir, 'src', name), 'package']);
}

function startRegistry(scope, dir) {
  const reg = { scope, active: 0, maxInFlight: 0, metadata: 0, tarball: 0, blobs: {} };
  for (let i = 0; i < PACKAGES; i++) {
    const name = `@${scope}/pkg${i}`;
    reg.blobs[name] = makeTarball(dir, name, i);
  }
  reg.server = createServer((req, res) => {
    const path = decodeURIComponent(req.url.split('?')[0]);
    const kind = path.endsWith('.tgz') ? 'tarball' : 'metadata';
    const name = path.slice(1).split('/-/')[0];
    if (!reg.blobs[name]) { res.writeHead(404).end(); return; }
    reg.active++;
    reg.maxInFlight = Math.max(reg.maxInFlight, reg.active);
    reg[kind]++;
    setTimeout(() => {
      let body = reg.blobs[name];
      if (kind === 'metadata') {
        const integrity = 'sha512-' + createHash('sha512').update(body).digest('base64');
        const tarball = `http://127.0.0.1:${reg.server.address().port}/${name}/-/pkg-1.0.0.tgz`;
        body = Buffer.from(JSON.stringify({
          name, 'dist-tags': { latest: '1.0.0' },
          versions: { '1.0.0': { name, version: '1.0.0', dist: { tarball, integrity } } },
          time: { '1.0.0': '2020-01-01T00:00:00Z' },
        }));
      }
      res.writeHead(200, { 'Content-Type': kind === 'metadata' ? 'application/json' : 'application/octet-stream', 'Content-Length': body.length });
      res.end(body);
      reg.active--;
    }, DELAY_MS);
  });
  return new Promise((r) => reg.server.listen(0, '127.0.0.1', () => r(reg)));
}

mkdirSync(outDir, { recursive: true });
const corp = await startRegistry('corp', outDir);
const pub = await startRegistry('public', outDir);
const registries = [corp, pub];

const project = join(outDir, 'project');
mkdirSync(project, { recursive: true });
const deps = {};
for (const r of registries) for (let i = 0; i < PACKAGES; i++) deps[`@${r.scope}/pkg${i}`] = '1.0.0';
writeFileSync(join(project, 'package.json'), JSON.stringify({ name: 'registry-concurrency-test', private: true, dependencies: deps }, null, 2));
let config = `networkConcurrency: 4\nenableGlobalVirtualStore: false\ncacheDir: ${join(outDir, 'cache')}\nregistries:\n`;
for (const r of registries) {
  config += `  "http://127.0.0.1:${r.server.address().port}/":\n    scopes: ["@${r.scope}"]\n`;
  if (limited && r.scope === 'corp') config += '    networkConcurrency: 1\n';
}
writeFileSync(join(project, 'pnpm-workspace.yaml'), config);

// Confine user settings and cache inside the output directory
const env = { ...process.env, HOME: join(outDir, 'home'), XDG_CONFIG_HOME: join(outDir, 'config'), XDG_CACHE_HOME: join(outDir, 'xdg-cache'), XDG_DATA_HOME: join(outDir, 'data'), XDG_STATE_HOME: join(outDir, 'state'), CI: 'true' };
mkdirSync(env.HOME, { recursive: true });

const version = execFileSync(pnpm, ['--version'], { env, encoding: 'utf8' }).trim();
// Launch asynchronously so the server can respond while waiting for the child process to complete
const child = spawn(pnpm, ['install', '--ignore-scripts', '--store-dir', join(outDir, 'store'), '--reporter', 'append-only'], { cwd: project, env });
let log = '';
child.stdout.on('data', (d) => (log += d));
child.stderr.on('data', (d) => (log += d));
const exit = await new Promise((r) => child.on('close', r));
writeFileSync(join(outDir, 'install.log'), log);

let installed = 0;
for (const r of registries) for (let i = 0; i < PACKAGES; i++) if (existsSync(join(project, 'node_modules', `@${r.scope}`, `pkg${i}`, 'package.json'))) installed++;
const result = {
  pnpm: version, mode, exit, installedPackages: installed,
  corp: { maxInFlight: corp.maxInFlight, metadata: corp.metadata, tarball: corp.tarball },
  public: { maxInFlight: pub.maxInFlight, metadata: pub.metadata, tarball: pub.tarball },
};
writeFileSync(join(outDir, 'result.json'), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
for (const r of registries) r.server.close();

With 12.8.1, a configuration error occurs before installation

When passing the same configuration to 12.8.1, it exited with ERR_PNPM_INVALID_SETTING. The error message indicated that registries[URL].networkConcurrency is not a known registry setting (the port number in the image is from a different run).

pnpm 12.8.1 showing ERR_PNPM_INVALID_SETTING for networkConcurrency in registries

Summary

I was able to confirm that you can reduce the simultaneous request count to a specific registry while preserving the overall concurrency. This seems well-suited for setups where you want to limit simultaneous connections to an internal registry without slowing down fetches from public registries. When adopting this, since the tested 12.8.1 stops with a configuration error even for pnpm --version, make sure to align the team's pnpm version in advance.

HTTPS and authenticated internal registries, configuration via global config.yaml, proxy usage, and behavior on macOS and Windows are outside the scope of this test. I also have not tested configurations where tarballs are served from a different origin, or pnpm 12.9.0 itself (its addition was confirmed in the official release notes).

Reference links:

Share this article