DeployU
Interviews / DevOps & Cloud Infrastructure / Workflows are consuming too many minutes and running slowly. Optimize for speed and cost.

Workflows are consuming too many minutes and running slowly. Optimize for speed and cost.

practical Performance Optimization Interactive Quiz Code Examples

The Scenario

Your organization’s GitHub Actions usage is out of control:

Monthly GitHub Actions Report:
- Total minutes used: 125,000 (limit: 50,000)
- Overage cost: $1,200
- Average workflow duration: 18 minutes
- Cache hit rate: 15%
- Jobs run: 8,500

Top offenders:
1. Full CI on every commit to all branches
2. Matrix testing 25 combinations for every PR
3. No caching - downloading 800MB of node_modules each run

The Challenge

Optimize workflows to reduce execution time by 70% and stay within the free tier, while maintaining code quality and test coverage.

Wrong Approach

A junior engineer might just skip tests, reduce matrix combinations without analysis, or throw money at the problem by upgrading plans. These approaches reduce quality, miss real issues, or waste budget.

Right Approach

A senior engineer analyzes workflow patterns, implements intelligent caching, uses path filters to skip unnecessary runs, parallelizes effectively, and uses concurrency controls to cancel redundant jobs.

Step 1: Implement Aggressive Caching

name: Optimized CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Cache npm dependencies
      - name: Cache npm dependencies
        uses: actions/cache@v4
        id: npm-cache
        with:
          path: |
            ~/.npm
            node_modules
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ runner.os }}-

      # Only install if cache missed
      - name: Install dependencies
        if: steps.npm-cache.outputs.cache-hit != 'true'
        run: npm ci

      # Cache build outputs
      - name: Cache build
        uses: actions/cache@v4
        with:
          path: |
            .next/cache
            dist
          key: build-${{ runner.os }}-${{ hashFiles('src/**', 'package-lock.json') }}

      - name: Build
        run: npm run build

      # Cache Playwright browsers
      - name: Cache Playwright
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

      # Cache Docker layers
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: false
          cache-from: type=gha
          cache-to: type=gha,mode=max

Step 2: Use Path Filters to Skip Unnecessary Runs

name: Smart CI

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
      - '.gitignore'
      - 'LICENSE'
  pull_request:
    paths-ignore:
      - '**.md'
      - 'docs/**'

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      frontend: ${{ steps.changes.outputs.frontend }}
      backend: ${{ steps.changes.outputs.backend }}
      infrastructure: ${{ steps.changes.outputs.infrastructure }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            frontend:
              - 'frontend/**'
              - 'package.json'
            backend:
              - 'backend/**'
              - 'requirements.txt'
            infrastructure:
              - 'terraform/**'
              - 'k8s/**'

  frontend-tests:
    needs: changes
    if: needs.changes.outputs.frontend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test
        working-directory: frontend

  backend-tests:
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install -r requirements.txt && pytest
        working-directory: backend

  # Always run if nothing specific changed (safety net)
  full-ci:
    needs: changes
    if: needs.changes.outputs.frontend != 'true' && needs.changes.outputs.backend != 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "No specific changes detected, running minimal checks"

Step 3: Implement Concurrency Controls

name: CI with Concurrency

on:
  push:
    branches: [main, develop]
  pull_request:

# Cancel in-progress runs for the same branch/PR
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test

Step 4: Optimize Matrix Builds

name: Smart Matrix

on:
  push:
    branches: [main]
  pull_request:

jobs:
  # Quick tests on PR - limited matrix
  test-pr:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: [18]  # Only latest LTS for PRs
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

  # Full matrix only on main branch
  test-full:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false  # Don't cancel other jobs if one fails
      matrix:
        node: [16, 18, 20]
        os: [ubuntu-latest, windows-latest, macos-latest]
        exclude:
          # Skip expensive combinations
          - os: macos-latest
            node: 16
          - os: windows-latest
            node: 16
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
      - run: npm ci && npm test

Step 5: Parallelize Test Execution

name: Parallel Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4

      - name: Setup
        uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - run: npm ci

      - name: Run tests (shard ${{ matrix.shard }}/4)
        run: |
          npm run test -- --shard=${{ matrix.shard }}/4

      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-${{ matrix.shard }}
          path: coverage/

  merge-coverage:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Download all coverage
        uses: actions/download-artifact@v4
        with:
          pattern: coverage-*
          merge-multiple: true

      - name: Merge and upload
        run: |
          # Merge coverage reports
          npx nyc merge coverage merged-coverage.json
          npx nyc report --reporter=text --reporter=lcov

Step 6: Use Larger Runners for CPU-Intensive Tasks

name: Optimized Build

on:
  push:
    branches: [main]

jobs:
  # Quick jobs on standard runners
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run lint

  # CPU-intensive on larger runner (4x faster, 2x cost = 2x savings)
  build:
    runs-on: ubuntu-latest-4-cores  # or self-hosted
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'npm'

      - run: npm ci

      # Parallel build using all cores
      - run: npm run build -- --parallel
        env:
          NODE_OPTIONS: "--max-old-space-size=8192"

Step 7: Complete Optimized Workflow

name: Optimized CI/CD

on:
  push:
    branches: [main, develop]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    paths-ignore:
      - '**.md'

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  NODE_VERSION: 18
  CACHE_VERSION: v1  # Increment to invalidate all caches

jobs:
  setup:
    runs-on: ubuntu-latest
    outputs:
      cache-key: ${{ steps.cache-key.outputs.key }}
    steps:
      - uses: actions/checkout@v4
      - id: cache-key
        run: |
          echo "key=${{ env.CACHE_VERSION }}-${{ hashFiles('**/package-lock.json') }}" >> $GITHUB_OUTPUT

  install:
    needs: setup
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/cache@v4
        id: cache
        with:
          path: node_modules
          key: modules-${{ needs.setup.outputs.cache-key }}

      - if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

  lint-and-typecheck:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: node_modules
          key: modules-${{ needs.setup.outputs.cache-key }}
      - run: npm run lint & npm run typecheck & wait

  test:
    needs: install
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: node_modules
          key: modules-${{ needs.setup.outputs.cache-key }}
      - run: npm test -- --shard=${{ matrix.shard }}/2

  build:
    needs: [lint-and-typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/cache@v4
        with:
          path: node_modules
          key: modules-${{ needs.setup.outputs.cache-key }}
      - uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ hashFiles('src/**') }}
      - run: npm run build

Optimization Results

MetricBeforeAfterImprovement
Avg duration18 min5 min72% faster
Monthly minutes125,00035,00072% reduction
Cache hit rate15%85%5.6x improvement
Monthly cost$1,200$0Within free tier
Queue time5 min30 sec90% reduction

Practice Question

What is the most impactful optimization for reducing GitHub Actions minutes for a typical Node.js project?