The fastest way to run a large Node.js test suite in parallel is to split it across a matrix of Depot CI jobs, then use historical JUnit timings to keep the shards balanced.
Depot CI has no runner concurrency limit, so every shard can start at once. Each shard runs on its own runner with its own CPU and memory. Depot uses the duration of earlier test results to give every shard a similar amount of work. That keeps one unusually slow test file from deciding when the entire suite finishes.
There are three different ways to parallelize a Node.js workflow:
| Approach | What runs concurrently | Best fit |
|---|---|---|
| Parallel steps | Independent commands inside one job | Lint, type checking, builds, and shorter test suites |
| Matrix job sharding | Different subsets of tests on separate runners | Large suites that need more CPU than one runner can supply |
| Timing-balanced test splitting | Matrix shards balanced using historical test timing | Suites where test files have very different run times |
A matrix creates the parallel jobs. It doesn't decide which tests belong in each job. Depot's test splitting handles that assignment and updates it as the suite changes.
Split Vitest across four jobs
This Depot CI workflow discovers Vitest files, creates four jobs, assigns a non-overlapping subset to each job, runs the subset, and uploads a JUnit report:
name: Node tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: depot-ubuntu-24.04
strategy:
fail-fast: false
matrix:
shard: [0, 1, 2, 3]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: depot/setup-action@v1
- name: Run timing-balanced test shard
uses: depot/tests-run-action@v1
with:
candidates-command: git ls-files | grep -E '\.(test|spec)\.(js|jsx|ts|tsx)$'
candidate-type: classname
command: >
mkdir -p test-results &&
xargs pnpm exec vitest run
--reporter=default
--reporter=junit
--outputFile.junit=test-results/junit.xml
report-path: test-results/junit.xmlDEPOT_MATRIX_JOB_INDEX and DEPOT_MATRIX_JOB_TOTAL tell the action which shard the current Depot CI job owns. You
don't need to wire the matrix index into the action yourself.
The candidate command must print one runnable test file per line. Change it to match where your repository keeps its
tests. The command receives the selected candidates on standard input, and xargs passes only that shard's files to
Vitest.
Vitest accepts filenames as arguments but records them in the JUnit classname field. That's why this example sets
candidate-type: classname. For Jest, Depot supports JUnit reports produced by jest-junit when its file field is
enabled. In that setup, filename candidates are inferred correctly and candidate-type can be omitted.
JUnit history keeps the shards balanced
Splitting four test files into four jobs is easy. Splitting 2,000 files so that all four jobs finish at roughly the same time is the hard part.
A naive split by file count can produce three four-minute shards and one fourteen-minute shard. The workflow still takes fourteen minutes because the slowest shard is on the critical path.
depot/tests-run-action runs and reports each shard in one step. Every job uploads
JUnit XML with the duration of its tests. On the next run, Depot uses those durations to build a new set of
non-overlapping shards with similar predicted run times.
The first run doesn't need timing history. Filename candidates fall back to file-size splitting. Other candidate types use deterministic fallback weights. Because Vitest records these candidates as classnames, the example above uses deterministic weights on its first run. As JUnit results accumulate, later runs become timing-balanced.
The same reports also show up in Depot test results, where you can inspect failures across every shard and find slow or flaky tests across the organization.
Use parallel steps for independent Node.js checks
A matrix isn't the only way to remove sequential work. Depot CI can run independent steps concurrently inside one job:
jobs:
checks:
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- parallel:
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm type-check
- name: Unit tests
run: pnpm testAll three commands start from the same job state after dependency installation. Depot waits for the entire parallel group before continuing.
This removes unnecessary ordering, but it doesn't give each command a separate machine. Lint, type checking, and tests still share the runner's CPU and memory. If the test runner already uses every available core, running more work beside it can make all three commands slower. Use a matrix when the suite needs more aggregate compute.
Don't start several copies of the complete test suite in a parallel block. That repeats the same work. Test splitting creates disjoint shards so each test runs once.
Choose the number of shards from the critical path
Parallelism trades wall-clock time for more aggregate compute and repeated setup. Four five-minute shards can reduce a twenty-minute test step to roughly five minutes, but every shard still checks out the repository, restores caches, and installs dependencies.
Depot's unlimited concurrency means runner capacity doesn't force those shards to wait in line. It doesn't make extra jobs free. Increase the shard count while the slowest shard gets meaningfully shorter. Stop when setup time, contention, or one unsplittable test becomes the new critical path.
A useful progression is:
- Record the duration of the test step on one Depot CI runner.
- Split the suite into two timing-balanced jobs.
- Compare the slowest shard, total workflow time, and total compute.
- Move to four or eight shards only if the critical path keeps shrinking enough to justify the extra jobs.
Keep some suites sequential
Don't parallelize a test suite by default when:
- The suite is shorter than the runner and dependency setup around it.
- Tests depend on execution order or mutate shared global state.
- Shards would fight over one database, port, account, queue, or external rate limit.
- One integration test dominates the entire suite and can't be split further.
- The Node.js test runner already saturates the runner and a larger runner is simpler than a matrix.
- Failures are already intermittent. More concurrency can expose hidden shared-state bugs before you're ready to diagnose them.
Fix isolation before adding more shards. Give each job its own database schema, port range, temporary directory, and external resource names. Parallel tests are useful when the tests can actually run independently.