---
title: "Slow Tests in CI | StarSling"
description: "Your GitHub Actions test job runs far slower than the same suite locally. Check the common causes in order and confirm each one with workflow evidence."
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-09-08

A test job that outruns the local suite by many times over 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, expensive production-grade work inside test fixtures, teardown repeating database deletes for IDs already cleaned up, 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)
- [Shipped by StarSling](#shipped-by-starsling)
- [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, a workflow that runs the whole suite in one sequential job 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. Test helpers can add another hidden cost when they default to production-strength password hashing or other intentionally expensive cryptography for every fixture. Better Auth removed that waste in public PR #10879 by using a fast test-only default while preserving NFKC normalization and caller-supplied password implementations. That boundary is essential: never weaken the production hasher, and keep the production implementation in tests that verify stored-hash formats, upgrades, compatibility, or security behavior. Teardown can add redundant database work too: before the Better Auth #10762 cleanup fix, each pass revisited an accumulated list of created IDs; after it, successful cleanup removes IDs from the pending list while thrown failures and explicit retry results remain pending. Profile fixture setup and teardown alongside the catalog-backed causes below. The remaining causes are ordered by leverage: 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.
- Tests that create many users or credentials spend a disproportionate share of runtime inside password hashing or other production-strength cryptography.
- Teardown repeatedly attempts to delete IDs that earlier cleanup passes already removed.
- 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/skills/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. Profile shared test setup and search test helpers for password hashing (`argon2`, `bcrypt`, `scrypt`, or production password adapters). If fixture creation dominates, use a fast test-only implementation without changing production defaults, normalization, or explicit caller overrides. Keep the production hasher explicitly enabled in tests for stored-hash formats, upgrades, compatibility, and security behavior.
6. Inspect teardown bookkeeping and compare successive cleanup passes: if the pending IDs still include rows deleted successfully on an earlier pass, remove IDs only after confirmed successful cleanup and retain every unsuccessful or deferred cleanup according to the helper's result contract. Better Auth #10762 retains thrown failures and explicit `retry` results.
7. 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.
8. 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="shipped-by-starsling"></a>

## Shipped by StarSling

A public Better Auth change shows how to remove redundant teardown work while preserving every cleanup item that still needs another attempt.

### Up to 5.4x faster

better-auth/better-auth#10762: Keep only pending rows during test cleanup
Mongo adapter Vitest duration: 24.3s -> 4.5s in the PR's benchmark, with 50 runs per arm.

Successful cleanup removes completed IDs from the pending list, while unsuccessful or deferred work remains for a later attempt. Better Auth's example preserves rows when cleanup throws or requests a retry.

Diff: Before: each teardown revisited accumulated created-row IDs. After: cleanup keeps only outstanding work, preserving thrown failures and explicit retry results.

Source: [better-auth/better-auth#10762](https://github.com/better-auth/better-auth/pull/10762), merged 2026-09-03.
### Customer case study

[How Better Auth got 2x faster E2E tests and cut 20,000 CI minutes a month with StarSling](https://starsling.dev/customers/better-auth)

Per-job E2E from 2m 22s down to 1m 04s. Self-driving CI for the auth library every team's PR queue gates on.


<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 an unsharded suite is bounded by the sum of every test file rather than by the slowest shard.

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: [Shard tests across parallel jobs in GitHub Actions](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: [Split long GitHub Actions jobs into parallel work](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: [GitHub Actions cache: dependencies, keys, and cache hits](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: [Build and test only what changed in GitHub Actions](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: [Replace fixed CI sleeps with bounded readiness polling](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, profile shared fixture setup and search test helpers for production-strength password hashing or other deliberately expensive cryptography; if it dominates, propose a fast test-only default while preserving normalization and explicit overrides, and never change the production hasher. Keep the production implementation explicitly enabled in tests that verify stored-hash formats, upgrades, compatibility, or security behavior. Fourth, inspect teardown bookkeeping for cleanup passes that revisit successfully deleted IDs; propose removing IDs only after confirmed successful cleanup and retaining every unsuccessful or deferred cleanup according to the helper's result contract. Better Auth #10762 retains thrown failures and explicit `retry` results. Fifth, 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. Sixth, 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 causes you found evidence for, ranked by likely impact, before proposing any fix.

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

## Related pages

- [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)
- [Better Auth #10879: faster test-only password hashing](https://github.com/better-auth/better-auth/pull/10879)
- [Better Auth #10762: retain only pending test cleanup rows](https://github.com/better-auth/better-auth/pull/10762)
