Observability and speeding up slow pipelines
Find where the time goes, cache what is safe to cache, build only what changed, use remote execution, track DORA measures, and alert on the failures that matter.
Find where the time actually goes
# GitHub Actions: per-step timing from the API
gh run view 123456 --json jobs \
--jq '.jobs[] | .name as $j | .steps[] | "\\($j)\\t\\(.name)\\t\\(.conclusion)\\t\\(.startedAt)\\t\\(.completedAt)"'
# GitLab: job durations across the last 20 pipelines
glab api "projects/:id/pipelines?per_page=20" \
| jq -r '.[].id' \
| xargs -I{} glab api "projects/:id/pipelines/{}/jobs" \
| jq -r '.[] | "\(.name)\t\(.duration)"' \
| sort -k2 -nr | head -20
# locally: Gradle, Jest and pytest all report slow suites
gradle build --profile # build/reports/profile
npx jest --verbose --silent=false| Where time goes | Typical fix | Risk |
|---|---|---|
| Queue wait for a runner | More runners or smaller jobs | Cost |
| Dependency install | Lockfile-keyed cache | A stale cache hides a resolution error |
| Compilation | Incremental or remote build cache | Cache poisoning if shared across branches |
| Tests | Shard by previous duration | Uneven shards if timings are stale |
| Deployment | Parallelise independent services | Harder to attribute a failure |
Queue wait is invisible in the workflow file and often the largest term. Measure wall-clock time from commit to first green result, not the sum of step durations.
Caching and affected-only builds
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # needed to diff against the base
- name: Detect changed packages
id: changed
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="origin/${{ github.base_ref }}"
git fetch origin "${{ github.base_ref }}" --depth=1
PACKAGES=$(git diff --name-only "$BASE"...HEAD -- 'packages/*' | cut -d/ -f2 | sort -u | paste -sd, -)
else
PACKAGES=all
fi
echo "packages=${PACKAGES:-none}" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- name: Restore build cache
uses: actions/cache@v4
with:
path: |
.turbo
node_modules/.cache
key: build-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ github.sha }}
restore-keys: build-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-
- run: npx turbo run build test --filter="[${{ steps.changed.outputs.packages }}]"- A cache key must include the lockfile hash, or a dependency change is served a stale
node_modules. - Use
restore-keysfor a partial match so a new commit still warms from the previous cache. - Never cache anything that must be reproducible byte for byte, such as a release artefact — build it fresh and upload it as an artefact instead.
- Cache only across trusted branches. A pull request that can write a cache another pull request restores is a delivery path for malicious code.
Metrics and alerting
| Measure | Definition | What it tells you |
|---|---|---|
| Deployment frequency | How often production changes | Delivery throughput |
| Lead time for change | Commit to production | Where the waiting is |
| Change failure rate | Deploys causing a degraded state | Test and review quality |
| Time to restore | Incident start to recovery | Rollback and observability quality |
| Pipeline success rate | Green runs over total runs | Whether the pipeline is trusted |
# notify when the default branch has been red for more than 30 minutes
- name: Alert on long red
if: failure() && github.ref == 'refs/heads/main'
run: |
./notify.sh "Pipeline broken on main since ${{ github.run_started_at }}: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# and a weekly report of what is slow, so it gets fixed rather than endured
on:
schedule:
- cron: '0 9 * * 1'💡
A pipeline that is slow but green gets tolerated; a pipeline that is fast and unreliable gets bypassed. Keep the success rate above 95 percent by fixing flakes immediately, and treat a broken default branch as an incident with an owner.
FAQ
What is the single biggest pipeline speed-up?
For most teams it is path and affected-only filtering, because it removes work that did not need to run at all. Caching is second, and parallelism third.
Should I run the full test suite on every commit?
Run what a change can affect on every commit and the complete suite on the default branch and nightly. That keeps feedback fast while still catching integration problems before release.
Related
Testing strategy inside the pipeline CI/CD concepts and choosing a platform
Last refreshed 2026-09-18.