Search code examples
angulargoogle-chromememory-leaksgarbage-collectionangular-components

Memory Leak - Garbage Collection in Browser Not Collecting Components


Angular Component Memory Leak - on Chrome and Microsoft Edge (could be all browsers, haven't tested all of them yet)

  • I've tried the app below on the latest versions of Angular 11, 12, and 13, and I've been able to get them all to leak memory
  • browser isn't garbage collecting Angular components when they get destroyed
  • the components shallow size (from the memory inspector) seems small, but the retained size (from the memory inspector) is large
  • DOM Nodes are kept in memory, causing a significant memory leak
  • I've been able to get this program to cause memory leaks in Chrome and Edge (haven't tried any others yet)
  • it happens in both dev and production mode of angular
  • it could have todo with the css property, display: flex
  • I can't reproduce the leak on stackblitz. I can only get it to work on machines running the app locally

Sometimes it won't leak memory, sometimes it will. That's what makes this one of the biggest head scratchers I've faced in a while.

Please don't immediately write this off as "I can't replicate, so it must be fake", if when you first run the program below and it doesn't leak memory. Try it a couple times first, please. I swear it will eventually get into a state when started (because this bug has been plaguing me for a while), that causes it to leak memory.

I've narrow it down to this simple app, since this memory leak has been messing with a much larger production app that I've been working on.

Below is the simplified app that can be used to test with.

https://github.com/kevinpbaker/angular-memory-killer

package.json

{
  "name": "memory-killer",
  "version": "0.0.0",
  "scripts": {
    "ng": "ng",
    "start": "ng serve --port 4201",
    "build": "ng build --prod --aot --buildOptimizer --commonChunk --vendorChunk --optimization --progress",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "~11.2.14",
    "@angular/cdk": "^11.2.13",
    "@angular/common": "~11.2.14",
    "@angular/compiler": "~11.2.14",
    "@angular/core": "~11.2.14",
    "@angular/forms": "~11.2.14",
    "@angular/material": "^11.2.13",
    "@angular/platform-browser": "~11.2.14",
    "@angular/platform-browser-dynamic": "~11.2.14",
    "@angular/router": "~11.2.14",
    "rxjs": "~6.5.4",
    "rxjs-compat": "^6.5.5",
    "zone.js": "~0.11.4"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "~0.1102.18",
    "@angular/cli": "~11.2.18",
    "@angular/compiler-cli": "~11.2.14",
    "@angular/language-service": "~11.2.14",
    "@types/jasmine": "~3.6.0",
    "@types/jasminewd2": "~2.0.3",
    "@types/node": "^12.11.1",
    "codelyzer": "^6.0.0",
    "jasmine-core": "~4.0.0",
    "jasmine-spec-reporter": "~5.0.0",
    "karma": "~6.3.13",
    "karma-chrome-launcher": "~3.1.0",
    "karma-coverage-istanbul-reporter": "~3.0.2",
    "karma-jasmine": "~4.0.0",
    "karma-jasmine-html-reporter": "^1.7.0",
    "protractor": "~7.0.0",
    "ts-node": "~8.3.0",
    "tslint": "~6.1.0",
    "typescript": "4.0.8"
  }
}

app.module.ts

import {NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {AppComponent, MemoryKiller2Component, MemoryKillerComponent} from './app.component';

@NgModule({
  declarations: [
    AppComponent,
    MemoryKillerComponent,
    MemoryKiller2Component
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Memory Killer</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
  <app-root></app-root>
</body>
</html>

app.component.ts

import {Component} from '@angular/core';
import {timer} from 'rxjs';
import {map} from 'rxjs/operators';

@Component({
  selector: 'app-root',
  template: '<app-memory-killer *ngIf="killCycle$|async"></app-memory-killer>',
})
export class AppComponent {
  // change 1000 to 1 if you want to speed up the memory killer
  killCycle$ = timer(0, 1000).pipe(map(k => k % 2 !== 0));
}

@Component({
  selector: 'app-memory-killer',
  template: `
    <div class="memory-killer">
      <app-memory-killer2></app-memory-killer2>
      <app-memory-killer2></app-memory-killer2>
      <app-memory-killer2></app-memory-killer2>
    </div>
  `,
  styles: [`.memory-killer { display: flex; }`]
})
export class MemoryKillerComponent { }

@Component({
  selector: 'app-memory-killer2',
  template: `
    <div class="memory-killer2">
      <div>Killing your memory</div>
      <div>Killing your memory</div>
      <div>Killing your memory</div>
    </div>
  `,
  styles: [`.memory-killer2 { display: flex; }`],
})
export class MemoryKiller2Component { }

Microsoft Edge Detached Elements Viewer and Memory Inspector - Version 97.0.1072.55

Chrome Memory Inspector - Version 97.0.4692.99

Visual of DOM Nodes Climbing - You can spam manual garbage collection and it won't collect the components

Any input as to if this is an angular bug, a browser bug, or if you could point me to the right person that could help me figure this out would be greatly appreciated.


This issue was a browser bug. It has been fixed by the chromium dev team. See the last post within "Original Angular issue post"

Original Angular issue post: https://github.com/angular/angular/issues/45080

Chromium bug specifying fix: https://bugs.chromium.org/p/chromium/issues/detail?id=1308845


Solution

  • This bug has been fixed in Chrome Version M102: See Chromium Bug Report.

    The leak happens because LayoutInline gets added to TextAutoresizer::fingerprints_ but is never removed. LayoutBlocks gets removed because it calls TextAutoresizer::Destroy() from WillBeDestroyed(). The Oilpanize CL just made the leak bigger because now we retain LayoutObjects pointed by TextAutoresizer::fingerprints_.

    The leak detector could not catch this leak because TextAutoresizer is owned by the document so the leak will go away on navigation.

    The following revision refers to this bug: https://chromium.googlesource.com/chromium/src/+/3b3305a8d8566a9ba5ef6ccf9d363e6497f06356

    commit 3b3305a8d8566a9ba5ef6ccf9d363e6497f06356 Author: Keishi Hattori [email protected] Date: Mon Apr 04 23:34:17 2022

    LayoutInline should be removed from TextAutosizer::FingerprintMapper

    LayoutInline was not removed from TextAutosizer::FingerprintMapper::fingerprints_ causing a memory leak.

    This CL removed LayoutInline from TextAutosizer in WillBeDestroyed().