Load balancing Cypress tests without Cypress Cloud

FEB 28, 2025 Updated on: JAN 24, 2026

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:

  1. 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 e2e or component tests.
  2. 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.
  3. 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.
  4. Execute each parallelized job that starts the Cypress testrunner with the list of spec files to run across each runner.
  5. When the parallelized jobs complete, collect and save the output of the load balancing files from each job in a temporary location.
  6. 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.json can 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!
See all posts