Overcoming “JavaScript Heap Out of Memory Error” During TypeScript Compilation in a MUI5 React Project: A Case Study

Carl Rannaberg
5 min readJul 1, 2023

--

Recently, I have been developing a React web application using MUI5 components. However, at one point, running tsc on the command line started taking tens of minutes and eventually crashed with a Heap Out of Memory Error. In this short post, I will outline the steps I took to successfully diagnose and solve this issue with running tsc.

1. Installing packages from scratch

First, I took the logical step of deleting the node_modules folder, along with package-lock.json, and running npm i to install packages and the dependency tree from scratch. Unfortunately, this did not help, so I needed to dig deeper.

2. Updating Typescript to latest version

Since the internet (including GitHub issues and Stackoverflow) was full of posts about out-of-memory errors while using Typescript compiler, which were usually resolved by updating to a newer version, this was the next step to try. However, it did not resolve the issue in this case.

3. Using tsc flags

The Internet pointed me towards using the --extendedDiagnostics and --build --verbose flags to gain better visibility into the compiler run. However, the output of these flags only appears at the end of the run and was not useful when the tsc crashed before completing the compilation process.

4. Comparing Typescript setup with similar project which works

I had a similar React project with MUI5 that didn’t encounter any issues while running tsc. To eliminate any issues with Typescript compiler configuration, I made sure that the tsconfig.json files were identical for both projects.

{
"compilerOptions": {
"target": "ESNext",
"lib": [
"dom",
"dom.iterable",
"ESNext"
],
"types": [
"vitest/globals",
"vite/client",
"vite-plugin-svgr/client"
],
"baseUrl": "src",
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src",
"vite.config.ts"
]
}

5. Uninstalling unnecessary packages

Since the project was rather small, I suspected that the issue must be related to a third-party package. To minimize the areas to look at, I went through the package.json file and uninstalled all packages that were not used in the project or were unnecessary for some other reason.

6. Increasing Node.js heap size

Although I didn’t believe it was a real solution, I attempted to increase the memory allocated to the V8 heap in Node.js to provide the Typescript compiler with more resources to complete its run. You can set the V8 heap size by running node with the -max-old-space-size flag and specifying the amount of memory in megabytes. To allocate 4GB, I used the following command:

$ node - max-old-space-size=4096 ./node_modules/.bin/tsc --noEmit

7. Using Typescript compiler tracing feature

After going through the previous steps, I finally discovered the --generateTrace flag for tsc. TypeScript supports performance tracing, and has an analyzer for that trace, which provides a quick summary of potential issues.

I ran the compiler with --generateTrace flag:

$ node --max-old-space-size=4096 ./node_modules/.bin/tsc --noEmit --extendedDiagnostics --generateTrace ./ts-traces

As a result, a trace.json file was created inside the ts-traces directory. This file contained 110k rows at the time when the compiler crashed with a Heap Out of Memory Error. The trace.json file consists of the following type of content:

{"name":"process_name","args":{"name":"tsc"},"cat":"__metadata","ph":"M","ts":119250.08392333984,"pid":1,"tid":1},
{"name":"thread_name","args":{"name":"Main"},"cat":"__metadata","ph":"M","ts":119250.08392333984,"pid":1,"tid":1},
{"name":"TracingStartedInBrowser","cat":"disabled-by-default-devtools.timeline","ph":"M","ts":119250.08392333984,"pid":1,"tid":1},
{"pid":1,"tid":1,"ph":"B","cat":"program","ts":119954.41627502441,"name":"createProgram","args":{"configFilePath":"/Users/username/webapp/tsconfig.json"}},
{"pid":1,"tid":1,"ph":"B","cat":"parse","ts":121123.66676330566,"name":"createSourceFile","args":{"path":"/Users/username/webapp/src/App.tsx"}},

Now that I had a trace I was able to use @typescript/analyze-trace to check for any obvious issues:

$ npx @typescript/analyze-trace ts-traces > ./ts-traces/analysis.txt

Lo and behold, it reported a hot spot in analysis.txt that was generated from the trace:

Trace ended unexpectedly
> checkSourceFile: {"path":"/users/username/webapp/src/components/button.tsx"}

Hot Spots
└─ Check file [35m/users/username/webapp/src/components/[36mbutton.tsx[39m[35m[39m (2266550ms)

Duplicate packages
├─ @mui/base
│ ├─ Version 5.0.0-beta.2 from /Users/username/webapp/node_modules/@mui/base
│ └─ Version 5.0.0-alpha.118 from /Users/username/webapp/node_modules/@mui/material/node_modules/@mui/base
└─ @mui/system
├─ Version 5.13.2 from /Users/username/webapp/node_modules/@mui/lab/node_modules/@mui/system
└─ Version 5.11.9 from /Users/username/webapp/node_modules/@mui/system

There are two things to notice in this output:

  1. The main culprit seems to be a custom button component
  2. There are duplicate packages installed because @mui/material and @mui/lab internally depend on different versions of these packages

This is what the button component, which was reported as a hot spot, looks like. It imports both @mui/material and @mui/lab

import type { PropsWithChildren } from 'react';
import type { ButtonProps } from '@mui/material';
import { Button as MuiButton } from '@mui/material';
import type { LoadingButtonProps } from '@mui/lab';
import { LoadingButton } from '@mui/lab';

type Props = PropsWithChildren<ButtonProps & LoadingButtonProps>;

export function Button(props: Props) {
const { children, ...rest } = props;

if (props.loading !== undefined) {
return (
<LoadingButton
{...rest}
>
{children}
</LoadingButton>
);
}

return (
<MuiButton
{...rest}
>
{children}
</MuiButton>
);
}

It’s a simple wrapper for MUI Button and LoadingButton component.

Putting the two clues together, I figured out that the issue came from types coming from @mui/material and @mui/lab packages, which didn’t work well together.

The first step towards resolving the issue was to install the latest versions of both packages:

$ npm i @mui/material@latest @mui/lab@latest

After upgrading the package, running tsc now finishes in only 3.39 seconds and uses just 380 MB of memory. This has finally solved the issue. Of course, I could have found a solution by simply updating all packages to their latest versions, but where's the fun in that, right? :)

Now when running the tracer and trace analysis, it reports no hotspots but still detects an issue with duplicate packages:

No hot spots found

Duplicate packages
├─ @mui/base
│ ├─ Version 5.0.0-beta.4 from /Users/username/webapp/node_modules/@mui/base
│ └─ Version 5.0.0-beta.5 from /Users/username/webapp/node_modules/@mui/material/node_modules/@mui/base
└─ @mui/system
├─ Version 5.13.6 from /Users/username/webapp/node_modules/@mui/material/node_modules/@mui/system
└─ Version 5.11.9 from /Users/username/webapp/node_modules/@mui/system

As the duplicate package issue has not been resolved, it is likely that these duplicate dependencies may cause similar issues in the future. However, it is easy to diagnose the issue using the --generateTrace flag and the @typescript/analyze-trace package.

In addition to understanding how to diagnose TypeScript compiler issues, the main takeaway from this post is to be aware of the potential issues that may arise when using @mui/material and @mui/lab packages together.

--

--