GitHub Actions for Parallel FastAPI + React Testing: Optimizing CI/CD Feedback Loops in Monorepos
I spent three months watching our CI pipeline consume 8 minutes per pull request. Eight minutes. That's long enough to switch contexts, check Slack, and forget what you were debugging. At CitizenApp, we have 9 AI features and 40+ API endpoints—skipping CI isn't an option, but neither is waiting forever for feedback.
The fix wasn't complicated, but it required thinking differently about how GitHub Actions executes jobs. Most teams either run everything sequentially (slow) or parallelize recklessly without understanding database isolation (flaky). I'm going to show you the exact pattern that cut our CI time to 90 seconds and kept our flake rate at zero.
The Problem: Sequential Testing Kills Productivity
Here's what our original workflow looked like:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run FastAPI tests
run: pytest tests/
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install React dependencies
run: npm install
- name: Run React tests
run: npm run test
This is a single job doing everything sequentially. Python dependencies install while Node sits idle. FastAPI tests run while React dependencies aren't even installed yet. It's like waiting for a single cashier when you have five registers.
The real cost: 8 minutes total, and you're blocked for any feedback.
The Solution: Matrix Strategy + Service Containers
I prefer running backend and frontend tests in separate parallel jobs with isolated PostgreSQL instances. Here's why:
- Different environments: Backend needs PostgreSQL; frontend doesn't. Why install both in one job?
- Independent scaling: If React tests take 20 seconds and FastAPI tests take 90, parallelize them instead of concatenating.
- Service isolation: Each test job gets its own database instance on a unique port. No race conditions, no port conflicts.
Here's the workflow:
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
backend-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run FastAPI tests
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/testdb
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: pytest tests/ -v --cov=src --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run React tests
run: npm run test:ci
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: "3.11"
cache: "pip"
- run: pip install ruff black
- run: ruff check .
- run: black --check .
This runs three jobs in parallel: backend-test, frontend-test, and lint. The PR is blocked only on the slowest job. In our case, that's backend tests at ~90 seconds. Frontend finishes in 25 seconds. Linting takes 8 seconds.
Why Service Containers Matter
The services key in GitHub Actions is magic. It spins up a PostgreSQL container for that job only, with health checks built in. Each test job gets its own isolated database—no test pollution, no port conflicts if you ever run multiple workflows simultaneously.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
The --health-cmd pg_isready is critical. GitHub Actions waits for this check to pass before running your steps. I learned this the hard way—without it, your test job starts and immediately fails because the database isn't ready yet.
Intelligent Dependency Caching
Notice cache: "pip" and cache: "npm". This is GitHub Actions' built-in caching—it hashes your requirements.txt or package-lock.json and restores dependencies automatically. On subsequent runs, this saves 30–40 seconds alone.
For Python:
- uses: actions/setup-python@v4
with:
python-version: "3.11"
cache: "pip"
For Node:
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
This is not optional. Without caching, you're downloading and installing dependencies every single run. That's 3–4 minutes you don't need to waste.
Gotcha: Database Initialization Timing
Here's what burned me initially: I was running pytest immediately after installing dependencies, assuming the PostgreSQL container would be ready. It wasn't. The container was still starting up, and tests failed with "connection refused" errors.
The fix: The --health-cmd and --health-retries options handle this, but you can also add explicit waits in your test setup:
# tests/conftest.py
import time
from sqlalchemy import create_engine
from sqlalchemy.exc import OperationalError
def wait_for_db(max_retries=30):
for attempt in range(max_retries):
try:
engine = create_engine(os.getenv("DATABASE_URL"))
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
return
except OperationalError:
if attempt == max_retries - 1:
raise
time.sleep(1)
@pytest.fixture(scope="session", autouse=True)
def setup_db():
wait_for_db()
# Run migrations
subprocess.run(["alembic", "upgrade", "head"], check=True)
Without this explicit wait, you get flaky tests that pass 80% of the time. Not fun in production.
The Numbers
- Before: 8 minutes, 2 seconds (sequential)
- After: 1 minute, 35 seconds (parallel, cached)
- Improvement: 80% faster
That's not premature optimization—that's respecting developers' time. Every developer on CitizenApp makes 10–15 PRs per week. Over a year, we're saving roughly 200 hours of waiting.
The Takeaway
Parallelization is a force multiplier, but only if you respect dependencies and isolation. Backend and frontend tests have different needs—acknowledge that in your workflow design. Use service containers for database-dependent tests. Cache aggressively. And always, always verify your database is actually ready before you run tests.
Top comments (0)