Ordered by how often each one turns out to be the answer.
Different dependency versions resolved
CommonYour lockfile pins exact versions and the install command you use locally may not honour it. `npm install` can update the lockfile to satisfy a range; `npm ci` installs exactly what is locked and fails if the lockfile and manifest disagree. If CI uses one and you use the other, the two machines are running different code from the same commit.
Confirm
npm ci --dry-run 2>&1 | tail -20
An error that the lockfile is out of sync with package.json, or a resolved version differing from what you have in node_modules
Fix
- Use the reproducible install in both places — `npm ci`, `yarn install --frozen-lockfile`, `pip install -r requirements.txt` against pinned versions, or your ecosystem's equivalent.
- Commit the lockfile, and treat a dirty lockfile after install as a failure rather than a diff to ignore.
- Delete local node_modules and reinstall from the lockfile before concluding the runner is wrong.
rm -rf node_modules && npm ci
Reproduces the runner's install locally. Safe: it only removes downloaded dependencies.
Tests depend on order, or on each other
CommonA test that passes alone and fails in the suite is reading state another test left behind — a shared database row, a module-level singleton, a stubbed clock never restored. CI often runs tests in a different order or in parallel workers, which exposes the dependency your local sequential run happened to satisfy.
Confirm
<your runner> --runInBand --shuffle # jest; use the equivalent seed/shuffle flag for your framework
The failure reproducing locally once order changes. If shuffling reproduces it, the test is coupled rather than the environment being different
Fix
- Make each test set up and tear down its own state instead of relying on what ran before it.
- Find the coupling by running the failing test alongside the suite and bisecting, not by re-running it alone — alone is the case that already passes.
- Resist adding a sleep. It converts a deterministic failure into an intermittent one.
An environment variable is missing or different
CommonYour shell has variables accumulated from a .env file, a profile script or an earlier export. The runner has only what the workflow sets. A missing value often does not throw — it reads as undefined and the code takes a different branch, which is why the failure can appear far from the cause.
Confirm
env | sort > /tmp/local.env # then compare against the runner's printed environment
Variables present locally and absent in CI. Compare names only — never paste the values into a log or an issue
Fix
- Declare every variable the application needs, and fail fast at startup when one is missing rather than defaulting silently.
- Keep a committed .env.example listing the names with placeholder values so the required set is documented.
- Set them in the workflow or the CI provider's secret store, not in the image.
The filesystem is case-sensitive on the runner and not on your machine
OccasionalmacOS and Windows default to case-insensitive filesystems; Linux runners are case-sensitive. `import './Utils'` finds `utils.ts` locally and finds nothing in CI. Git also preserves the case it first recorded, so a later rename that differs only in case may not have been committed at all.
Confirm
git ls-files | grep -i '<the filename from the error>'
The name Git actually stores, compared against the case used in the import that fails
Fix
- Correct the import to match the stored filename exactly.
- If the file itself is misnamed, rename it through Git so the change is recorded: `git mv Utils.ts utils.ts`.
- A case-only rename sometimes needs an intermediate name on a case-insensitive filesystem.
A dependency is not up yet when the tests start
CommonLocally your database has been running for days. In CI it starts seconds before the tests, and a container being *started* is not the same as a database being *ready to accept connections*. The failure looks like a connection error in the first test and nowhere else, which makes it read as a flaky test.
Confirm
<your CI client> logs <service-container> | head -30
The service's own readiness line, and whether its timestamp is before or after the first test's connection attempt
Fix
- Wait for readiness rather than for the container to exist — a health check, or a short retry loop against the real protocol.
- Where the CI provider supports service health checks, use them; they gate the job rather than being raced by it.
- Prefer a bounded retry to a fixed sleep. A sleep is either too short on a slow day or wasted on every fast one.
A cache is carrying something from a previous run
OccasionalDependency and build caches are keyed on a hash, and a key that does not include everything the cache depends on will restore stale content. This is the one case where the runner has something your clean machine does not, and it typically appears right after a dependency or toolchain change.
Confirm
# Re-run the job with caching disabled, or change the cache key, then compare
The failure disappearing without a code change, which implicates the cache rather than the commit
Fix
- Include the lockfile hash and the toolchain version in the cache key so a change invalidates it.
- Never cache the build output itself unless you can prove the key covers every input.
- Clear the cache when a dependency upgrade behaves inexplicably; it is cheap and it rules a whole class out.
The runner is smaller than your machine
OccasionalHosted runners have modest CPU and memory. A test suite that fits comfortably in 32GB locally can be killed at 7GB, and a parallel build that saturates two cores runs slower than the timeout allows. The symptom is usually a kill or a timeout rather than an assertion failure.
Confirm
# In the failing job, print limits before the step: ulimit -a; nproc; free -m
An exit code of 137 — see the exit-code explainer — or a step ending exactly on the timeout boundary
Fix
- Reduce parallelism in CI rather than assuming the local setting transfers.
- Split a long suite into jobs that run concurrently instead of one job doing everything.
- Raise the runner size only after confirming the workload genuinely needs it.