Skip to content

Seeking Guidance: Significant typecheck performance drop post migrating to TS Project References in large monorepo #59780

Closed as not planned
@sudesh-atlassian

Description

@sudesh-atlassian

Acknowledgement

  • I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.

Comment

Context

We recently implemented TypeScript's project references feature in our monorepo, which has led to significant performance issues compared to our previous TypeScript configuration.

The typecheck process has experienced a dramatic increase in execution time, rising from 469.24 seconds to 4,297.17 seconds when we don't utilize any TypeScript cache (existing .d.ts files). We are keen to identify the root cause of this performance degradation and explore ways to improve or diagnose the typecheck performance with project references.

We are currently using .d.ts cache files to accelerate the typecheck process. However, this solution presents challenges in our integrated development environment (IDE). Emitting these .d.ts on the fly is time-consuming, and the cache files generated on Unix-based systems are incompatible with macOS machines, and vice versa.

We are seeking guidance on:

  • Understanding the underlying reasons for the significant performance decline when using project references.
  • Identifying methods to improve typecheck performance in our monorepo setup.
  • Developing strategies to effectively use project references in our IDE without compromising development speed.
  • Finding a solution to the cross-platform incompatibility of cache files between Unix and macOS systems.
  • Any insights or recommendations on addressing these challenges would be greatly appreciated.

Setup

Typescript Version

We are using Typescript version 5.4.2

Base tsconfigs common between both tsconfigs with references and earlier typecheck

tsconfig.entry-points.json - This file is used to provide path resolution for custom entry points. Below is a snippet of the configuration; in total, it contains 2,769 paths.

{
  "compilerOptions": {
    "paths": {
      "@af/package-1": [
        "./package-1/src",
        "../package-1/src",
        "../../package-1/src",
        "../../../package-1/src",
        "../../../../package-1/src"
      ],
	  "@af/package-1/some-func": [
        "./package-1/src/some-func",
        "../package-1/src/some-func",
        "../../package-1/src/some-func",
        "../../../package-1/src/some-func",
        "../../../../package-1/src/some-func"
      ],
    }
  }
}   

Base TypeScript Configuration tsconfig.base.json - This file serves as the foundational TypeScript configuration for all packages within our monorepo. It defines common compiler options and settings that are inherited by individual package configurations.

{
  "extends": "./tsconfig.entry-points.json",
  "compilerOptions": {
    "plugins": [
      {
        "name": "tsserver-metrics-plugin"
      }
    ],
    "baseUrl": ".",
    "allowJs": false,
    "strict": true,
    "allowSyntheticDefaultImports": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "downlevelIteration": true,
    "importHelpers": true,
    "jsx": "react",
    "moduleResolution": "node",
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "isolatedModules": true,
    "target": "es2019",
    "module": "esnext",
    "lib": [
      "dom",
      "dom.iterable",
      "es5",
      "scripthost",
      "es2015.core",
      "es2015.collection",
      "es2015.symbol",
      "es2015.iterable",
      "es2015.promise",
      "es2016.array.include",
      "es2017"
    ]
  },
  "files": [
    // Global typings go here which are expected in most of the packages - we have 62 such files
    "./typings/json.d.ts",
        ...
  ],
  "ts-node": {
    "transpileOnly": true,
    "files": true,
    "compilerOptions": {
      "module": "CommonJS"
    }
  }
}

Typecheck with project references

To address circular dependency issues, we have split each package's TypeScript configuration into two separate files: one for application code and another for development-related code. Below are sample configurations for both:

tsconfig.app.json - This configuration is used for the main application code of each package.

{
  "extends": "../tsconfig.base.json",
  "include": [
    "./src/**/*.ts",
    "./src/**/*.tsx",
    "./scripts/**/*",
    "definitions.ts",
    "types.ts"
  ],
  "exclude": [
    "**/docs/**/*",
    "**/__tests__/**/*",
    "**/vr-tests/**/*",
    "**/__perf__/**/*",
    "**/*.test.*",
    "**/test.*",
    "**/test-*",
    "**/examples.ts",
    "**/examples.tsx",
    "**/examples/*.ts",
    "**/examples/*.tsx",
    "**/examples/**/*.ts",
    "**/examples/**/*.tsx",
    "**/storybook/**/*",
    "**/constellation/**/*",
    ".storybook/*",
    "./__fixtures__/**/*",
    "./__generated__/**/*",
    "./mocks/**/*",
    "./__mocks__/**/*",
    "**/mock.*",
    "**/codemods/**/*.ts",
    "**/codemods/**/*.tsx"
  ],
  "compilerOptions": {
    "composite": true,
    "lib": ["ES2021.String"],
    "outDir": "../tsDist/package-1/app"
  },
  "references": [
    {
      "path": "../folder-2/foo/bar/tsconfig.app.json"
    },
  ]
}

tsconfig.dev.json - This configuration is used for development-related code, such as tests, mocks and build scripts.

{
  "extends": "../tsconfig.base.json",
  "include": [
    "**/docs/**/*",
    "**/__tests__/**/*",
    "**/vr-tests/**/*",
    "**/__perf__/**/*",
    "**/*.test.*",
    "**/test.*",
    "**/test-*",
    "**/examples.ts",
    "**/examples.tsx",
    "**/examples/*.ts",
    "**/examples/*.tsx",
    "**/examples/**/*.ts",
    "**/examples/**/*.tsx",
    "**/storybook/**/*",
    "**/constellation/**/*",
    ".storybook/*",
    "./__fixtures__/**/*",
    "./__generated__/**/*",
    "./mocks/**/*",
    "./__mocks__/**/*",
    "**/mock.*",
    "**/codemods/**/*.ts",
    "**/codemods/**/*.tsx"
  ],
  "exclude": ["./dist/**/*", "./build/**/*", "./node_modules/**/*"],
  "compilerOptions": {
    "composite": true,
    "lib": ["ES2021.String"],
    "outDir": "../tsDist/package-1/dev"
  },
  "references": [
    {
      "path": "tsconfig.app.json"
    }
  ]
}

