Memory Leak in Google Maps


Author: Rahul Krishna

Premise


Untitled

In order to visualize geospatial data, we are employing the google-map-react library. However, we have encountered a performance issue when rendering a substantial volume of data points. This issue manifests as a temporary unresponsiveness of the page, followed by a noticeable slowdown in subsequent operations. These symptoms suggest the presence of a memory leak.

Untitled

This is the code used. The only cleanup happening was setting data to null.

The issue


There are three issues present here.

  1. Destroying Google Maps instance never frees up memory. The persistent memory leak issue within the Google Maps JavaScript Library has been a significant concern for developers. Upon visiting the page, approximately 600MB of memory remains unfreed, leading to potential performance degradation. This problem was initially documented in Google’s Bug Tracker in 2011, and the most recent update was in February 2023, indicating that this issue has persisted for over a decade.
  2. A line consists of three drawings. In an optimal scenario, the ‘trip line’ should be represented by a single stroke. However, in the current implementation, we are generating three separate drawings: one Polyline and two Markers. It has been observed that the process of rendering the Markers is particularly resource-intensive, leading to a noticeable decrease in performance.
  3. The Polylines could be made into an Overlay. In lieu of instantiating classes within the Google Maps environment, we propose the development of a distinct Polyline component. This approach would allow us to isolate and effectively manage any associated memory-related issues within this separate component.

The fix for Google Maps memory leak


Untitled

But we can try to Introduce a function to manually delete all of Google Map’s event listeners.

// Helper function: Removes all event listeners registered with Google's addDomListener function,
// including from __e3_ properties on target objects.
function removeAllGoogleListeners(target, event) {
  var listeners = target["__e3_"];
  if (!listeners) {
    console.warn(
      "Couldn't find property __e3_ containing Google Maps listeners. Perhaps Google updated the Maps SDK?"
    );
    return;
  }
  var evListeners = listeners[event];
  if (evListeners) {
    for (var key in evListeners) {
      if (evListeners.hasOwnProperty(key)) {
        google.maps.event.removeListener(evListeners[key]);
      }
    }
  }
}

// Removes all DOM listeners for the given target and event.
function removeAllDOMListeners(target, event) {
  var listeners = target["__listeners_"];
  if (!listeners || !listeners.length) {
    return;
  }

  // Copy to avoid iterating over array that we mutate via removeEventListener
  var copy = listeners.slice(0);
  for (var i = 0; i < copy.length; i++) {
    target.removeEventListener(event, copy[i]);
  }
}

// Shim addEventListener to capture and store registered event listeners.
var addEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function () {
  var eventName = arguments[0];
  var listener = arguments[1];
  if (!this["__listeners_"]) {
    this.__listeners_ = {};
  }
  var listeners = this.__listeners_;
  if (!listeners[eventName]) {
    listeners[eventName] = [];
  }
  listeners[eventName].push(listener);
  return addEventListener.apply(this, arguments);
};
var removeEventListener = EventTarget.prototype.removeEventListener;
EventTarget.prototype.removeEventListener = function () {
  var eventName = arguments[0];
  var listener = arguments[1];
  if (this["__listeners_"] && this.__listeners_[eventName]) {
    // Loop because the same listener may be added twice with different
    // options, and because our simple addEventListener shim doesn't
    // check for duplicates.
    while (true) {
      var i = this.__listeners_[eventName].indexOf(listener);
      if (i === -1) {
        break;
      }
      this.__listeners_[eventName].splice(i, 1);
    }
  }
  return removeEventListener.apply(this, arguments);
};

// After you remove the Google Map from the DOM, call this function to completely free the object.
export default function destroyGoogleMaps(window) {
  removeAllGoogleListeners(window, "blur");
  removeAllGoogleListeners(window, "resize");
  removeAllGoogleListeners(document, "click");
  removeAllGoogleListeners(document, "keydown");
  removeAllGoogleListeners(document, "keypress");
  removeAllGoogleListeners(document, "keyup");
  removeAllGoogleListeners(document, "MSFullscreenChange");
  removeAllGoogleListeners(document, "fullscreenchange");
  removeAllGoogleListeners(document, "mozfullscreenchange");
  removeAllGoogleListeners(document, "webkitfullscreenchange");
  // ASSUMPTION: No other library registers global resize and scroll event listeners! If this is not true, then you'll need to add logic to avoid removing these.
  removeAllDOMListeners(window, "resize");
  removeAllDOMListeners(window, "scroll");
}

Credit to this commenter: https://issuetracker.google.com/issues/35821412#comment53

This resolves the memory issue to an extent.

The flow observed is. Landing page → Page with Trips View → Page with another Google Map → Page without Google Map

The flow observed is. Landing page → Page with Trips View → Page with another Google Map → Page without Google Map

In Prod and Staging, the Page without Google Maps hogs up memory while introducing the memory cleanup in dev shows us that it requires way less memory!

The fix for three drawings


This one happens to be very simple. While creating a Polyline, just specify an icon and set the repeat value as 100%.

new google.maps.Polyline({
  strokeColor: props.color,
  geodesic: true,
  strokeWeight: 3,
  icons: [
    {
      icon: {
        path: google.maps.SymbolPath.CIRCLE,
      },
      repeat: "100%",
    },
  ],
});

We can even make the icon a SymbolPath.FORWARD_PATH (Reference: https://developers.google.com/maps/documentation/javascript/reference/marker#SymbolPath) indicating the trip direction as well!

Making the Polylines an Overlay


Now all of the computations happen inside the onGoogleApiLoaded method of google-map-react.

Untitled

First thing, we make this function do nothing but set a state called map. This is for us to later set the PolyLine on to the map.

Polyline.js

import { useState } from "react";

import useDeepCompareEffect from "use-deep-compare-effect";

function pathsDiffer(path1, path2) {
  if (path1.getLength() != path2.length) return true;
  for (const [i, val] of path2.entries())
    if (path1.getAt(i).toJSON() != val) return true;
  return false;
}

export default function PolyLine(props) {
  const [polyline, setPolyline] = useState(null);

  useDeepCompareEffect(() => {
    // Create polyline after map initialized.
    if (!polyline && props.map) {
      setPolyline(
        new google.maps.Polyline({
          strokeColor: props.color,
          geodesic: true,
          strokeWeight: 3,
          icons: [
            {
              icon: {
                path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW,
              },
              repeat: "100%",
            },
          ],
        })
      );
    }

    // Synchronize map polyline with component props.
    if (polyline && polyline.getMap() != props.map) polyline.setMap(props.map);
    if (polyline && pathsDiffer(polyline.getPath(), props.path))
      polyline.setPath(props.path);

    return () => {
      // Cleanup: remove line from map
      if (polyline) polyline.setMap(null);
    };
  }, [props, polyline]);

  return null;
}

Now inside the component,

Untitled

Finally, as a sibling to GoogleMapReact we introduce the Polyline component.

Untitled

Final Result


Maps look way more meaningful

Untitled

The memory consumption which went from 500MB to 367MB after memory cleanup, went down to 140MB after removing the marker drawings!

This project is maintained by rahulakrishna on GitHub. You can find me on Mastodon @_thisdot@mastodon.social