CI Too Slow, Nobody Watches | 7 Strategies to Speed Up GitHub Actions and pytest

“Can’t wait for CI to finish.” “It’ll pass anyway.” — When CI takes too long, teams stop waiting and stop watching. This article explains how slow CI quietly destroys engineering culture, and walks through 7 concrete strategies to speed it up: pytest-xdist parallelization, stage splitting, caching, mocking, and more.

CI does more damage when it’s too slow than when it’s broken. A CI that fails in 3 minutes and a CI that fails in 30 minutes both catch the same bug — but they have completely different effects on the team.

📌 Who This Article Is For

  • Teams where CI takes more than 10 minutes and people have given up waiting
  • Engineers where “merge without waiting for CI” has become the norm
  • QA engineers who want specific, actionable techniques to speed up GitHub Actions
  • Anyone tired of CI getting slower every time the test suite grows

✅ What You Will Learn

  • The 7 root causes of slow CI and how each one leads to “nobody watches” culture
  • Ready-to-implement speedup strategies: pytest-xdist, stage splitting, caching, mocking
  • An early-warning checklist to catch the problem before the culture collapses

👤 About the Author

Written by Yoshi, a QA engineer and test automation engineer with over 15 years of hands-on experience. Having lived through multiple projects where CI became something nobody waited for, and having recovered from E2E-heavy, no-parallelization, no-caching setups, these strategies come directly from the field.

📖 How This Article Differs from Related Content

  • Escaping Flaky Test Hell: CI trust collapse caused by flaky results → If your tests are unreliable, start there
  • GitHub Actions × Playwright CI Setup: How to configure CI → If you need CI setup instructions, start there
  • This article: How CI being too slow causes cultural collapse — and 7 concrete speed-up strategies

📌 Key Takeaways

  • CI speedup priority order: ① parallelization → ② stage splitting → ③ caching → ④ mocking → ⑤ test audit
  • The “nobody waits” habit tends to set in over a period of months — and the longer it goes unaddressed, the harder it is to reverse. Act at the warning signs
  • The ideal target is “first feedback within 5 minutes of a push.” When it exceeds 10 minutes, that’s when most teams start looking at improvements

“Is CI done?” “Still running. Just merge it for now.” — Once that conversation happens even once, the CI speed problem has already become a culture problem.

Unlike flaky tests, slow CI doesn’t break anything. It just quietly, gradually erodes the culture of checking CI at all. This article covers the collapse process that slow CI triggers — and the concrete strategies to stop it.

How Slow CI Leads to “Nobody Watches” Culture

StageTeam BehaviorTypical CI Duration
① Tolerable“A bit long, but I can wait”under 5 min
② Disengaged“I’ll watch CI while doing other things”5–10 min
③ Ignoring“Merging without waiting for CI” becomes the norm10–20 min
④ Collapsed“CI is red, whatever, merging anyway”20+ min
⚠️ Why “nobody watches” is the most dangerous state: Merging with a red CI increases the risk of production incidents. Worse: once the habit of ignoring CI is established, fixing the speed problem alone won’t bring the culture back easily. Culture breaks faster than CI gets slow.

7 Root Causes of Slow CI

Leaving CI as “just slow somehow” means it never gets fixed. Identifying which root cause is driving the slowness is the first step to making real improvements.

CauseTypical SymptomSpeed Impact
① E2E-heavyPolicy of “write everything as an E2E test”🔴 High
② No parallelizationTests run sequentially, one at a time🔴 High
③ No cachingpip install / npm install runs from scratch every time🔴 High
④ External I/O dependencyTests hit real DBs and external APIs🟡 Medium
⑤ No stage designAll tests run in one job, all at once🟡 Medium
⑥ Unnecessary sleeptime.sleep(3) scattered throughout tests🟡 Medium
⑦ Bloated test suiteOld, duplicate tests have never been cleaned up🟢 Low–Medium

Fix ①: Parallelize with pytest-xdist

The highest-impact fix. pytest-xdist distributes tests across multiple CPU cores. For suites with 100+ tests, it commonly cuts execution time by 2–4x.

# Install
# pip install pytest-xdist

# Local: auto-detect CPU count and run in parallel
pytest tests/ -n auto

# GitHub Actions: specify worker count explicitly
pytest tests/ -n 4

# For CI-only activation (recommended — avoids conflicts with local debugging)
# Set in GitHub Actions step, not in pytest.ini:
#   run: pytest tests/ -n auto
#
# ⚠️ Adding -n auto to pytest.ini addopts causes issues with:
# VSCode Test Explorer, debugger, coverage measurement, flaky investigation
⚠️ Before parallelizing — check test independence: Parallelization requires tests that don’t share state. Shared DBs, files, or global state will cause data conflicts and create new flakiness. Verify with pytest --randomly-seed=1234 (requires pip install pytest-randomly) to confirm there are no order-dependent tests.
💡 Practical tip: For E2E tests (Playwright/Selenium), confirm browser instances are independent per test. A scope="function" driver fixture is generally safe to parallelize. Note: state-modifying scope="session" fixtures (login state, DB writes) are not safe to parallelize — workers share state and produce flakiness. Read-only data or immutable config fixtures are safe to keep as session-scoped.