tsconfig.project-references.json - This configuration file serves as the central hub for TypeScript project references across our entire monorepo. Its primary purpose is to enable efficient typechecking using TypeScript's project references feature.
In all we have 1226 packages and their app and dev tsconfigs (2452 tsconfigs). We didnt add tsconfig.app.json's as they are already included in tsconfig.dev.json's references.

{
  "files": [],
  "references": [
    { "path": "./folder-1/package1/tsconfig.dev.json" },
    { "path": "./folder-2/package2/tsconfig.dev.json" },
    // ... additional 1,224 package references
  ]
}

Typecheck command

NODE_OPTIONS="--max-old-space-size=16384" tsc --build tsconfig.project-references.json

Extended Diagnostic with project references when we didnt used any existing Cache

Projects in scope:                         2423
Projects built:                            2315
Aggregate Files:                        4064060
Aggregate Lines of Library:            85984227
Aggregate Lines of Definitions:       902941654
Aggregate Lines of TypeScript:          5739519
Aggregate Lines of JavaScript:                0
Aggregate Lines of JSON:                  61189
Aggregate Lines of Other:                     0
Aggregate Identifiers:               1036740113
Aggregate Symbols:                    710515971
Aggregate Types:                       36168921
Aggregate Instantiations:             216983900
Aggregate Memory used:                15641296K
Aggregate Assignability cache size:    12826627
Aggregate Identity cache size:           475304
Aggregate Subtype cache size:            586070
Aggregate Strict subtype cache size:     696811
Aggregate I/O Read time:                  4.28s
Aggregate Parse time:                    49.46s
Aggregate ResolveModule time:           223.61s
Aggregate ResolveTypeReference time:     33.39s
Aggregate ResolveLibrary time:            2.86s
Aggregate Program time:                 823.17s
Aggregate Bind time:                     28.12s
Aggregate Check time:                  1927.31s
Aggregate transformTime time:           735.72s
Aggregate commentTime time:               3.42s
Aggregate printTime time:              1023.13s
Aggregate Emit time:                   1023.78s
Aggregate I/O Write time:                 8.43s
Config file parsing time:                55.87s
Up-to-date check time:                    0.20s
Build time:                            4297.17s

Typehecking with TS Cache

  • We run a scheduled job which runs typecheck with references on stable master, then use the emitted .d.ts files as Cache with masters current commit hash as index.
  • On branch typecheck we fetch the nearest cache available based on commit history, also we update the file timestamps for files which are modified post the cache generation as to only typecheck them

Typecheck with --project mode

tsconfig.typecheck.json - This is the tsconfig for typecheck with --project mode

{
    {
  "extends": "./tsconfig",
  "include": [
    "./folder-1/**/*",
    "./folder-2/**/*",
    "./folder-3/**/*",
    "./folder-4/**/*",
    "./services/service-1/**/*",
    "./services/service-2/**/*",
    "./services/service-3/**/*",
    "./services/service-4/**/*",
    "./services/service-5/**/*",
    "./services/service-6/**/*",
    "./services/service-7/**/*",
    "./services/service-8/**/*",
    "./tools/**/*"
  ],
  "exclude": [
    "./folder-1/**/**/dist",
    "**/some.d.ts",
    "**/node_modules",
    "node_modules",
    "./services/node_modules",
    "./folder-3/foo/bar/",
    "./folder-3/foo/bar2/",
    "./folder-3/foo2/bar3/0-type.ts",
    "./folder-3/foo2/bar4/0-type.ts",
    "./folder-3/foo3/__generated__/globalTypes.ts"
  ],
  "compilerOptions": {
    "baseUrl": "./",
    "emitDeclarationOnly": false,
    /* Typechecking only, no code generation required */
    "noEmit": true,
    /* Set module to CJS rather than ES so that CJS-only modules that use
     * import/export assignment (export = require(...)) don't break
     */
    "module": "CommonJS"
  }
}

Typecheck command

NODE_OPTIONS="--max-old-space-size=16384" tsc --project tsconfig.typecheck.json

Extended Diagnostic with --project mode

Files:                         59890
Lines of Library:              40963
Lines of Definitions:        2185828
Lines of TypeScript:         4577662
Lines of JavaScript:               0
Lines of JSON:                     0
Lines of Other:                    0
Identifiers:                 7206749
Symbols:                    14278742
Types:                       4368428
Instantiations:             14365921
Memory used:               12876878K
Assignability cache size:    4615911
Identity cache size:          166448
Subtype cache size:           418129
Strict subtype cache size:    510959
I/O Read time:                 2.18s
Parse time:                   21.74s
ResolveModule time:           10.10s
ResolveTypeReference time:     0.05s
ResolveLibrary time:           0.02s
Program time:                 40.06s
Bind time:                    13.03s
Check time:                  416.12s
printTime time:                0.03s
Emit time:                     0.03s
Total time:                  469.24s

Observations

  • Compared to --project mode, typecheck with project references has nearly 60x (59890 vs 4064060) more files to typecheck, The number of files difference is mostly because of the way files are calculated (Basically files is aggregate number of files part of each tsconfig) not the files that are typechecked

Metadata

Metadata

Assignees

No one assigned

    Labels

    QuestionAn issue which isn't directly actionable in code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions