Skip to content
compiler.dev

Speed Up a TypeScript Monorepo in GitHub Actions

Last updated

In a TypeScript monorepo, install and tests are usually not the slowest part of CI. Type checking and linting are, because every job starts on a fresh machine and checks every file again. To speed them up, keep the compiler's incremental state between runs, use lint caches that work after a fresh git checkout, and run checks as parallel jobs instead of one long chain.

This guide covers those three things with working YAML. It does not repeat how to run only the packages that changed; that is in fastest CI for pnpm workspaces. For the general approach, read how to speed up GitHub Actions.

Measure which check is slow

Time each check on its own before changing anything. In a workflow, give each one a step name and read the step durations in the run summary:

- name: typecheck
  run: pnpm -r --workspace-concurrency=4 run typecheck
- name: lint
  run: pnpm -r --workspace-concurrency=4 run lint
- name: test
  run: pnpm -r --workspace-concurrency=4 run test

If one of the three is more than half the job, fix that one first. The sections below follow the usual order of payoff: type checking, then lint, then job layout.

Type checking: incremental builds

tsc --noEmit checks types and writes nothing. With incremental turned on, TypeScript also writes a .tsbuildinfo file that records what it learned about each file, so a later run only re-checks files that changed and the files that depend on them. In tsconfig.json:

{
  "compilerOptions": {
    "noEmit": true,
    "incremental": true,
    "tsBuildInfoFile": "node_modules/.cache/tsc/web.tsbuildinfo",
    "skipLibCheck": true
  }
}
  • tsBuildInfoFile puts the state in a path you choose, so you know exactly what to cache. Use one file per package, because two projects must not write the same file.
  • skipLibCheck skips type checking of .d.ts files. The TypeScript docs describe it as saving time at the cost of some accuracy in the type system. Your own code is still checked. Most CI setups accept that trade.

Then restore and save that folder with the cache action:

- uses: actions/cache@v6
  with:
    path: node_modules/.cache/tsc
    key: tsc-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
    restore-keys: |
      tsc-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      tsc-${{ runner.os }}-
- run: pnpm -r run typecheck

The key ends in the commit SHA so every run saves a fresh copy, and restore-keys loads the newest earlier copy. GitHub only lets a run restore caches saved by its own branch, by the pull request's base branch or by the default branch, so pull requests start from what main saved. That is the cache you want: a PR then re-checks only what it changed.

A fresh checkout gives every file a new modification time, but that does not defeat this cache. We checked with TypeScript 5.9.3: after copying a project (new timestamps) and touching its files without changing them, tsc --extendedDiagnostics still reported fewer types checked than a cold run, because TypeScript compares file contents, not times. If the saved state does not match your files, TypeScript re-checks them, so a stale cache is slower, never wrong. If you suspect one, delete it and run again.

Project references

A monorepo with packages that import each other can use project references. Each package sets composite: true, and the root lists the packages it builds:

{
  "files": [],
  "references": [
    { "path": "packages/ui" },
    { "path": "packages/api-client" },
    { "path": "apps/web" }
  ]
}
tsc --build

tsc --build (or -b) builds each project in dependency order and skips projects that are up to date. Two things to know before you rely on it in CI:

  • Build mode writes .d.ts output for each referenced package, so the next package can check against a small declaration file instead of the full source. That is where the time goes down on large graphs.
  • Build mode can skip projects across a fresh checkout. Its first check is timestamps, and a checkout makes your sources newer than restored outputs. We tried exactly that with TypeScript 5.9.3 and tsc --build --verbose: with unchanged contents it reported up to date but needs to update timestamps of output files that are older than input files and rebuilt nothing. After we edited one file in packages/lib, it rebuilt that project and then apps/web, which depends on it, and nothing else. Run tsc --build --verbose once on your runner after restoring the cache; the log says for each project whether it was skipped or rebuilt, and why.
  • To use that in CI, cache each project's output folder together with its .tsbuildinfo file. Set tsBuildInfoFile to a path inside the output folder (for example dist/.tsbuildinfo) so one path covers both:
- uses: actions/cache@v6
  with:
    path: |
      packages/*/dist
      apps/*/dist
    key: tsb-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
    restore-keys: |
      tsb-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-

Do not turn on project references only because it is fashionable. A repository with two or three packages gains little. A repository with many packages and a deep import graph gains a lot.

Lint: caches that survive a checkout

