Designing Multi-Stage CI/CD Pipelines for React Apps
Shipping a React application reliably requires more than a single build-and-deploy job. Without discrete validation gates, a broken type annotation can deploy alongside a broken canary, and the failure surface spans the entire pipeline. This page shows how to decompose a frontend workflow into independently executable stages β each with clear entry criteria, runnable config, and verification commands β so failures are isolated, fast, and recoverable. It is part of the broader CI/CD Pipeline Architecture & Fundamentals coverage on this site.
Prerequisites
Before wiring up the pipeline, confirm the following are in place:
How Multi-Stage Pipelines Work
A multi-stage pipeline maps each concern β static analysis, unit tests, compilation, integration tests, promotion β onto a separate job. Jobs declare explicit needs: relationships, forming a directed acyclic graph (DAG). GitHub Actions executes independent branches of the graph in parallel; dependent jobs wait for their predecessors to succeed before starting.
The critical property is failure isolation: a lint failure does not consume build-runner minutes. A failed integration test does not trigger a deploy. Because each job runs in a fresh environment, there is no shared mutable state between stages β which eliminates an entire class of flakiness that plagues monolithic scripts.
The diagram below shows the DAG for the pipeline implemented on this page.
Step-by-Step Implementation
Step 1 β Define the validate job
The validate job must be the fastest possible gate. It runs lint, TypeScript type checking, and unit tests in sequence. Fail here in under 90 seconds so developers can iterate without waiting for compilation.
name: React Multi-Stage Pipeline
on:
push:
branches: [main, staging]
pull_request:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm' # restores ~/.npm using lockfile hash
- run: npm ci # deterministic install from package-lock.json
- run: npm run lint # ESLint; fail fast on rule violations
- run: npm run type-check # tsc --noEmit; catches type errors without emitting
- run: npm test -- --coverage --ci
# --ci disables interactive mode; coverage enforces thresholdsVerification: confirm the job exits non-zero on a deliberate lint error before merging this config.
Step 2 β Add the matrix build job
The build job compiles the React application and uploads the immutable bundle as a named artifact. A matrix strategy validates compatibility across Node.js versions in parallel β three parallel jobs instead of three sequential runs.
build:
needs: validate # blocks until validate succeeds
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20, 22, 24]
fail-fast: true # cancel remaining matrix legs on first failure
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build
env:
NODE_ENV: production
# Do NOT bake REACT_APP_API_URL here β inject at runtime
- uses: actions/upload-artifact@v4
with:
# Name includes commit SHA and Node version for traceability
name: react-bundle-${{ github.sha }}-node${{ matrix.node-version }}
path: dist/
retention-days: 14 # balances traceability with storage cost
if-no-files-found: errorVerification: after a successful run, confirm three artifacts appear under the runβs summary page β one per Node.js version.
For monorepo setups that need affected-path filtering before reaching the build stage, see how to structure a monorepo CI pipeline for Next.js and Node.
Step 3 β Isolate integration tests
Integration tests hit real API contracts, seed databases, and spin up service containers. They are the slowest gate and must not block the critical path to staging. Run them in parallel with deploy-canary rather than sequentially before it.
integration-test:
needs: build
runs-on: ubuntu-latest # or a dedicated label for heavier runners
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: ci
POSTGRES_DB: app_test
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci
- run: npm run db:seed:test
env:
DATABASE_URL: postgresql://postgres:ci@localhost/app_test
- run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:ci@localhost/app_testVerification: confirm test:integration exits 0 and that the postgres health check appears in the runner log before tests start.
Step 4 β Deploy with canary routing
The canary job downloads the exact artifact validated in the matrix (pinned to Node 22, the LTS version), deploys to edge infrastructure at 10 % traffic weight, then validates the endpoint before the workflow proceeds.
deploy-canary:
needs: build
if: github.ref == 'refs/heads/main' # restrict to main branch merges
runs-on: ubuntu-latest
environment: canary # requires manual approval if configured
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: react-bundle-${{ github.sha }}-node22
path: dist/
- name: Deploy to edge at canary weight
run: ./scripts/deploy.sh --strategy canary --weight 10
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
RUNTIME_API_URL: ${{ secrets.STAGING_API_URL }}
# RUNTIME_API_URL injected at deploy time β not baked into the bundle
- name: Synthetic health check
run: |
for i in $(seq 1 5); do
curl -sf https://app.example.com/api/health && break
sleep 10
done
# Retries 5 times; fails the job if the endpoint never returns 200
promote-production:
needs: [deploy-canary, integration-test] # requires both gates
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: react-bundle-${{ github.sha }}-node22
path: dist/
- name: Promote to 100 % production
run: ./scripts/deploy.sh --strategy promote --weight 100
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
RUNTIME_API_URL: ${{ secrets.PROD_API_URL }}Verification: after promotion, query real-user monitoring or your CDNβs traffic split API to confirm weight is at 100 %. Check that the canary weight returns to 0 so the next release starts from a clean baseline.
Configuration Reference
| Key | Type | Default | Effect |
|---|---|---|---|
strategy.matrix.node-version |
array |
β | Spawns one parallel job per listed version |
strategy.fail-fast |
bool |
true |
Cancels remaining matrix legs on first failure; set false to collect all failures |
needs |
string | array |
β | Declares job dependencies; job is skipped if any dependency fails |
if |
expression |
β | Conditional execution; use github.ref to restrict deploy jobs to specific branches |
environment |
string |
β | Associates job with a GitHub Environment, enabling protection rules and secrets scoping |
retention-days |
int |
90 |
Artifact expiry; keep at 14 for build artifacts to control storage costs |
if-no-files-found |
error | warn | ignore |
warn |
Fails the upload step if path resolves to zero files β catches silent build failures |
cache: 'npm' (setup-node) |
string |
β | Restores ~/.npm keyed on package-lock.json hash; cuts install time by 60β80 % |
For a full reference to matrix syntax and concurrency controls, see managing environment matrices in GitHub Actions.
Integration with Sibling Topics
This pipeline sits at the intersection of several concerns within the CI/CD Pipeline Architecture & Fundamentals domain:
Upstream β artifact storage: The upload-artifact@v4 / download-artifact@v4 pattern above is the simplest form of artifact promotion. For larger teams with cross-pipeline reuse requirements and CDN-backed storage, artifact management strategies for frontend builds covers immutable registries, content-addressable storage, and retention policies in depth.
Concurrent pipelines β cost control: When multiple branches run this pipeline simultaneously, shared runners queue. Optimizing pipeline concurrency and queue limits explains how to apply concurrency: groups, cancel-in-progress strategies, and runner autoscaling to keep queue wait times below one minute.
Financial visibility: Each job above should carry COST_CENTER and PROJECT_ID environment variables so spending can be attributed. Tracking CI/CD compute costs for platform teams covers tagging strategies and the cost per run calculation for multi-matrix pipelines.
Build speed: The cache: 'npm' option in actions/setup-node handles node_modules restore, but compiled output β Vite/Webpack chunks, TypeScript declaration caches β requires a separate caching layer. Build optimization and caching strategies covers Viteβs cacheDir persistence and Webpackβs cache.type: 'filesystem' for CI environments.
Performance Benchmarks
The following numbers are observed on GitHub-hosted ubuntu-latest runners running a medium-sized React SPA (230 components, 180 k source lines):
| Stage | Without Caching | With cache: βnpmβ | With Full Layer Cache |
|---|---|---|---|
npm ci |
55 s | 12 s | 9 s |
npm run lint |
28 s | 28 s | 28 s |
npm run type-check |
41 s | 41 s | 18 s (incremental) |
npm run build (Vite) |
67 s | 67 s | 22 s (warm Vite cache) |
| End-to-end pipeline | ~4 min 30 s | ~2 min 55 s | ~1 min 45 s |
Key takeaways:
- Lockfile-based
npm cicaching recovers ~78 % of install time. - TypeScript incremental mode (
tsBuildInfoFilepersisted viaactions/cache) cuts type-check time by ~56 %. - The three-way Node matrix runs in parallel, so the total wall-clock cost for the build stage is the slowest single leg, not the sum.
- Canary and production deploys are network-bound (~15β25 s) and are not improved by caching.
Troubleshooting
Cache not found for input keys
Exact error: Warning: Cache not found for input keys: node-20-Linux-...
Root cause: The cache key is derived from package-lock.json. If the lockfile path differs between jobs (e.g., a monorepo with multiple package.json files) or the file does not exist, the key produces no match.
Fix: Explicitly set cache-dependency-path in actions/setup-node:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: apps/frontend/package-lock.jsonArtifact not found on download
Exact error: Unable to find any artifacts for the associated workflow
Root cause: The download-artifact@v4 step is referencing an artifact name that did not match what was uploaded β usually a mismatch in the ${{ github.sha }} interpolation or a failed upload that produced no files.
Fix: Confirm the artifact name exactly matches across jobs. Add if-no-files-found: error to the upload step so the pipeline fails loudly rather than silently uploading an empty artifact.
Matrix job cancelled before finishing
Exact error: The job was cancelled because fail-fast is enabled
Root cause: fail-fast: true (the default) cancels all remaining matrix legs the moment one fails. During local development or debugging runs this can mask whether Node 20 also fails.
Fix: Set fail-fast: false temporarily to collect all matrix results. Re-enable for production pipelines once the matrix is stable to avoid burning unnecessary runner minutes.
Health check fails immediately after canary deploy
Exact error: curl: (22) The requested URL returned error: 502
Root cause: The CDN or load balancer route has not yet propagated to all edge nodes. The health check fires too soon after the deploy script returns.
Fix: Add a propagation wait with a retry loop (as shown in the canary job above) rather than a fixed sleep. Five retries at 10-second intervals (50 s total) covers edge propagation for major CDN providers without adding unnecessary delay on fast networks.
Frequently Asked Questions
How do I enforce environment parity between staging and production?
Use runtime configuration injection: the RUNTIME_API_URL variable in the canary job above is injected at deploy time rather than baked into the compiled bundle. Validate configuration schemas in a pre-deploy step using a JSON Schema validator or a TypeScript-first tool like zod. Synchronize feature flag states across environments using your feature flag serviceβs SDK so the same code path executes in staging and production.
What is the right stage granularity for a React pipeline?
Decompose into lint/type-check, unit test, integration test, build, and deploy. Keep the validate stage under two minutes β developers iterate fastest when failures arrive before coffee cools. Isolate integration tests into their own job with dedicated service containers so a slow database seed does not delay the canary deploy. Add a bundle size budget check (e.g., bundlesize or size-limit) as an optional gate that fails with a warning rather than blocking the deploy outright.
How do I balance parallel matrix builds against compute costs?
Run the full three-version matrix on pushes to main and staging. On pull requests, restrict to a single version (the active LTS) using an if: expression on the matrix input:
strategy:
matrix:
node-version: ${{ github.ref == 'refs/heads/main' && fromJson('[20,22,24]') || fromJson('[22]') }}This reduces PR matrix runner minutes by two-thirds while still validating cross-version compatibility on every merge. Track the savings via cost attribution tags described in tracking CI/CD compute costs for platform teams.
What deployment strategy minimizes downtime during a bad release?
Use canary with an automated rollback trigger. After the canary deploy step, poll your real-user monitoring (RUM) error rate for five minutes. If the rate exceeds a defined threshold (e.g., a 2Γ increase over the baseline rolling window), execute deploy.sh --rollback before the promote-production job runs. GitHub Actions continue-on-error: false and job-level if: success() conditions ensure promotion never proceeds if the canary health check or error-rate gate fails.
Related
- Artifact Management Strategies for Frontend Builds β immutable registries, content-addressable storage, and retention policies for compiled React bundles.
- Managing Environment Matrices in GitHub Actions β full syntax reference for matrix strategies, includes and excludes, and concurrency guards.
- Optimizing Pipeline Concurrency and Queue Limits β
concurrency:group configuration and runner autoscaling to prevent queue starvation across simultaneous PRs. - How to Structure a Monorepo CI Pipeline for Next.js and Node β affected-path filtering and dependency graph traversal for multi-package workspaces.
β Back to CI/CD Pipeline Architecture & Fundamentals