Sizing Runners for Node.js Test Suites
Node test workers each hold their own heap, so memory per worker sets the runner size. Measure peak CPU and memory, then pick the warp- Linux label to match.
Last verified:
A Node.js test suite runs one process per worker, and each process carries its own V8 heap, so the runner size that matters is the one that holds the worker count you want in memory. Measure peak memory and peak CPU for the job first, divide peak memory by the worker count to get the per worker footprint, then pick the warp- Linux label where the memory ceiling and the vCPU count agree.
This guide covers the measurement that comes before the label change, a worker count table across the Linux ladder at three per worker footprints, the workflow file that sets the worker count from the runner size rather than leaving it at the default, and the arithmetic on one large runner against several sharded small ones.
Diagnosis
Three failures look like a slow test job, and each one has a different metric signature.
Workers are killed and restarted. Test files fail without a stack trace, the log shows a worker exiting with code 137, or Node prints a heap out of memory error. Peak memory sits near 100 percent while CPU stays in the middle of the range. The suite is memory bound, and adding cores at the same memory ratio will not help.
Cores sit idle. Peak CPU stays in the low tens on a large label. The worker count is below what the machine can run, either because it was left at the default on a size that was chosen for memory or because the suite has fewer test files than workers.
Everything is pinned. Peak CPU sits near 100 percent for most of the run and peak memory has headroom. This is the case where more cores return wall clock time, and the next label up is a straight trade of dollars for minutes.
WarpBuild collects CPU, memory, filesystem, and network utilization from an agent on every runner it operates, and the observability documentation describes what each metric records. Two limits shape how you read them: metrics are collected only for jobs longer than about one minute, and the Recommendations view flags an instance once maximum sustained CPU or maximum memory utilization reaches 80 percent.
Converting peak memory into a per worker footprint
Memory utilization is reported as a percentage of the runner's RAM, so one division turns it into the number that drives the sizing decision.
Take a job on warp-ubuntu-latest-x64-16x, which carries 16 vCPU and 64 GB per the cloud runners documentation, running 15 workers, with memory utilization peaking at 78 percent.
0.78 x 64 GB = 49.9 GB peak resident
49.9 GB - 2 GB = 47.9 GB held by workers
47.9 GB / 15 workers = 3.2 GB per workerThe 2 GB subtraction covers the operating system, the runner agent, and the Jest or Vitest main process. Run the same arithmetic on the size you are on today, and the result transfers to every other label, because the per worker footprint is a property of the suite rather than the machine.
For a local cross-check before the job ever runs on a bigger label, jest --logHeapUsage prints heap size after each test file, and /usr/bin/time -v npx jest --ci --maxWorkers=4 reports maximum resident set size for the whole run. Divide that figure the same way.
Fix
Choose the label from the per worker footprint, then set the worker count to match it.
| Runner label | vCPU | RAM | Workers at 1 GB each | Workers at 2 GB each | Workers at 4 GB each |
|---|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8 GB | 2 | 2 | 1 |
warp-ubuntu-latest-x64-4x | 4 | 16 GB | 4 | 4 | 3 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | 8 | 8 | 7 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | 16 | 16 | 15 |
warp-ubuntu-latest-x64-32x | 32 | 128 GB | 32 | 32 | 31 |
Each cell is the smaller of the vCPU count and the worker count the memory holds after the 2 GB reserve. Read down the columns and the pattern is the sizing rule: at 1 GB and 2 GB per worker every Linux size is bound by cores, so the worker count equals the vCPU count and the label choice is a pure speed decision. At 4 GB per worker memory binds at every size, and each label loses one or two workers against its core count. A suite that drives headless browsers, spins up a database per worker, or loads a large fixture set lands in that last column.
Two consequences follow. A memory bound suite gains nothing from a label that adds cores at the same memory-to-vCPU ratio, since the ratio is fixed at 4 GB per vCPU across the whole Linux ladder. And once the footprint pushes the worker count below the core count, the cheaper move is to cut the footprint: cap each worker heap so V8 collects earlier, or drop per worker fixtures, before buying a wider machine.
Cap the heap explicitly. Node sizes its default old space from the memory it sees, so a worker on a 64 GB machine will let its heap grow far past what 15 concurrent workers can afford. Setting --max-old-space-size to slightly below the per worker budget makes the ceiling a decision rather than an accident.
warp- runners are ephemeral VMs, so nproc and os.availableParallelism() report the vCPU count of the label. That makes the default worker count predictable, and it also means the default is wrong in the same direction every time: Jest opens one worker fewer than the core count, which leaves a core idle, and neither runner knows the memory budget you just computed.
Configuration
Set the worker count and the heap ceiling from the label in the workflow file.
name: test
on:
pull_request:
jobs:
test:
runs-on: warp-ubuntu-latest-x64-16x
env:
TEST_WORKERS: 15
NODE_OPTIONS: --max-old-space-size=3072
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Run tests
run: npx jest --ci --maxWorkers=$TEST_WORKERS --logHeapUsageFifteen workers with a 3 GB heap ceiling each is 45 GB of heap against 64 GB of RAM, which leaves room for the main process, the operating system, and off-heap allocations such as buffers and native modules. Keep --logHeapUsage on for the first week so the next measurement pass has data.
To decide between two sizes with evidence rather than argument, run the same suite at both for a week. The matrix carries the worker count and the heap ceiling alongside the label, so no entry inherits a default meant for a different machine.
jobs:
size-trial:
strategy:
fail-fast: false
matrix:
include:
- label: warp-ubuntu-latest-x64-8x
workers: 7
heap_mb: 3072
- label: warp-ubuntu-latest-x64-16x
workers: 15
heap_mb: 3072
runs-on: ${{ matrix.label }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- name: Run tests
run: npx jest --ci --maxWorkers=${{ matrix.workers }}
env:
NODE_OPTIONS: --max-old-space-size=${{ matrix.heap_mb }}Vitest takes the same two inputs under different names. Its forks pool gives each worker its own process and its own heap, which is the shape the table above models, so poolOptions.forks.maxForks takes the worker count. The threads pool puts workers inside one process, so the heap ceiling applies once for all of them and the per worker column stops applying.
Install time is a separate lever from test time, and sizing decisions get muddy when the two are measured together. If the install step dominates the job, why npm install is slow in GitHub Actions covers that half, and caching Jest runs on GitHub Actions covers the transform cache that sits between them.
Cost or Time Model
Substitute your own numbers. The assumptions below describe one suite:
- 1,400 Jest test files totalling 96 worker-minutes of test execution.
- Per worker peak of 4.0 GB, measured as above.
- Fixed per job overhead of 1.5 minutes for checkout, Node setup, and
npm cifrom a warm cache. - Parallel efficiency of 85 percent, so 15 workers do the work of 12.75.
- 600 runs per month.
| Option | Workers | Wall clock per run | Billed minutes per run | Monthly cost |
|---|---|---|---|---|
One warp-ubuntu-latest-x64-8x | 7 | 17.6 min | 17.6 | $168.96 |
One warp-ubuntu-latest-x64-16x | 15 | 9.0 min | 9.0 | $172.80 |
Four shards on warp-ubuntu-latest-x64-4x | 3 each, 12 total | 10.9 min | 43.6 | $209.28 |
Eight shards on warp-ubuntu-latest-x64-4x | 3 each, 24 total | 6.2 min | 49.6 | $238.08 |
The single 16 vCPU runner returns 8.6 minutes of wall clock over the 8 vCPU runner for $3.84 a month, which is the clearest trade on the list. Sharding buys the next step down in wall clock and pays for it twice: the fixed 1.5 minutes is billed once per shard, and the smaller labels hold fewer workers each at a 4 GB footprint. Eight shards reach 6.2 minutes at $238.08 a month, which is $65.28 more than the single 16 vCPU runner for 2.8 minutes per run.
Rates are the per minute Linux x64 rates from the pricing page, billed per minute. For a baseline, warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price, so the 5,400 monthly minutes of the second row cost $226.80 on GitHub-hosted runners against $172.80. warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price, so the 29,760 monthly minutes of the last row cost $357.12 against $238.08. GitHub list prices checked on 2026-08-13 in the GitHub Actions billing reference.
The same worker arithmetic applies on each platform once you read the vCPU and memory figures for its label. The utilization metrics this guide reads come from CI observability. For the sizing method applied across an estate rather than one suite, see right sizing GitHub Actions runners, and for the surrounding Node.js pipeline see Node.js builds on WarpBuild GitHub Actions runners.
FAQ
What runner size does a Node.js test suite need?
The size whose memory holds the worker count you want. Measure peak memory for the job, subtract about 2 GB for the operating system and the test runner main process, and divide by the worker count to get the per worker footprint. A suite at 4 GB per worker fits 3 workers on warp-ubuntu-latest-x64-4x, 7 on warp-ubuntu-latest-x64-8x, and 15 on warp-ubuntu-latest-x64-16x. A suite at 1 GB per worker is bound by cores instead, so the worker count equals the vCPU count of the label.
Is one large runner better than several small ones for a Node.js test suite?
One large runner is usually cheaper per run because it pays the checkout and install overhead once. Sharding across several jobs is faster in wall clock once the total worker count exceeds what a single label can hold in memory, and it bills that fixed overhead once per shard. Model both against your own run count before choosing.
Why did the test suite get slower after moving to a larger runner?
The worker count did not follow the label. Jest defaults to one worker fewer than the reported CPU count and Vitest defaults from available parallelism, so a suite whose workers each need 4 GB will open more workers than the machine's memory holds, and the run slows down while the kernel reclaims pages. Set maxWorkers explicitly from the runner size and cap each worker heap with NODE_OPTIONS.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.