---
title: "Slow tests in CI | StarSling"
description: "Your GitHub Actions test job runs far slower than the same suite locally. Here are the five causes to check, in order, and how to confirm each one."
url: https://starsling.dev/github-actions/problems/slow-tests
canonicalUrl: https://starsling.dev/github-actions/problems/slow-tests
---

# Why your test job is slower in CI than it is locally

[GitHub Actions](https://starsling.dev/github-actions) / [Problems](https://starsling.dev/github-actions/problems) / Why your test job is slower in CI than it is locally

- [How StarSling works](https://starsling.dev/)

Diagnosis mode: hybrid. Last updated: 2026-08-31

A test job that outruns the local suite by many times over almost always comes down to one of a few things: the tests running as one unsharded block, a single long job on the critical path, a dependency cache that never restores, unscoped test runs on every change, or fixed sleeps standing in for readiness checks. Check them in that order.

## Table of contents

- [Symptoms](#symptoms)
- [How to diagnose it](#how-to-diagnose-it)
- [Likely causes](#likely-causes)
- [Hand it to an agent](#verify)
- [Related pages](#related-pages)
- [Related symptoms](#related-symptoms)
- [Sources](#sources)

<a id="symptoms"></a>

## Symptoms

Locally you run the suite once, warm, on one machine, with every dependency already on disk. In GitHub Actions the job starts from a cold runner: it checks out the repo, installs dependencies, and only then runs tests, and if that install has to fetch everything from scratch every time, the setup cost alone can dwarf the run itself. On top of that, most repos start with one job running the whole suite in sequence, which caps the job's floor at the slowest possible arrangement of that work even when the machine has more cores or the workflow has more runners available to it. The causes below are ordered by how often they explain the gap: sharding and job-splitting first, because an unsplit suite is the single biggest lever; caching next, because a cold install is the next-biggest recurring cost; then scoping test runs to what changed; then sleeps embedded in the tests themselves, which inflate wall clock without doing any real work.

- The test job takes far longer in GitHub Actions than running the same suite locally, even on a similar machine.
- One job in the workflow visibly dominates the run, with other jobs finishing well before it.
- The first minute or more of the job log is checkout and dependency install before any test output appears.
- The job runs the full suite on every push, including pushes that only touch documentation or an unrelated package.
- Test output shows long pauses that don't correspond to real work, particularly around browser or integration tests.

<a id="how-to-diagnose-it"></a>

## How to diagnose it

[ci-speedup](https://starsling.dev/ci-speedup) carries the detection logic behind the causes below and opens the fix as a reviewable pull request. Install it with `npx skills add starslingdev/skills`, then run `/ci-speedup` in your repository.

To check by hand:

1. Open the workflow run in the GitHub Actions UI and read the per-job timeline to find which single job is the long pole, since the workflow's total time is set by that job, not by the average.
2. Inside that job's log, time the gap between the checkout step and the first line of real test output; if that gap is more than a minute, dependency install or cache restore is the likely cost, not the tests themselves.
3. Check `.github/workflows/*.yml` for a `strategy.matrix` with a `--shard` flag on the test step; its absence on a suite over five minutes is the largest single lever.
4. Run `grep -rn 'actions/cache\|setup-node.*cache\|setup-python.*cache\|setup-go.*cache' .github/workflows/` to confirm a caching action exists for the ecosystem the job installs.
5. If the repo is a monorepo, check whether `turbo.json` or `nx.json` exists and whether the test command in the workflow actually uses a `--filter` or `affected` scope against the merge base.
6. Grep the test source for fixed-duration waits (`waitForTimeout`, `cy.wait(`, `sleep(`) and total their durations against the full suite time to see whether they're a meaningful share of the gap.

<a id="likely-causes"></a>

## Likely causes

Ordered by how often each one turns out to be the answer. Confirm a cause with its check before you change anything.

### 1. The suite runs as one sequential job instead of parallel shards

A single job running every test file in order is bounded by the sum of all of them, not by how many runners GitHub Actions could hand you at once. Frameworks like Playwright and vitest support splitting a run into shards that execute as separate matrix jobs, but that only shortens the run if the shards are configured and reasonably balanced. An unsharded suite over five minutes is the first thing to look at, because it is usually the largest single gap between local and CI time.

Confirm it: Look for a `strategy.matrix` block with a `--shard` flag in the test step. If the test job has no matrix and no shard flag, this is your cause. If it does have a matrix, compare the slowest leg's duration to the fastest in the Actions run summary: if one leg runs more than two to three times longer than the others, the shards are imbalanced rather than absent.

Fix: [Test sharding](https://starsling.dev/best-practices/github-actions/shard-tests) (`ci.parallel.test-sharding`, detection mode static)

### 2. One job sits on the critical path while the rest of the workflow waits or finishes early

Even with parallel jobs elsewhere in the workflow, the total run time is set by whichever job takes longest, not by the average. A job that bundles multiple independent suites, or that isn't split further even after sharding is in place, keeps acting as the long pole. Splitting that job's remaining work into more parallel pieces shortens the workflow's wall clock directly.

Confirm it: Open the workflow run summary in the Actions UI and read the job timeline: the workflow's total duration equals the longest single job's duration plus queue time, not the sum of all jobs. If one job's bar is visibly longer than every other job combined, that job is the long pole worth splitting.

Fix: [Long-running jobs split](https://starsling.dev/github-actions/optimizations/split-long-running-jobs) (`ci.parallel.long-running-jobs`, detection mode runtime)

### 3. Dependencies install from scratch instead of restoring from cache

If a job has no caching action for its package manager, every run downloads and installs the full dependency tree before any test can start, and that cost multiplies across every matrix shard running the same install. This is pure overhead: it does not vary with test content, only with how many packages the repo depends on and how many jobs repeat the same install.

Confirm it: `grep -rn 'actions/cache\|setup-node.*cache\|setup-python.*cache\|setup-go.*cache' .github/workflows/`. If that returns nothing but the job runs `npm ci`, `pnpm install`, `pip install`, or an equivalent, the install has no cache to restore from and pays full cost every run.

Fix: [Dependency caching](https://starsling.dev/best-practices/github-actions/cache-dependencies) (`ci.cache.dependency-cache`, detection mode static)

### 4. The job tests everything regardless of what the diff touched

In a monorepo or a repo with a test runner that supports a changed-files mode, running the full suite on every push means a one-line fix to an unrelated package pays the same cost as a change that touches the whole codebase. Tools like Turborepo, Nx, and vitest's changed-file mode can scope the run to what the diff actually affects, using the merge base as the comparison point rather than the previous commit.

Confirm it: Check whether `turbo.json`, `nx.json`, or a comparable workspace config exists in the repo, and whether the test invocation in the workflow includes a `--filter`, `affected`, or `--changed` flag pointed at `origin/${{ github.base_ref }}`. If the config exists but the workflow step runs the plain, unscoped test command, this is your cause.

Fix: [Change-scoped builds](https://starsling.dev/best-practices/github-actions/build-only-affected) (`ci.build.change-scoped`, detection mode runtime)

### 5. Fixed sleeps in the test source inflate every run regardless of actual readiness

Calls like `page.waitForTimeout`, `cy.wait(1000)`, or a raw `sleep()` block the test for a fixed duration whether or not the thing being waited on is actually ready. Locally, on a fast warm machine, these delays are the same fixed cost, but they stand out far more in CI once they're multiplied across many tests and combined with a colder, more contended runner.

Confirm it: `grep -rn 'waitForTimeout([0-9]\|cy\.wait([0-9]\|await sleep(\|await delay(' packages/ e2e/ tests/ --include='*.spec.ts' --include='*.test.ts'`. Sum the matched durations against the total suite time. If fixed waits account for a meaningful share of the total, replace them with event-driven waits before looking anywhere else.

Fix: [Polling waits, not fixed sleeps](https://starsling.dev/best-practices/github-actions/replace-fixed-sleeps-with-polling) (`ci.hygiene.polling-waits`, detection mode static)

<a id="verify"></a>

## Hand it to an agent

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this diagnosis against your repository and report which cause it found:

Diagnose why the test job in this repo's GitHub Actions workflow runs much slower than the same suite does locally. Do not change anything yet. First, open the workflow YAML under .github/workflows/ and identify the test job: check whether it uses a strategy.matrix with a --shard flag (Playwright, vitest), and whether it's the single longest job in the workflow's run history. Second, check whether the test job's install step has a matching caching action (actions/cache, or setup-node/setup-python/setup-go with cache: true) for its package manager. Third, if this is a monorepo (turbo.json or nx.json present), check whether the test command actually scopes to the diff against the merge base rather than running everything. Fourth, grep the test source (packages/, e2e/, tests/) for fixed-duration waits like waitForTimeout, cy.wait(1000+), or raw sleep() calls, and estimate their total contribution to run time. Report back which of these four causes you found evidence for, ranked by likely impact, before proposing any fix.

<a id="related-pages"></a>

## Related pages

- [Shard tests across parallel jobs in GitHub Actions](https://starsling.dev/best-practices/github-actions/shard-tests)
- [Split long GitHub Actions jobs into parallel work](https://starsling.dev/github-actions/optimizations/split-long-running-jobs)
- [GitHub Actions cache: dependencies, keys, and cache hits](https://starsling.dev/best-practices/github-actions/cache-dependencies)
- [Build and test only what changed in GitHub Actions](https://starsling.dev/best-practices/github-actions/build-only-affected)
- [Replace fixed CI sleeps with bounded readiness polling](https://starsling.dev/best-practices/github-actions/replace-fixed-sleeps-with-polling)
- [Why GitHub Actions is slow, and how to fix it](https://starsling.dev/github-actions-too-slow)

<a id="related-symptoms"></a>

## Related symptoms

Other symptoms on this site that share a likely cause with this one. If none of the causes above is yours, one of these is usually the page you wanted.

- [Why is the Playwright job so slow in CI](https://starsling.dev/github-actions/problems/slow-playwright)
- [A job hits a timeout, or hangs until GitHub kills it](https://starsling.dev/github-actions/problems/github-actions-timeouts)
- [npm install or npm ci taking a large slice of every run](https://starsling.dev/github-actions/problems/slow-npm-install)

<a id="sources"></a>

## Sources

- [Caching dependencies to speed up workflows](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows)
- [Using a matrix for your jobs](https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs)
- [Playwright: sharding tests between multiple machines](https://playwright.dev/docs/test-sharding)
- [Turborepo: filtering tasks by what changed](https://turborepo.com/docs/crafting-your-repository/running-tasks#filtering-by-source-control-changes)
