Pipeline Optimization

Learn how to make GitLab CI/CD pipelines faster. From DAG dependencies and parallel execution to caching strategies, resource groups, and performance monitoring, cut pipeline times without sacrificing safety.

Speed Monitoring GitLab CI/CD
Why Pipeline Optimization Matters

Pipeline speed is not a luxury — it's a direct multiplier on developer productivity. A pipeline that takes 45 minutes means every push costs 45 minutes of waiting, every merge request takes 45 minutes longer to review, and every developer context-switches while waiting. Reduce that to 10 minutes, and you've given every developer back 35 minutes per push. Across a team of ten, that's hours of recovered focus every week [citation:2].

Optimization is also about cost. GitLab compute minutes are billed, and inefficient pipelines burn through them. A pipeline that rebuilds dependencies in every job, downloads every artifact in every stage, and runs tests sequentially is paying for compute that could be avoided. Caching, parallelization, and DAG dependencies aren't just faster — they're cheaper [citation:2][citation:4].

The most common bottleneck is the sequential stage model. By default, every job in a stage must finish before any job in the next stage starts. If your build stage has a fast frontend build and a slow backend build, the frontend tests wait for the backend build even though they don't depend on it. The needs keyword breaks this dependency, letting jobs start as soon as their actual inputs are ready .

Core Principle: Optimize for the critical path. Find the slowest chain of dependent jobs and shorten it. Everything else is secondary.

needs (DAG)

Jobs start as soon as their dependencies finish. Breaks the sequential stage barrier.

parallel:matrix

Run the same job with different variable combinations. Ideal for version matrices and test sharding.

Cache

Reuse dependencies across pipelines. Key by lockfile hash. Use pull-push for build, pull for test.

resource_group

Serialize deployment jobs without blocking unrelated jobs. Choose process mode (oldest_first, newest_first).

CI/CD Analytics

View pipeline duration trends, failure rates, and (with ClickHouse) job-level P50/P95 metrics.

Observability

Export traces and metrics to OpenTelemetry. Identify slow jobs and bottlenecks in production-like pipelines.
DAG with needs

The needs keyword is the single most impactful optimization for most pipelines. Without it, GitLab runs jobs stage by stage: all build jobs finish, then all test jobs, then all deploy jobs. With needs, a job can start as soon as the specific jobs it depends on finish, even if other jobs in the same stage are still running. This creates a directed acyclic graph (DAG) instead of a linear sequence .

A common pattern is the fan-out/fan-in structure. A single build job produces an artifact, multiple test jobs depend on that one build, and a deploy job depends on all the test jobs. Each test runs as soon as the build finishes, and deploy waits for all tests. This is more efficient than running all builds, then all tests, then deploy — especially when builds or tests have different durations .

Another pattern is immediate start. Jobs that don't depend on any previous job — linting, static analysis, secret detection — can use needs: [] to start immediately when the pipeline is created, without waiting for the build stage. They run in parallel with the build, giving faster feedback on issues that don't require compiled code .

# Fan-out/fan-in with needs build: stage: build script: npm run build artifacts: paths: [dist/] test_unit: stage: test needs: [build] script: npm run test:unit test_integration: stage: test needs: [build] script: npm run test:integration deploy: stage: deploy needs: [test_unit, test_integration] script: ./deploy.sh # Immediate start (no dependencies) lint: stage: test needs: [] script: npm run lint # Optional dependency (job may not exist due to rules) deploy_optional: needs: - job: test_optional optional: true - job: test_unit # needs with parallel:matrix (1:1 mapping) build_matrix: stage: build parallel: matrix: - OS: [ubuntu, alpine] NODE: [18, 20] script: ./build.sh deploy_matrix: stage: deploy parallel: matrix: - OS: [ubuntu, alpine] NODE: [18, 20] needs: - job: build_matrix parallel: matrix: - OS: ['$[[ matrix.OS ]]'] NODE: ['$[[ matrix.NODE ]]'] script: ./deploy.sh
needs Best Practices:
  • Use needs: [] for linting and scanning jobs that don't need build artifacts
  • Use needs to create independent execution paths for monorepos
  • Use optional: true when combining needs with rules that may skip jobs
  • View the job dependency graph to verify the DAG structure
Parallel Execution

Some jobs are inherently serial — you can't test before you build. But many jobs can run in parallel: different test suites, different platform builds, different version matrices. GitLab's parallel keyword runs multiple instances of the same job, each with a different index. This is ideal for sharding a large test suite: split 1000 tests across 5 parallel jobs, each running 200 tests, and the suite finishes in one-fifth the time [citation:3].

parallel:matrix goes further: it runs the job with different variable combinations. Testing against Node 18 and 20 across Ubuntu and Alpine is four jobs, each with its own environment. This is essential for libraries and applications that need to support multiple versions or platforms. The matrix generates all combinations automatically, so you write the job once [citation:3].

Parallelism has a cost: it requires more runners. If you don't have enough runners, parallel jobs queue and the benefit disappears. Monitor runner availability and scale up if queue times are long. The goal is to find the sweet spot where parallelism reduces wall-clock time without creating a queue bottleneck [citation:2].

# Parallel sharding (5 jobs, each runs 1/5 of tests) test_sharded: stage: test parallel: 5 script: - npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL # Matrix build (6 jobs: 3 Node versions × 2 OS) test_matrix: stage: test parallel: matrix: - NODE_VERSION: ["18", "20", "22"] OS: ["ubuntu", "alpine"] image: node:$NODE_VERSION script: - npm test # Matrix with different database versions test_db: parallel: matrix: - DB: ["postgres:14", "postgres:16"] NODE: ["18", "20"] services: - $DB script: - npm run test:db # Parallel with artifacts (each instance uploads) test_parallel: parallel: 4 script: - npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL artifacts: when: always paths: - test-results-$CI_NODE_INDEX/ expire_in: 1 day # Parallelism limits: # - Max 200 permutations per job # - Requires sufficient runners # - Queue time increases if runners are scarce
Parallelism Caveats: