Parallel Jobs and Caching
A ten-minute pipeline that runs fifty times a day costs a team over eight hours of waiting daily. Systematic optimization does not start with guessing, it starts with measuring.
Table of Contents
- 1. Why sequential stages rarely reflect the actual dependencies
- 2. needs: from linear stages to a real dependency graph
- 3. parallel: splitting a single job into multiple simultaneous instances
- 4. parallel:matrix: parallelization across different configurations
- 5. Measuring cache hit rate instead of assuming it
- 6. Which steps suit parallelization and which do not
- 7. Fail-fast strategies: run expensive jobs only after cheap checks
- 8. Measuring before and after optimization
- 9. A prioritized overall strategy for practice
- 10. Summary
- 11. FAQ
1. Why sequential stages rarely reflect the actual dependencies
The classic GitLab CI model with stages, where every job in a stage must finish before the next stage begins, is simple to understand but rarely reflects a project's real dependencies. A unit test job that only depends on the backend's build result still waits, in this model, for the entire build stage to finish, even if that stage also contains a completely unrelated frontend build that takes five minutes longer.
This artificial synchronization at stage boundaries is the most common cause of unnecessarily long pipeline runtimes. Every stage waits for the slowest job within it, even when most downstream jobs only actually need a small part of that stage's output. The first and most effective optimization step is therefore almost always to make the actual data dependencies between jobs explicit, instead of relying on the implicit stage ordering.
2. needs: from linear stages to a real dependency graph
The needs directive lets a job explicitly declare which other jobs it actually depends on, regardless of which stage they sit in. GitLab starts a job with needs as soon as all the jobs listed there have finished, instead of waiting for the entire previous stage to complete. This effectively turns the pipeline from a linear chain into a directed acyclic graph, or DAG, where independent branches run genuinely in parallel instead of only appearing to, within the same stage.
A typical example is a project with separate backend and frontend builds: with needs, the backend test job can start right after the backend build finishes, without waiting on the longer-running frontend build, even though both formally sit in the same build stage. In practice this often cuts total runtime by several minutes, especially in pipelines with many small but unevenly-timed jobs within the same stage.
stages:
- build
- test
- deploy
build_backend:
stage: build
script:
- composer install --no-dev
build_frontend:
stage: build
script:
- npm ci && npm run build
test_backend:
stage: test
needs: ["build_backend"]
script:
- vendor/bin/phpunit
test_frontend:
stage: test
needs: ["build_frontend"]
script:
- npm run test
3. parallel: splitting a single job into multiple simultaneous instances
While needs optimizes the ordering between different jobs, the parallel keyword addresses a different problem: splitting a single, long-running job into several simultaneously running instances. A large test suite with several thousand tests that takes twelve minutes sequentially can be spread across four simultaneous jobs with parallel: 4, each running only a quarter of the tests, cutting total runtime to a quarter in the best case, limited by available runner capacity.
The actual challenge lies in splitting test cases across the parallel instances. Many test runners, such as PHPUnit with an appropriate plugin or Jest, support automatic splitting based on the CI_NODE_INDEX and CI_NODE_TOTAL variables that GitLab automatically passes to each parallel instance. Without a sensible splitting strategy, for example balancing by test duration rather than just file count, a single particularly slow block of test cases can bottleneck an entire parallel group while the other three finish long before it.
test_suite:
stage: test
parallel: 4
script:
- vendor/bin/phpunit
--testsuite=integration
--order-by=random
$(vendor/bin/phpunit-split --index="$CI_NODE_INDEX" --total="$CI_NODE_TOTAL")
4. parallel:matrix: parallelization across different configurations
A related but conceptually different form of parallelization is parallel:matrix, where the same task is not split into equal chunks but instead run simultaneously with different parameter combinations, for example testing against multiple PHP versions or multiple database engines. GitLab automatically generates one job per combination of the specified variable values, making manual maintenance of many nearly identical job definitions unnecessary.
The performance gain from parallel:matrix lies less in raw speed for a single test run and more in the fact that compatibility checks against multiple environments no longer run sequentially one after another, but simultaneously. A pipeline that previously checked PHP 8.2, 8.3, and 8.4 one after another in three separate, manually maintained jobs can, with a single matrix definition, test all three versions at once and, ideally, take no longer than the slowest single run.
test_php_versions:
stage: test
parallel:
matrix:
- PHP_VERSION: ["8.2", "8.3", "8.4"]
image: php:${PHP_VERSION}-cli
script:
- composer install --no-progress
- vendor/bin/phpunit
5. Measuring cache hit rate instead of assuming it
Before using caching as an optimization lever, you should measure how often the existing cache actually hits. GitLab explicitly logs in the job log whether a cache was downloaded and which key was searched for, which can also be evaluated programmatically via the GitLab API by iterating over job traces from multiple pipeline runs and searching for the characteristic cache log lines. Without this measurement, any cache optimization is based on pure guesswork.
A simple but effective approach is a weekly script that aggregates the cache hit rate per job across the last hundred pipeline runs and logs it as a simple metric. If the hit rate for a given job drops well below ninety percent, that almost always points to a cache:key that is too granular or misconfigured, for example a key that unnecessarily includes environment variables that change constantly, even though the actual cache content could remain stable.
6. Which steps suit parallelization and which do not
Not every pipeline step benefits from parallelization. Well-suited steps process independent datasets, for example test cases without shared state, independent linters for different languages within the same repository, or compatibility tests against multiple versions of a dependency. These steps split mechanically without the partial results affecting each other or needing to follow a particular order.
Poorly suited, in contrast, are steps with shared, mutable state, such as integration tests that all write against the same database without it being isolated per parallel instance, or deploy steps that must follow a strict order, such as database migration before application deploy. A common parallelization mistake is naively adding parallel to such steps anyway, which leads to inconsistent, hard-to-reproduce failures when two parallel instances manipulate the same resource at the same time.
7. Fail-fast strategies: run expensive jobs only after cheap checks
An often-overlooked lever for shorter average pipeline runtime is not more parallelization, but a deliberate ordering that puts fast, cheap checks ahead of slow, expensive jobs. A syntax check or linter that runs in a few seconds should sit ahead of a ten-minute integration test and block it via needs, so an obvious syntax error aborts the pipeline immediately instead of only surfacing after ten minutes of expensive test runtime.
This strategy does not reduce the runtime of successful pipelines, but it does reduce the average runtime across all pipeline runs, since broken commits, which already make up a significant share of all pipeline triggers, get aborted much earlier. Combined with fail_fast at the parallel job level, which immediately cancels the remaining parallel jobs when one fails instead of letting them run to completion, this adds up over many pipeline runs to noticeably lower total runner consumption.
lint:
stage: check
script:
- php -l src/
- vendor/bin/phpcs
test_suite:
stage: test
needs: ["lint"]
parallel: 4
script:
- vendor/bin/phpunit --order-by=random
8. Measuring before and after optimization
Every optimization should be measured against a clear metric, typically average pipeline runtime over a representative period of at least two weeks, not against a single lucky or unlucky run. GitLab offers a basic breakdown for this under Analytics, CI/CD Analytics, which can additionally be broken down by job rather than just overall pipeline via the GraphQL or REST API for deeper analysis.
A methodical approach changes only one variable at a time, for example introducing needs first and observing runtime over two weeks before additionally enabling parallel for the test suite. If multiple changes are made at once, it becomes nearly impossible to reliably attribute afterward which measure contributed the most to the improvement, which makes future optimization decisions harder.
9. A prioritized overall strategy for practice
For most projects a fixed optimization order pays off: first introduce needs to eliminate artificial stage synchronization, then measure the cache hit rate and improve cache:key deliberately, only then apply parallel or parallel:matrix to genuinely compute-heavy, independent steps, and finally optimize fail-fast ordering for the average case across all pipeline runs. This order delivers the biggest improvements with the least configuration effort first.
The table below summarizes the mechanisms covered here and ranks them by how large the typical runtime gain is relative to the configuration effort, so the most impactful measures can be implemented first when time is limited.
| Mechanism | Solves which problem | Typical runtime gain | Effort |
|---|---|---|---|
| needs (DAG) | Artificial stage wait time | Several minutes per pipeline | Low |
| parallel | One slow job | Up to factor N with N instances | Medium (needs test splitting) |
| parallel:matrix | Sequential compatibility tests | Factor equal to number of combinations | Low to medium |
| cache:key tuning | Unnecessary reinstalls | Depends on hit rate | Low, but measurement needed |
Mironsoft
CI/CD pipelines, zero-downtime deployments and release automation
Deployments that run without downtime and without the nail-biting?
We review existing GitLab pipelines for fragile deployment steps and missing safeguards, then build a release process with zero-downtime deployments, automated checks and a rollback you can actually trust in an emergency.
Pipeline Review
Checking an existing .gitlab-ci.yml for fragility, missing stages and security gaps.
Zero-Downtime Deployment
Building symlink releases, health checks and rollback strategies for Magento stores.
CI/CD Automation
Connecting tests, security scans and deployments into one reliable pipeline.
10. Summary
Optimizing Pipeline Runtime: Key Takeaways
needs
Turns linear stages into a real dependency graph, independent jobs run in parallel.
parallel
Splits a slow job into several simultaneous instances, test splitting determines the payoff.
Cache measurement
Evaluate hit rate from job logs instead of just assuming the cache is working.
Fail-fast
Putting cheap checks before expensive jobs lowers average runtime across all runs.