Fix ②: Split CI into Stages for Early Feedback

“Nothing to learn until all tests finish” is a design that has a ceiling regardless of how fast it runs. Splitting into unit → integration → E2E stages means bugs surface at the earliest possible point.

# GitHub Actions stage split example (.github/workflows/test.yml)
# Note: for large projects, consider reusable workflows or composite actions
# to avoid repeating the setup steps below in every job.
name: CI Pipeline

on: [push, pull_request]

jobs:
  # Stage 1: unit tests (target: under 1 minute)
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"              # built-in pip cache — no separate cache step needed
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run unit tests
        run: pytest tests/unit/ -n auto --tb=short

  # Stage 2: integration tests (target: under 3 minutes)
  # runs only if unit tests pass
  integration-tests:
    needs: unit-tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run integration tests
        run: pytest tests/integration/ -n auto

  # Stage 3: E2E tests (target: under 5 minutes)
  # runs only if integration tests pass
  e2e-tests:
    needs: integration-tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Install Playwright browsers
        run: playwright install chromium --with-deps
      - name: Run E2E tests
        run: pytest tests/e2e/ -n 2
💡 The value of stage splitting: If a unit test fails in 1 minute, the developer gets feedback without waiting for 15 minutes of E2E tests. Designing CI to deliver early signals turns it from something you wait for into something you rely on.

Fix ③: Cache Dependencies to Skip Reinstalls

Running pip install from scratch every run can cost 2–5 minutes by itself. When dependencies haven’t changed, caching skips the install entirely.

# GitHub Actions caching example

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # ✅ Option A: built-in pip cache via setup-python (simpler)
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: "pip"             # invalidates when requirements.txt changes

      - name: Install dependencies
        run: pip install -r requirements.txt

      # ✅ Option B: manual cache with actions/cache (more control)
      - name: Cache pip packages
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            ${{ runner.os }}-pip-

      # ✅ Cache Playwright browser binaries
      - name: Cache Playwright browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ hashFiles('requirements.txt') }}

      - name: Install Playwright browsers (only when cache misses)
        run: playwright install chromium --with-deps
💡 Cache key design: Using hashFiles('requirements.txt') ensures the cache only invalidates when dependencies actually change. Playwright browser caching is particularly valuable — it saves the 1–3 minute install on almost every run. The cache: "pip" option in actions/setup-python@v5 is the simpler option and handles the key design automatically.

Fix ④: Mock External I/O to Remove Wait Time

Tests that hit real DBs, external APIs, or email services add network latency and third-party response time as a CI bottleneck.

from unittest.mock import patch, MagicMock
import requests
import pytest

from sqlalchemy import create_engine
from sqlalchemy.orm import Session, declarative_base

# Base definition (in real projects, this lives in a models file)
# from sqlalchemy.orm import declarative_base
# Base = declarative_base()
Base = declarative_base()

# ❌ Slow: calls a real external API every time
def test_send_notification():
    result = requests.post(
        "https://api.slack.com/api/chat.postMessage",
        json={"channel": "#test", "text": "Test complete"}
    )
    assert result.status_code == 200
    # 200–500ms per network round-trip

# ✅ Fast: mock the external API — returns instantly
@patch("requests.post")
def test_send_notification_fast(mock_post):
    mock_post.return_value = MagicMock(status_code=200)
    result = requests.post(
        "https://api.slack.com/api/chat.postMessage",
        json={"channel": "#test", "text": "Test complete"}
    )
    assert result.status_code == 200
    # Under 1ms — no network involved

# ✅ Use an in-memory DB fixture instead of a real database
@pytest.fixture
def db_session():
    """Fast DB fixture using SQLite in-memory database"""
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    with Session(engine) as session:
        yield session
    # Automatically closes and rolls back when the with block exits

Fix ⑤: Replace time.sleep with Explicit Waits

If 100 tests each have one time.sleep(2), that’s over 3 minutes of fixed waste. Sleep is also a primary cause of flakiness — it’s the first thing to clean up from a CI speed perspective.

# Find all time.sleep usage (grep -R avoids matching asyncio.sleep or page.wait_for_timeout)
# grep -R "time.sleep(" ./tests

# ❌ Slow: fixed wait — always waits the full duration
import time
time.sleep(3)
driver.find_element(By.ID, "result").click()

# ✅ Fast: wait only until the condition is met
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

wait = WebDriverWait(driver, 10)
element = wait.until(EC.element_to_be_clickable((By.ID, "result")))
element.click()
# Executes the instant the element is ready — average 0.3s instead of 3s

Fix ⑥: Profile and Audit Slow Tests

“We need all of them” without a regular audit leads to CI bloat. Profiling for the slowest 10–20 tests immediately surfaces the highest-value cleanup targets.

# pytest built-in feature — no additional plugin required
pytest tests/ --durations=20

# Recommended: only show tests that take 1 second or more (reduce noise)
pytest tests/ --durations=20 --durations-min=1.0

