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

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.

hybrid · repo + run history
How StarSling works

AI agents find the cause

StarSling agents run this diagnosis against your workflows and run history, identify which cause is yours, and open the fix as a reviewable PR.

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.

How to diagnose it

  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.

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.

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
  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
  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
  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
  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

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.

Prompt for your coding agent
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.

Go further

Find the cause, fix them all, or keep them fixed.

You have the prompt that works out which cause applies. Here is how much further you can take it, each step doing more for you than the last.

  1. Find the cause

    Copy the diagnosis prompt above

    Hand it to your coding agent to confirm which cause applies in your repo. It reports back what it found and changes nothing.

  2. Fix them all, once

    Install the ci-speedup skill

    One prompt audits your whole repo against all 73 ci-speedup patterns (these 7 plus 66 more) and hands your agent every fix at once. Open source, MIT, runs locally.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

All GitHub Actions problems

Sources

1Caching dependencies to speed up workflows (opens in new tab)

2Using a matrix for your jobs (opens in new tab)

3Playwright: sharding tests between multiple machines (opens in new tab)

4Turborepo: filtering tasks by what changed (opens in new tab)

5Better Auth #10879: faster test-only password hashing (opens in new tab)

6Better Auth #10762: retain only pending test cleanup rows (opens in new tab)

Last updated 2026-09-08