Skip to content

Memory leak in dev server rebuilds #32584

@Joyzyy

Description

@Joyzyy

Command

serve

Is this a regression?

  • Yes, this behavior used to work in the previous version

The previous version in which this bug was not present was

No response

Description

When running ng serve with i18n configured, every file save (.html files) triggers a rebuild that creats a new piscina ThreadPool for i18n-inline-worker.js. Old pools and their worker threads are not properly cleaned up, causing unbounded memory growth. Each pool spawns worker threads with their own V8 isolates (~120 MB each). After normal deployment activity (~100 saves), the Node.js process consumes over 6GB of memory.

Image

Disabling i18n configuration in angular.json completely eliminates the leak as no new threads are spawned and memory remains stable across development and html file rebuilds.

In the video attached below, you can see the issue in action, each save of the .html file, creates a new thread and increases the memory used by the ng serve nodejs process

Untitled.mov

Minimal Reproduction

  1. Create an Angular project with i18n configured, and use the i18n directive or $localize in the template of an HTML file.
  2. Run ng serve
  3. Open the component's html file and save it repeatedly
  4. Monitor memory and thread count by using Activity Monitor or vmmap / footprint
  5. Observe memory and thread count going up with each save of the html file

Exception or Error


Your Environment

Angular CLI       : 21.1.0
Angular           : 21.1.0
Node.js           : 24.7.0
Package Manager   : npm 11.5.1
Operating System  : darwin arm64

┌───────────────────────────┬───────────────────┬───────────────────┐
│ Package                   │ Installed Version │ Requested Version │
├───────────────────────────┼───────────────────┼───────────────────┤
│ @angular/aria             │ 21.1.0            │ ^21.1.0           │
│ @angular/build            │ 21.1.0            │ ^21.1.0           │
│ @angular/cdk              │ 21.1.0            │ ^21.1.0           │
│ @angular/cli              │ 21.1.0            │ ^21.1.0           │
│ @angular/common           │ 21.1.0            │ ^21.1.0           │
│ @angular/compiler         │ 21.1.0            │ ^21.1.0           │
│ @angular/compiler-cli     │ 21.1.0            │ ^21.1.0           │
│ @angular/core             │ 21.1.0            │ ^21.1.0           │
│ @angular/forms            │ 21.1.0            │ ^21.1.0           │
│ @angular/localize         │ 21.1.0            │ ^21.1.0           │
│ @angular/material         │ 21.1.0            │ ^21.1.0           │
│ @angular/platform-browser │ 21.1.0            │ ^21.1.0           │
│ @angular/platform-server  │ 21.1.0            │ ^21.1.0           │
│ @angular/router           │ 21.1.0            │ ^21.1.0           │
│ @angular/ssr              │ 21.1.0            │ ^21.1.0           │
│ rxjs                      │ 7.8.2             │ ~7.8.2            │
│ typescript                │ 5.9.3             │ ~5.9.3            │
│ vitest                    │ 4.0.17            │ ^4.0.17           │
└───────────────────────────┴───────────────────┴───────────────────┘

Anything else relevant?

From what I've gathered, the following function creates a new ThreadPool on every rebuild

Each call to inlineI18n, which is triggered on every rebuild via

if (i18nOptions.shouldInline) {
const result = await inlineI18n(metafile, options, executionResult, initialFiles);
executionResult.addErrors(result.errors);
executionResult.addWarnings(result.warnings);
executionResult.addPrerenderedRoutes(result.prerenderedRoutes);
} else {

creates a new i18nInliner, which creates a new WorkerPool, so each pool immediately spawns at least one OS thread with its own V8 isolate

Now I THINK (not entirely sure, but I did a little fix and it fixed the thread spawning problem) the issue is this piece of code:

try {
for (const locale of i18nOptions.inlineLocales) {
// A locale specific set of files is returned from the inliner.
const localeInlineResult = await inliner.inlineForLocale(
locale,
i18nOptions.locales[locale].translation,
);
const localeOutputFiles = localeInlineResult.outputFiles;
inlineResult.errors.push(...localeInlineResult.errors);
inlineResult.warnings.push(...localeInlineResult.warnings);
const {
errors,
warnings,
additionalAssets,
additionalOutputFiles,
prerenderedRoutes: generatedRoutes,
} = await executePostBundleSteps(
metafile,
{
...options,
baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref,
},
[...unModifiedOutputFiles, ...localeOutputFiles],
executionResult.assetFiles,
initialFiles,
locale,
);
localeOutputFiles.push(...additionalOutputFiles);
inlineResult.errors.push(...errors);
inlineResult.warnings.push(...warnings);
// Update directory with locale base or subPath
const subPath = i18nOptions.locales[locale].subPath;
if (i18nOptions.flatOutput !== true) {
localeOutputFiles.forEach((file) => {
file.path = join(subPath, file.path);
});
for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) {
updatedAssetFiles.push({
source: assetFile.source,
destination: join(subPath, assetFile.destination),
});
}
} else {
executionResult.assetFiles.push(...additionalAssets);
}
inlineResult.prerenderedRoutes = { ...inlineResult.prerenderedRoutes, ...generatedRoutes };
updatedOutputFiles.push(...localeOutputFiles);
}
} finally {
await inliner.close();
}
// Update the result with all localized files.
executionResult.outputFiles = [
// Root and SSR entry files are not modified.
...unModifiedOutputFiles,
// Updated files for each locale.
...updatedOutputFiles,
];
// Assets are only changed if not using the flat output option
if (!i18nOptions.flatOutput) {
executionResult.assetFiles = updatedAssetFiles;
}
// Inline any template updates if present
if (executionResult.templateUpdates?.size) {
// The development server only allows a single locale but issue a warning if used programmatically (experimental)
// with multiple locales and template HMR.
if (i18nOptions.inlineLocales.size > 1) {
inlineResult.warnings.push(
`Component HMR updates can only be inlined with a single locale. The first locale will be used.`,
);
}
const firstLocale = [...i18nOptions.inlineLocales][0];
for (const [id, content] of executionResult.templateUpdates) {
const templateUpdateResult = await inliner.inlineTemplateUpdate(
firstLocale,
i18nOptions.locales[firstLocale].translation,
content,
id,
);
executionResult.templateUpdates.set(id, templateUpdateResult.code);
inlineResult.errors.push(...templateUpdateResult.errors);
inlineResult.warnings.push(...templateUpdateResult.warnings);
}
}

The if (executionResult.templateUpdates?.size) runs after the finally block which closes the newly created inliner; but inside the if block we use the closed inliner await inliner.inlineForLocale, which then uses the WorkPool instance:

const { output, messages } = await this.#workerPool.run(
{ code: templateCode, filename: templateId, locale, translation },
{ name: 'inlineCode' },
);

When doing a test and overriding the method run from the Workpool class, the current worker threads is 0. This causes Piscina to create a new thread (i think, i'm not really sure how Piscina works under the hood), but because this new thread is never called .destroy upon (like we do in the finally block), it becomes an orphan thread, which can't close after the idleTimeout: 4_000, because of the minThreads: 1.

Finally, moving the if block inside the try block (before closing the inliner in the finally block), fixed the issue.
I'm happy to open a PR with the change if it turns out the findings are correct (since I'm not too sure of how Piscina works under the hood fully)

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions