🚀 GitHub Actions Tips That Will Make Your CI/CD Pipeline Actually Fast
You pushed a one-line typo fix. You waited 23 minutes for CI to pass. You stared at the ceiling. You questioned your life choices.
Welcome to the club — we have slow pipelines and existential dread.
GitHub Actions is genuinely powerful, but out of the box it's also very good at letting you accidentally burn through compute credits while doing nothing useful. This post is about flipping that script. Let's make your pipelines lean, fast, and actually enjoyable to work with.
1. Cache Everything You Can (Seriously, Everything)
The single biggest win you can get — without rewriting your entire workflow — is caching dependencies. Most pipelines reinstall the same 200 MB of node_modules on every single run. That's like driving to the grocery store, buying milk, throwing it away, and driving back again every morning.
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # ← One line. That's it. You're welcome.
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
The cache: 'npm' option in setup-node automatically hashes your package-lock.json and restores the cache on cache hits. You can do the same for pip, yarn, pnpm, composer — most of the official setup actions support this natively.
Real-world result: a team I worked with dropped their average install step from 4 minutes to 18 seconds just by adding this. One line of config. Four minutes back in their lives every single push.
2. Run Jobs in Parallel, Not in Sequence
Most default workflows look like this: install → lint → test → build → deploy. A nice waterfall. Very sequential. Very slow.
The thing is — lint and test don't need each other. They both just need the code. So why are you running them one after another?
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm test
build:
runs-on: ubuntu-latest
needs: [lint, test] # ← Only runs when BOTH pass
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
Now lint and test run simultaneously. build waits for both. Your total pipeline time is capped by whichever of lint/test takes longer — not the sum of both.
A 12-minute pipeline can become a 7-minute pipeline overnight. The installs are duplicated, yes, but with caching they're cheap. The net result is almost always faster.
3. Use Concurrency Groups to Kill Stale Runs
Here's a scenario: you push to a PR branch. CI starts. You immediately push a fix. Now two CI runs are queued for the same PR — and you only care about the latest one.
GitHub Actions keeps running both. You pay for both. You wait for both. Only one matters.
Fix it with one block at the top of your workflow:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This groups all runs for the same workflow + branch together and cancels any in-progress run when a new one starts. For feature branches and PRs, this is almost always what you want.
One caveat: don't use cancel-in-progress: true on your main branch deployments. You don't want a new push to cancel a deploy that's halfway done. Use a condition:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
Elegant, safe, and it'll save you from that horrible moment of discovering a half-deployed application.
Bonus Tips (Because I Can't Stop)
Use workflow_dispatch for manual triggers. Add a manual trigger to your workflows so you can re-run deploys without pushing a dummy commit. Your git history will thank you.
Pin your action versions. uses: actions/checkout@v4 is good, but uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 is better. Pinning to a SHA prevents supply chain attacks where a malicious actor takes over an action and pushes a new tag. Yes, this has happened.
Use environments for secrets. Don't put production secrets in your repo-level secrets if you can avoid it. GitHub Environments let you scope secrets to specific branches and require manual approval before deploying to production. This is a genuinely good guardrail.
Matrix builds are magic. Need to test against Node 18, 20, and 22? Against Ubuntu and macOS? Use a matrix strategy instead of copy-pasting jobs. One job definition, multiple parallel runs.
The Real Lesson
Fast CI isn't just about saving time (though it does save a lot of it). Fast CI changes how your team works. When a pipeline takes 20 minutes, developers context-switch away, forget what they were doing, and lose the flow state that makes engineering actually enjoyable.
When CI takes 3 minutes, people stay in the loop. They fix things immediately. They iterate faster. The feedback loop is tight enough to actually be useful.
Most of the wins here are low-effort. Cache your dependencies. Parallelize what you can. Kill stale runs. These aren't heroic refactors — they're configuration tweaks that pay off on every single push, forever.
Go make your pipelines fast. Your future self, staring at a deployment progress bar at 11pm, will thank you.
What's your biggest CI/CD pain point? Drop it in the comments or reach out — I'm always curious what pipelines people are wrestling with. And if this helped, share it with that one teammate whose deploys always take "just a few more minutes."