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
}
}
tsBuildInfoFileputs 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.skipLibCheckskips type checking of.d.tsfiles. 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.tsoutput 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 reportedup to date but needs to update timestamps of output files that are older than input filesand rebuilt nothing. After we edited one file inpackages/lib, it rebuilt that project and thenapps/web, which depends on it, and nothing else. Runtsc --build --verboseonce 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
.tsbuildinfofile. SettsBuildInfoFileto a path inside the output folder (for exampledist/.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
tscand 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.
Related
- Speed Up Playwright in a Monorepo on GitHub ActionsSpeed up Playwright e2e tests in a Node monorepo on GitHub Actions: build the app once, skip e2e when the web app did not change, shard and merge reports.
- Add a CI Speed Badge to Your READMEShow how fast your CI is with a README badge: the median GitHub Actions run time for your repository, updated automatically. Free for public repos.
- Fastest CI for Bazel Monorepos on GitHub ActionsFast Bazel CI on GitHub Actions. Target determination for affected builds, remote cache flags, read-only PR uploads, sharding and a complete workflow.
- Fastest CI for Nx Monorepos on GitHub ActionsMake Nx CI fast on GitHub Actions. nx affected with nx-set-shas, Nx remote cache, task distribution, fetch-depth and a complete workflow.