Load balancing Cypress tests without Cypress Cloud
UPDATE: This article is in reference to version 1 of the plugin, so the information here has been updated to match the version as of October 2025. This plugin has been made more effective and efficient as it uses a different algorithm than a round-robin approach now.
Recently I’ve been asked to work on a solution of efficiently running Cypress component tests on pull requests without taking a lot of time. At first, my standing solution was to just evenly spread out the files against a number of parallel jobs on GitHub Actions workflows, but there is a big discrepancy between the slowest job and the average job times. Thus, we’ve been wondering if there is a smarter way of evening out the runtimes.
With that, I created a new plugin of cypress-load-balancer, which allows us to solve that problem. This plugin saves the durations of the tests it runs and calculates an average, which then then can be passed into a script; that script uses an algorithm to perform load balancing for a number of job runners.
What is a load balancer?
Wikipedia’s summary is as such:
In computing, load balancing is the process of distributing a set of tasks over a set of resources (computing units), with the aim of making their overall processing more efficient. Load balancing can optimize response time and avoid unevenly overloading some compute nodes while other compute nodes are left idle.
The general approach of using a load balancer for tests
This is the basic idea of steps that need to occur to utilize results from load balancing properly. A persistent load
balancing map file known as spec-map.json is saved on the host machine. The load balancer will reference that file and
perform calculations to assign tests across a given number of runners. After all parallel test jobs complete, they will
create a key-value list of test file names to their execution time; these results can then be merged back to the main
spec map file, recalculate a new average duration per each test file, and then overwrite the original file on the host
machine. Then the spec map can be consumed on the next test runs, and run through this process all over and over again.
For this tool, here are the general steps:
- Install and configure the plugin in the Cypress config. When Cypress runs, it will be able to locally save the
results of the spec executions per each runner, depending on
e2eorcomponenttests. - Initialize the load balancer main map file in a persisted location that can be easily restored from cache. This means the main file needs to be in a place outside of the parallelized jobs to can be referenced by the parallelized jobs in order to save new results.
- Execute the load balancer against a number of runners. The output is able to be used for all parallelized jobs to instruct them which specs to execute.
- Execute each parallelized job that starts the Cypress testrunner with the list of spec files to run across each runner.
- When the parallelized jobs complete, collect and save the output of the load balancing files from each job in a temporary location.
- After all parallelized test jobs complete, merge their load balancing map results back to the persisted map file and cached for later usage. This is where the persisted file on the host machine gets overwritten with new results to better perform on the next runs. (In a GitHub Actions run, this means on pull request merge, the load balancing files from the base branch and the head branch need to be merged, then cached down to the base branch.)
So, for Docker Compose, a persistent volume needs to exist for the host spec-map.json to be saved. It can then run the
load balancing script, and execute a number of parallelized containers to run those separated Cypress tests. When each
test job completes, the duration of each test can be merged back to the original file and re-calculate a new average.
For GitHub Actions, it’s a bit more complex. More on that later.
How does it work for Cypress automated tests?
Installation
The current installation guide as of February 2025 October 2025 is as such:
Install the package to your project:
npm install --save-dev cypress-load-balancer
yarn add -D cypress-load-balancer Add the following to your .gitignore and other ignore files:
.cypress_load_balancer In your Cypress configuration file, add the plugin separately to your e2e configuration and also component configuration, if you have one.
This will register load balancing for separate testing types
import { addCypressLoadBalancerPlugin } from 'cypress-load-balancer';
defineConfig({
e2e: {
setupNodeEvents(on, config) {
addCypressLoadBalancerPlugin(on, config, 'e2e');
return config;
}
},
component: {
setupNodeEvents(on, config) {
addCypressLoadBalancerPlugin(on, config, 'component');
return config;
}
}
}); Usage
- Cypress tests are run for e2e or component testing types.
- When the run completes, the durations and averages of all executed tests are added to
spec-map.json. - The
spec-map.jsoncan now be used by the included executable,cypress-load-balancer, to perform load balancing against the current Cypress configuration and tests that were executed. The tests are sorted from slowest to fastest and then assigned out per runner to get them as precise as possible to each other in terms of execution time. For example, with 3 runners and e2e tests: npx cypress-load-balancer --runners 3 --testing-type e2e- The script will output an array of arrays of spec files balanced across 3 runners.
Scripts
There are included scripts with npx cypress-load-balancer:
$: npx cypress-load-balancer --help
cypress-load-balancer
Performs load balancing against a set of runners and Cypress specs
Commands:
cypress-load-balancer Performs load balancing against a set of
runners and Cypress specs [default]
cypress-load-balancer initialize Initializes the load balancing map file and
directory.
cypress-load-balancer merge Merges load balancing map files together
back to an original map.
Options:
--version Show version number [boolean]
-r, --runners The count of executable runners to use
[number] [required]
-t, --testing-type The testing type to use for load balancing
[string] [required] [choices: "e2e", "component"]
-F, --files An array of file paths relative to the current
working directory to use for load balancing.
Overrides finding Cypress specs by configuration
file.
If left empty, it will utilize a Cypress
configuration file to find test files to use for
load balancing.
The Cypress configuration file is implied to
exist at the base of the directory unless set by
"process.env.CYPRESS_CONFIG_FILE"
[array] [default: []]
--format, --fm Transforms the output of the runner jobs into
various formats.
"--transform spec": Converts the output of the
load balancer to be as an array of "--spec
{file}" formats
"--transform string": Spec files per runner are
joined with a comma; example:
"tests/spec.a.ts,tests/spec.b.ts"
"--transform newline": Spec files per runner are
joined with a newline; example:
"tests/spec.a.ts
tests/spec.b.ts"
[choices: "spec", "string", "newline"]
--set-gha-output, --gha Sets the output to the GitHub Actions step output
as "cypressLoadBalancerSpecs" [boolean]
-h, --help Show help [boolean]
Examples:
Load balancing for 6 runners against cypressLoadBalancer -r 6 -t
"component" testing with implied Cypress component
configuration of `./cypress.config.js`
Load balancing for 3 runners against cypressLoadBalancer -r 3 -t e2e -F
"e2e" testing with specified file paths cypress/e2e/foo.cy.js
cypress/e2e/bar.cy.js
cypress/e2e/wee.cy.js Example on GitHub Actions
I included two workflows in the package that show how this can work for tests executed on pull requests.
Generally, here is what occurs:
Running tests on pull requests
# See https://github.com/brennerm/github-actions-pr-close-showcase/
# 1. **Set a number of runners in `X/Y` format.** For example, 2 runners would mean saving `"1/2"`, `"2/2"`, for later. (Job: "generate_runner_variables")
# 2. **Restore the same load balancer main map file from a persisted location, that will be used for every run attempt.** (Jobs: needed for "cypress_run_e2e" for accurate balancing, and "merge_cypress_load_balancing_maps" for saving results)
# 3. **Execute each Cypress `run` process in parallel using the `runner` variables.** ("cypress_run_e2e")
# 4. **Wait for each Cypress process to fully complete.**
# 5. **Collect the load balancing maps from each completed runner process.** ("merge_cypress_load_balancing_maps")
# 6. **Merge the temporary maps back to the original load balancing map.** ("merge_cypress_load_balancing_maps")
# 7. **Save the updated main load balancing map back to its persisted location.** ("merge_cypress_load_balancing_maps")
name: Example workflow | Load balancing of Cypress E2E tests
on:
pull_request:
push:
branches:
workflow_dispatch:
inputs:
runner_count:
type: number
description: Number of runners to use for parallelization
required: false
default: 4
DEBUG:
type: choice
description: Enables debugging on the job and on the cypress-load-balancer script.
options:
- ''
- '*'
- 'cypress-load-balancer'
env:
runner_count: ${{ inputs.runner_count || 4 }}
jobs:
# This job exists so that if any testing jobs fail, or the same workflow needs to be re-run, then the testing jobs
# will contain the same set testing files for all new run attempts of this workflow. This is necessary to ensure
# that the balancing occurs for the timings of files on its first run attempt as well as for all new run attempts,
# and will not consider any new timings until a new instance of the workflow is run.
save_original_map_for_all_attempts:
name: Save the original map to cache to use on all future run attempts of this workflow
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Check if ORIGINAL map exists, and if not, restore cache of the map from previous workflow runs
id: check-if-original-map-exists
uses: actions/cache/restore@v4
with:
fail-on-cache-miss: false
path: .cypress_load_balancer/spec-map.json
key: cypress-load-balancer-map-${{ github.head_ref || github.ref_name }}-${{ github.run_id }}-ORIGINAL
## Static restore key:
## - "ORIGINAL" key from first run attempt, which is used for all future run attempts
# Extra restore keys in order, if static is not hit:
## - Key from this current branch
## - Key from base branch or default branch; in this case, default branch is "main" (GitHub Actions does not provide a way to get the default_branch name easily)
restore-keys: |
cypress-load-balancer-map-${{ github.head_ref || github.ref_name }}-
cypress-load-balancer-map-${{ github.base_ref || 'main' }}
- uses: actions/setup-node@v4
if: ${{ steps.check-if-original-map-exists.outputs.cache-hit != 'true' }}
with:
node-version: ${{ env.NODE_VERSION }}
- name: Initialize "ORIGINAL" map if not existing
if: ${{ steps.check-if-original-map-exists.outputs.cache-hit != 'true' }}
run: |
yarn install
yarn build
npx cypress-load-balancer initialize
- name: Cache "ORIGINAL" map for all future run attempts, if not existing
if: ${{ steps.check-if-original-map-exists.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
key: cypress-load-balancer-map-${{ github.head_ref || github.ref_name }}-${{ github.run_id }}-ORIGINAL
path: .cypress_load_balancer/spec-map.json
# This is a utility job that fills in the `cypress_run_e2e` job variables to correctly parallelize them on GitHub Actions.
generate_runner_variables:
runs-on: ubuntu-22.04
outputs:
runner-variables: ${{ steps.generate-runners.outputs.runner-variables }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# "yarn build" is only needed for this project to build the CLI ! It shouldn't be required in other projects as the CLI scripts should be installed normally
- name: Install dependencies
run: |
yarn install
yarn build
- id: generate-runners
run: npx cypress-load-balancer generate-runners ${{ inputs.runner_count || env.runner_count }} --gha
# This is where the parallelization happens: test sets are split up amongst the jobs, and run at the same time!
cypress_run_e2e:
runs-on: ubuntu-22.04
needs: [save_original_map_for_all_attempts, generate_runner_variables]
strategy:
fail-fast: false
matrix:
runner: ${{ fromJson(needs.generate_runner_variables.outputs.runner-variables) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Restore ORIGINAL load-balancing map for this run attempt
uses: actions/cache/restore@v4
with:
fail-on-cache-miss: true
path: .cypress_load_balancer/spec-map.json
key: cypress-load-balancer-map-${{ github.head_ref || github.ref_name }}-${{ github.run_id }}-ORIGINAL
- name: Cypress e2e tests (${{ matrix.runner }})
uses: cypress-io/github-action@v6
with:
browser: electron
# Fix for https://github.com/cypress-io/github-action/issues/480
config: videosFolder=/tmp/cypress-videos
env:
CYPRESS_runner: ${{ matrix.runner }}
DEBUG: ${{ inputs.DEBUG || '' }}
- name: Upload temp load balancer map
if: (!cancelled())
uses: actions/upload-artifact@v4
with:
# Artifacts cannot be saved with a forward slash ( / ), so using runner.name as it is unique
name: spec-map-${{ runner.name }}
path: .cypress_load_balancer/spec-map-*.json
include-hidden-files: true
if-no-files-found: 'error'
# Collects the timing results and merge them to a main file to use in the next pipeline run!
merge_cypress_load_balancing_maps:
runs-on: ubuntu-22.04
needs: [cypress_run_e2e]
if: (!cancelled())
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: |
yarn install
yarn build
- name: Restore cached load-balancing map
id: cache-restore-load-balancing-map
uses: actions/cache/restore@v4
with:
fail-on-cache-miss: false
path: .cypress_load_balancer/spec-map.json
# Save with key for this current run attempt
key: cypress-load-balancer-map-${{ github.head_ref || github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }}
# Restore keys:
## 1. Key for the ORIGINAL map for this run attempt
## 2. Key from the head ref/current branch
## 3. Key from base ref/default branch
restore-keys: |
cypress-load-balancer-map-${{ github.head_ref || github.ref_name }}-${{ github.run_id }}-ORIGINAL
cypress-load-balancer-map-${{ github.head_ref || github.ref_name }}-
cypress-load-balancer-map-${{ github.base_ref || 'main' }}-
- name: If no map exists for either the base branch or the current branch, then initialize one
id: initialize-map
run: npx cypress-load-balancer initialize
if: ${{ hashFiles('.cypress_load_balancer/spec-map.json') == '' }}
- name: Download temp maps
uses: actions/download-artifact@v4
with:
pattern: spec-map-*
path: temp
merge-multiple: false
- name: Merge files
run: npx cypress-load-balancer merge -G "./temp/**/spec-map-*.json" --HE error
- name: Save overwritten cached load-balancing map
id: cache-save-load-balancing-map
uses: actions/cache/save@v4
with:
#This saves to the workflow run. To save to the base branch during pull requests, this needs to be uploaded on merge using a separate action
# @see `./save-map-on-to-base-branch-on-pr-merge.yml`
key: cypress-load-balancer-map-${{ github.head_ref || github.ref_name }}-${{ github.run_id }}-${{ github.run_attempt }}
path: .cypress_load_balancer/spec-map.json
# This is to get around the issue of not being able to access cache on the base_ref for a PR.
# We can use this to download it in another workflow run: https://github.com/dawidd6/action-download-artifact
# That way, we can merge the source (head) branch's load balancer map to the target (base) branch.
- name: Upload main load balancer map
if: always()
uses: actions/upload-artifact@v4
with:
name: cypress-load-balancer-map
path: .cypress_load_balancer/spec-map.json Merging back on pull requests
- When the pull request is merged, the newest map uploaded from the source branch’s testing workflow is downloaded, merged with the base branch’s map, and then cached to the base branch. This allows it to be reused on new pull requests to that branch.
# For GitHub Actions, the tests must be run on the base and head branches and upload their load balancer maps!
# Caches cannot be accessed across feature branches:
# See https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache
# See https://github.com/actions/cache/blob/main/tips-and-workarounds.md#use-cache-across-feature-branches
# Additionally,
# See https://github.com/brennerm/github-actions-pr-close-showcase/
name: Save load balancing map from head branch to base branch on pull request merge
on:
#This isn't perfect -- it needs to run when the PR is closed and the tests have completed on the base branch
pull_request:
types: [closed]
jobs:
save:
# this job will only run if the pull request has been merged
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- run: |
echo PR #${{ github.event.number }} has been merged
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- run: yarn install && yarn build
- name: Download load-balancing map from HEAD branch using "cross-workflow" tooling
id: download-load-balancing-map-head-branch
uses: dawidd6/action-download-artifact@v8
with:
workflow: example-parallel-testing-workflow.yml
# Optional, will get head commit SHA
pr: ${{ github.event.pull_request.number }}
name: cypress-load-balancer-map
path: .cypress_load_balancer
- name: Download load-balancing map from BASE branch using "cross-workflow" tooling
id: download-load-balancing-map-base-branch
uses: dawidd6/action-download-artifact@v8
with:
workflow: example-parallel-testing-workflow.yml
branch: ${{ github.base_ref }}
name: cypress-load-balancer-map
path: temp
- name: Merge files (Using two different glob patterns)
run: npx cypress-load-balancer merge -G "./temp/**/spec-map-*.json" -G "./temp/**/spec-map.json" --HE error
- name: Save cache of merged load-balancing map
uses: actions/cache/save@v4
with:
path: .cypress_load_balancer/spec-map.json
key: cypress-load-balancer-map-${{ github.base_ref }}-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/upload-artifact@v4
with:
name: cypress-load-balancer-map
path: .cypress_load_balancer/spec-map.json And that’s it! While the example above is comprehensive, the general approach should be the same:
- Save a spec map on the host machine
- Perform load balancing against the spec map
- Run parallel test jobs organized by the list of files separated by the load balancer
- Collect their results
- Merge those results back to the host map and recalculate the average
- Repeat!