Logo

[CI/CD] Cutting CI From 20 to 6 Minutes by Pulling Lint and Type Checks Out of next build

7 min read

Table of Contents

The Problem

At work, CI on a large Next.js app took about 20 minutes per PR. Releases to our dev environment took about 40 minutes. Push a one-line fix, wait 40 minutes to see it deployed. Painful.

So I profiled the workflows, and most of the time wasn't going where I expected.

Where the Time Actually Went

Three culprits:

  1. next build was doing too much. By default, next build runs ESLint and the TypeScript type check inline, as part of the build. On a codebase this size, that's minutes of work sitting on the critical path of every build.
  2. And we were building more than once. CI had a build job, and the release workflow built a Docker image (which runs next build inside it). So lint and type checking ran inline in every one of those builds, in serial, every time.
  3. The caches were quietly broken. More on this below.

The fix wasn't one trick. It was pulling the checks off the critical path, parallelizing everything, and repairing the caches.

Fix 1: Get Lint and Type Checks Out of next build

Two changes. In package.json:

1"build:next": "next build --no-lint"

And in next.config.js:

1typescript: {
2 ignoreBuildErrors: true,
3},

Yes, these flags look scary. ignoreBuildErrors: true reads like "ship broken code to production". But what they actually disable is the duplicate inline execution inside the build. Type errors and lint errors are still caught, just by dedicated jobs that run in parallel with the build instead of inside it (next two sections).

The build itself doesn't need type checking to succeed. SWC strips types without checking them. So the check is a quality gate, not a build dependency, and quality gates can run in parallel.

Fix 2: A Dedicated Type-Check Job

CI got a new type-check job that runs tsc alongside the build instead of inside it, with tsconfig.tsbuildinfo cached for incremental checking:

1type-check:
2 needs: changes
3 runs-on: ubuntu-latest
4 steps:
5 - uses: actions/checkout@v6
6 - uses: ./.github/actions/install-dependencies
7 - name: Cache tsbuildinfo
8 uses: actions/cache@v5
9 with:
10 path: tsconfig.tsbuildinfo
11 key: ${{ runner.os }}-tsbuildinfo-${{ hashFiles('**/package-lock.json') }}
12 restore-keys: |
13 ${{ runner.os }}-tsbuildinfo-
14 - name: Run type check
15 run: npm run compile:ts

With a warm tsbuildinfo cache, tsc only re-checks what changed. This job usually finishes before the build does.

Fix 3: Lint Only Changed Files

The old lint job ran ESLint and Stylelint over the entire src/ tree on every PR. But a PR only touches a handful of files, and main is already lint-clean, so re-linting the other 99% is wasted work.

The workflow already had a changes job that listed the PR's changed files to decide whether to run at all. I made it output the actual file list:

1- name: Detect code changes
2 id: filter
3 run: |
4 {
5 echo 'files<<__CHANGED_EOF__'
6 echo "$CHANGED"
7 echo '__CHANGED_EOF__'
8 } >> "$GITHUB_OUTPUT"

Then the lint job feeds only those files to each tool:

1changed=$(echo "$CHANGED_FILES" | while IFS= read -r f; do
2 [ -n "$f" ] && [ -f "$f" ] && printf '%s\n' "$f"
3done)
4
5eslint_files=$(echo "$changed" | grep -E '^src/.*\.(ts|tsx)$' || true)
6
7echo "$changed" | xargs -r npx prettier --check --ignore-unknown --cache
8echo "$eslint_files" | xargs -r npx eslint --cache

Two details that matter:

Fix 4: The Caches Were Lying

Two cache bugs had been sitting there for who knows how long.

The npm cache was restored twice. Our shared install action used setup-node with cache: 'npm', which already caches ~/.npm. But a later actions/cache step in the same action also included ~/.npm in its path list. Result: about 443MB downloaded and unpacked twice on every job, in every workflow using that action. Deleting one line fixed installs across three workflows.

The Next.js cache key hashed everything. The .next/cache key looked like this:

1key: ...-${{ hashFiles('**/*.ts', '**/*.tsx') }}

** includes node_modules. So GitHub Actions was hashing tens of thousands of dependency files just to compute the key (slow), and the key changed whenever dependencies did anything (unstable restores). Scoping it to source files fixed both:

1key: ...-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}

Fix 5: The Dev Release Needed a Quality Gate

Here's the catch with Fix 1. Our dev environment deploys directly from a push to release/dev* branches, without going through a PR. No PR means no CI. Which means the inline lint and type check inside next build was, accidentally, the only quality gate for dev deploys. Remove it and broken code sails straight into the cluster.

So the release workflow got restructured:

1publish-and-deploy:
2 needs: [build-image, type-check, lint]
3 if: >-
4 always()
5 && !contains(needs.*.result, 'failure')
6 && !contains(needs.*.result, 'cancelled')

The always() is needed because type-check and lint are skipped (not passed) for staging releases, and by default a skipped dependency skips the dependent job too. This condition says: run as long as nothing failed or got cancelled, skipped is fine.

If the gate fails, publish-and-deploy never runs, so nothing touches the registry, S3, or Kubernetes. A separate notify-failure job with if: failure() sends exactly one Slack alert no matter which job broke.

Results

CI on pull requests:

BeforeAfter (cold cache)After (warm cache)
Duration~20 min~14 min~6 min

Dev releases:

BeforeAfter (cold cache)After (warm cache)
Duration~40 min~34 min~21 min

The cold-cache improvement is pure parallelization and deduplication. The warm-cache numbers add the repaired caches on top.

TL;DR

Related Articles