From 120 to 800 MB of RAM usage in 5 minutes

 

A story of plugging memory leaks

jsconf.be, 21.06.2019

Hi, I'm Rafal RUManek

Doing frontend stuff @Codewise

Event organizer @AngularDragons

@RafalRumanek


I have a poor sense of humour
 

 (please, forgive me!)

Agenda

  1. Performance & memory leaks intro
  2. Learning your mistakes the hard way
  3. Leaks & fixes - case studies
  4. Synthetic memory tests
  5. Real User Measurements

Load time PLAYS A key role

  • minification, compression, tree shaking
  • bundling & code splitting
  • CDN, DNS, no. of connections optimizations
  • caching (static & dynamic content), browser & CDN
  • HTTP push, pre-fetch, pre-connect, loading="lazy" ....
  • ...

We need our (potential) users to get to use our app 

soon

But... What HAPPENS then?


  • what if you as developer don't even get to experience their experience?
  • what if your users spend hours, days or even weeks without page reload?

But... What HAPPENS then?


  • what if you as developer don't even get to experience their experience?
  • what if your users spend hours, days or even weeks without page reload?

 

  • what if it's not good?

INDUSTRY STAnDARDS?

WHAT If your app does that

WHAT If your app does that

memory management

High-level languages embed a piece of software called "garbage collector" whose job is to track memory allocation and use in order to find when a piece of allocated memory is not needed any longer in which case, it will automatically free it.

Browser Javascript engines use Garbage Collectors use Mark and Sweep algorithm with generational approach (as most of objects 'die' young)

memory leak- what is it?

Memory leak is a type of resource leak that occurs when a computer program incorrectly manages memory allocations in such a way that memory which is no longer needed is not released.

memory leak- how it happens?

  • not removed event listeners (when no longer used), RxJS subscriptions, timers ...
  • references kept in closure
  • circular references*
  • detached and not destroyed HTML nodes
  • overusing global scope

*circular references aren't the case in Mark and Sweep algorithm,

but there are edge cases for JS + DOM

memory leak- what it does?

  • keeps resources even when they are not needed
  • most likely leads to downgrading performance of your app

speaking of which...

START WITH profiling

profiling

performance monitor

memory api

    
    // window.performance.memory
   
    {
      totalJSHeapSize: 29400000,
      usedJSHeapSize: 15200000,
      jsHeapSizeLimit: 1530000000
    }
  • only in Chrome
  • even in chrome requires flag
    --enable-precise-memory-info

synthetic memory tests

Memory snapshots

you can even ask your clients to send you a snapshot and analyze it ❤️

Memory snapshots comparison

przyklad, omowic retained vs shallow size

you can even ask your clients to send you their snapshots

it's a delicious tooL, really

Disclaimer #1

  • we did/do have solid (and sometimes painful) code reviews
  • we did/do have very experienced engineers on team
  • we did/do have multiple layers of regression tests 
  • ...but we are humans after all :)

 

Disclaimer #2

Causes of memory leaks are quite often trivial. Once you find them and understand them, they might be really easy to fix.

CASE study #1 - snapshot

CASE STUDY #1 - cause

YUI Widget class:

// meanwhile in our code

this._toolbarWidget.destroy();
// ... 
this._quickSearchWidget.destroy();
// ...
this._quickSearchWidget.destroy();

Really?

Really?

 

Well, it's getting better

CASE STUDY #2 - snapshot

There shouldn't be more than 50 at that time, wtf

CASE STUDY #2 - snapshot

It turned out there's whole report model cached

CASE STUDY #2 - cause

  • framework's global events' handlers not being detached
  • not destroyed components at all
  • view wasn't properly destroyed (zombie skeleton remained) - patch created
  • plus some other smaller leaks at that time, including considerable amount of data kept in closure

CASE STUDY #2 - cause

// wrong - existed for 4 years <3
Y.on('reload-report-after-first-payment', this.load, this);

// our wrapper for auto detaching upon destruction
this.addEventHandler(Y.on('reload-report-after-first-payment', this.load, this));
_bindResizeEvent: function () {
    const resizeCallback = Y.CommonHelpers.debounce(200, this.onWindowResize, this);
    this._windowResizeHandle = Y.on('resize', resizeCallback, null, this);
},


// fixed
_bindResizeEvent: function () {
    const resizeCallback = Y.CommonHelpers.debounce(200, this.onWindowResize, this);
    this._windowResizeHandle = this.addEventHandler(Y.on('resize', resizeCallback, null, this));
},

In general, mostly silly and too obvious mistakes (not only tho), but...

it kinda felt like this

CASE STUDY #3 - snapshot

CASE STUDY #3 - cause

@DestroyAware()
private readonly destroy$: Subject<void> = new Subject<void>();

// ...

this.campaignSave$.pipe(
        takeUntil(this.destroy$) // <--- this was doing the trick
        // ...
    ).subscribe(savedCampaign => this.onSaveSucceeded(savedCampaign));

faulty auto-unsubscribe

@DestroyAware relied on ngOnDestroy hook, which  worked for us only when interface OnDestroy is explicitly implmenented

syntH. memory tests - before

(one of scenarios)

syntH. memory tests - AFTER

syntH. memory tests - AFTER

syntH. memory tests

import {Selector} from 'testcafe';
import {ITERATIONS_COUNT} from "../utils/consts";
import {runForCertAndDemoEnvs} from "../utils/testWrappers";

fixture `Tracker memory leaks`;

runForCertAndDemoEnvs('reports tabs', async t => {
    for (let i = 0; i < ITERATIONS_COUNT; i++) {
        await Selector('[data-button-id="cost-update"]');
        await t.click('[data-filterby="offer"]');
        await Selector('[title="Creates a new offer"]');

        t.ctx.memoryManager.snapshot();

        await t.click('[data-filterby="campaign"]');
    }
});

TestCafe + headless Chrome + CircleCI

syntH. memory tests

import {ClientFunction} from "testcafe";

export default async function initMemoryManager(t) {
    t.ctx.memoryManager = new MemoryManager();
}

class MemoryManager {
    memorySnapshots = [];

    // here it goes
    getUsedJSHeapSize = ClientFunction(() => performance.memory.usedJSHeapSize);

    async snapshot() {
        const usedJSHeapSize = await this.getUsedJSHeapSize();
        this.memorySnapshots.push([this.memorySnapshots.length + 1, usedJSHeapSize]);
    }
}

real user

measurements

 

tests in production

  • calculations are only approximate
  • yet another task that would slow down code execution on client's side

Manually calculate memory allocated on heap but:

...or look for symptoms

long tasks


    const observer = new PerformanceObserver((list) => {
        const perfEntries = list.getEntries();
    
        // queue reporting for long tasks existance
    });
    
    observer.observe({entryTypes: ["longtask"]});
    
    // long task refers to occurrences of event loop tasks
    // or pauses caused by UI thread work
    // whose duration exceeds 50ms

long tasks

long tasks

  • chrome exclusive tho....
  • ... but you get the idea of what's happening in general 

downgrading percieved behavior

key take aways

  1. Poor performance (just like UX) could be the reason for your users to leave you!
  2. Tracking potential memory leaks on client's side isn't easy, but there are ways
  3. Over the years you won't avoid having even obvious mistakes in your code

Thank you!

@RafalRumanek

rumanek.r@gmail.com

Plugging memory leaks @ jsconf.be 21.06.2019

By Rafał Rumanek (truti)

Plugging memory leaks @ jsconf.be 21.06.2019

  • 406