Premise
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.
This is the code used. The only cleanup happening was setting data
to null
.
The issue
There are three issues present here.
- 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.
- 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.
-
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
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
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
.
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,
Finally, as a sibling to GoogleMapReact
we introduce the Polyline
component.
Final Result
Maps look way more meaningful
The memory consumption which went from 500MB
to 367MB
after memory cleanup, went down to 140MB
after removing the marker drawings!