# Example output:
# 15.23s call  tests/e2e/test_checkout.py::test_full_purchase_flow
#  8.91s call  tests/e2e/test_login.py::test_login_with_mfa
#  6.44s call  tests/integration/test_report.py::test_generate_pdf_report
#  3.12s call  tests/unit/test_user.py::test_create_user  ← investigate: unit test over 3s

# ❌ Warning patterns:
# - Unit test taking 3+ seconds → external I/O may have leaked in
# - E2E test taking 20+ seconds → scope may be too broad
# - Multiple tests that are minor variations of the same scenario → consolidate with parametrize
💡 Audit criteria: Consider removing, consolidating, or moving tests that match any of: ①unit test taking 3+ seconds (likely has external I/O), ②E2E test taking 30+ seconds (can the scope be split?), ③not failed or modified in 6+ months, ④scenario substantially overlaps with another test.

Fix ⑦: Fix the Inverted Test Pyramid

When CI is fundamentally slow, the root cause is often an inverted test pyramid — E2E tests keep accumulating while unit and integration tests stay thin.

Test TypeIdeal RatioTypical SpeedWhat It Covers
Unit tests70%< 0.1s eachIndividual functions and classes
Integration tests20%< 1s eachModule interactions, APIs
E2E tests10%10–30s eachCore user scenarios
⚠️ What doesn’t need to be E2E: The following are better covered at the unit or API test level: ①validation logic (required fields, character limits) → unit test, ②API response format and status codes → API test (requests / Playwright API), ③error message display conditions → integration test. Reserve E2E for the critical user-facing scenarios that justify the cost.

Act Before It Collapses: Early Warning Checklist

3 or more matching items means it’s time to start improving now.

Check ItemApplies?
CI takes more than 10 minutes to complete✅ / ❌
PRs are sometimes merged before CI finishes✅ / ❌
Tests run sequentially — no parallelization✅ / ❌
pip install / npm install runs from scratch every time✅ / ❌
10 or more time.sleep() calls in the test code✅ / ❌
E2E tests make up more than 50% of the suite✅ / ❌
All tests are in one job — nothing runs until everything is done✅ / ❌

🚀 Start Here: Top 3 Immediate Actions

1stParallelize with pytest-xdist (do it today — highest impact)
pip install pytest-xdist → add -n auto to your CI run command
2ndAdd caching to GitHub Actions (takes 1–2 hours)
Add cache: "pip" to setup-python, and cache Playwright browsers
3rdFind and replace time.sleep calls
grep -R "time.sleep(" ./tests — list every instance and replace with explicit waits

FAQ

Q. What is a realistic target CI execution time?

A practical target is getting the first feedback within 5 minutes of a push — many teams find this is around where waiting becomes viable again. That said, what’s tolerable varies by project size and team workflow. When CI exceeds 10 minutes, it’s generally worth considering improvements. A concrete starting goal: “reduce current time by 30%.” That’s more actionable than an absolute number.

Q. pytest-xdist makes my tests unstable — what do I do?

Increased flakiness after parallelization almost always traces back to shared state between tests or execution order dependencies. Run pytest --randomly-seed=1234 to check for order dependencies, and make test data unique with uuid. State-modifying scope="session" fixtures are the most common culprit — switching them to scope="function" resolves most cases.

Q. We have 200 E2E tests. Can we parallelize all of them?

If tests are independent (no shared DB or global state), yes. But 200 E2E tests is itself a warning sign. Auditing which ones actually need to be E2E — and moving validation logic, API response checks, and error message tests to unit or API tests — combined with parallelization can produce much larger gains than parallelization alone.

Q. How much does GitHub Actions caching actually help?

When dependencies are unchanged, caching typically saves 2–5 minutes for pip install and 1–3 minutes for Playwright browser install — often 3–8 minutes saved per run. It consistently has one of the best effort-to-impact ratios of any CI speedup. The cache: "pip" option in actions/setup-python@v5 is the simplest implementation. Just make sure your cache key is tied to requirements.txt so stale dependencies don’t silently slip through.

CI exists to protect code quality — but when it takes too long, it becomes something nobody waits for. A technical problem silently becomes a cultural one. Measure your current execution time today, and make one improvement. A team with a 5-minute CI and a team with a 30-minute CI will look very different a year from now.

📋 Summary

  • Slow CI creates cultural collapse. The habit of ignoring CI tends to set in over months — the longer it goes unaddressed, the harder it is to reverse
  • Fix priority: ① pytest-xdist parallelization (highest impact, do today) → ② caching (saves 3–8 min) → ③ stage splitting (early feedback)
  • Find time.sleep with grep -R "time.sleep(" ./tests and replace with WebDriverWait / expect()
  • E2E-heavy test suites are the root cause of structural CI slowness. Target 70% unit / 20% integration / 10% E2E
  • Use pytest --durations=20 --durations-min=1.0 to surface the slowest tests (built-in — no plugin needed)
  • Parallelization flakiness → data conflict. Use unique data per test and avoid state-modifying session-scoped fixtures
Copied title and URL