ESLint can store results per file and skip unchanged files. Its default metadata strategy decides a file is unchanged from file metadata such as the modification time. A fresh checkout gives every file a new time, so the default cache can miss on every run. ESLint and Prettier both have a content strategy that compares file contents instead:

eslint . --cache --cache-location node_modules/.cache/eslint/web.cache --cache-strategy content

Prettier takes the same options:

prettier --check . --cache --cache-location node_modules/.cache/prettier/.prettier-cache --cache-strategy content

Cache those folders the same way as the TypeScript state:

- uses: actions/cache@v6
  with:
    path: |
      node_modules/.cache/eslint
      node_modules/.cache/prettier
    key: lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', '**/eslint.config.*', '**/.prettierrc*') }}-${{ github.sha }}
    restore-keys: |
      lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', '**/eslint.config.*', '**/.prettierrc*') }}-

The key includes the lockfile and the config files, because a new rule or plugin version must invalidate old results.

Type-aware lint rules cost more

Rules that need type information (@typescript-eslint rules such as no-floating-promises) make ESLint build a TypeScript program, which is much slower than syntax-only rules. The typescript-eslint docs say typed linting is slower than untyped linting, and in current versions its projectService option is the way to set it up, instead of listing project paths by hand. Options:

  • Turn on type-aware rules only for the folders that need them.
  • Run the type-aware pass as its own job, so the fast lint results come back first.
  • Do not run both a full tsc and type-aware ESLint on every file if you only need one of them for a given rule.

Run checks as parallel jobs

One job that runs typecheck, then lint, then test takes the sum of the three. Three jobs take the longest one, plus one install each:

name: CI
on:
  push:
    branches: [main]
  pull_request:

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  typecheck:
    runs-on: [self-hosted, Linux, compiler-ci]   # or ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - uses: actions/cache@v6
        with:
          path: node_modules/.cache/tsc
          key: tsc-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
          restore-keys: tsc-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      - run: pnpm -r run typecheck

  lint:
    runs-on: [self-hosted, Linux, compiler-ci]
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - uses: actions/cache@v6
        with:
          path: node_modules/.cache/eslint
          key: lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
          restore-keys: lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      - run: pnpm -r run lint

  test:
    runs-on: [self-hosted, Linux, compiler-ci]
    steps:
      - uses: actions/checkout@v6
      - uses: pnpm/action-setup@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm -r run test

cancel-in-progress stops older runs of the same branch when you push again, which saves minutes too; see reduce your GitHub Actions bill. Each job installs, so keep the pnpm store cached; caching pnpm in GitHub Actions has the details. The split pays off when install is short compared with the checks. If install is the slow part, a single job is faster.

Make the checks required in branch protection by job name, so a slow job cannot be skipped by accident.

What more CPU cores buy you

tsc is mostly single-threaded for one project, but a monorepo has many projects, and pnpm -r runs them side by side, as do ESLint across packages and the test runner. With more cores, set --workspace-concurrency to match, for example 8 on an 8-core machine. Memory matters too: a large type-aware ESLint run can need several gigabytes, and a 2-vCPU hosted runner has 7 GB in total.

To see what your repository gains, change one line to runs-on: [self-hosted, Linux, compiler-ci] and compare the run times, or use comparison mode, which runs a copy of your jobs next to your current ones. See pricing, compare and our benchmarks. For the rest of the pipeline, start at the slow CI hub.

FAQ

Should I cache the .tsbuildinfo file?

Yes, if type checking is a large share of the job. Set tsBuildInfoFile to a known path and cache that folder. If the cache is stale TypeScript just checks more files, so it is safe.

Does tsc --build work with a cache in CI?

Yes. In TypeScript 5.9.3 a restored cache was used even though a fresh checkout made the sources newer than the outputs, as long as their contents had not changed. Run tsc --build --verbose once after restoring the cache to see which projects are skipped on your version.

Is skipLibCheck safe?

It skips checking of declaration files, so errors inside your dependencies' types are not reported. Errors in your own code still are. Most teams turn it on.

Why does my ESLint cache never hit in CI?

The default strategy (metadata) detects changes from file metadata such as modification time, and a fresh checkout changes all of them. Use --cache-strategy content and cache the folder that holds the cache file.

Should typecheck, lint and test be separate jobs?

When each takes a few minutes, yes. When install is most of each job, no: you pay it three times.

Run this monorepo build on compiler.dev 200 free minutes

Try for free