DEV Community

Cover image for How I Fixed A Memory Leak In Expose.sh, My NodeJS App
Robbie Cahill
Robbie Cahill

Posted on

How I Fixed A Memory Leak In Expose.sh, My NodeJS App

Introduction

If you run Node in production, sooner or later you'll come across a common bug known as a memory leak.

This was the case with my current side project, expose. I wrote a popular article Six Ways To Drastically Boost Your Productivity As A Node Developer which mentioned it, then suddenly the server was overloaded.

During the period of high load, I could see that a memory leak was forming.

What is expose?

expose is a command line app that makes its simple to give a public URL to any web based app you have running locally on your machine. So if you have a local API runnig at http://localhost:8000 and you then run expose 8000, it will generate a public URL that you can then share.

It works by creating a websocket connection between the client and the expose service, which listens with websocket, http and https. When requests come in to the public URL, they are routed through the websocket connection to the client and then the client hits your server locally.

This has various uses like demoing early work without needing to deploy code anywhere and debugging webhook integrations.

You can install it for Linux, Mac and Windows here

The leak

In the expose server, I have a Singleton class called Proxy, which is in TypeScript, the superset of JavaScript with type safety.

This class manages all client connections to the expose service. Anytime you run expose to get a public url for your project running on localhost, a Websocket connection is created between the client and the service. Those connections are stored in Proxy.connections.

This is a trimmed down version of the Proxy class. The real version has extra logic, such as finder methods to help route requests to the right client websocket so that you see your site, not someone elses when you hit the public URL.

import Connection from "./connection";
import HostipWebSocket from "./websocket/host-ip-websocket";

export default class Proxy {
    private static instance : Proxy;

    connections : Array<Connection> = [];

    addConnection(hostname: string, websocket: HostipWebSocket, clientId: string): void {
        const connection : Connection = {
            hostname,
            clientId,
            websocket
        };

        this.connections.push(connection);
    }
....
More methods to find the right connections, avoid    duplicates etc...
....

    listConnections() {
        return this.connections;
    }

    public static getInstance(): Proxy {
        if (!Proxy.instance) {
            Proxy.instance = new Proxy();
        }

        return Proxy.instance;
    }
}
Enter fullscreen mode Exit fullscreen mode

Every time a client connects, addConnection() is called. The problem here is that when they disconnect, the Websocket connection stays alive and their entry in Proxy.connections stays there.

So as more clients connect, the Proxy.connections array gets bigger and bigger. This is a classic memory leak.

Before the article, this wasn't such an issue as few people were connecting to and using the service. After the article, the server had to deal with more connections, then ran out of memory. I ended up upgrading the instance to a bigger one, which handled the load even with the memory leak.

Fixing the leak

Once the problem was apparent, I went about fixing the leak.

In addConnection(), I started tagging websocket connections with the client id of the connecting client.

    addConnection(hostname: string, websocket: HostipWebSocket, clientId: string): void {
// Tag the connection so it can be found and destroyed later
// when the client disconnects
        websocket.exposeShClientId = clientId;

        const connection : Connection = {
            hostname,
            clientId,
            websocket
        };

        this.connections.push(connection);
    }

Enter fullscreen mode Exit fullscreen mode

I also added a deleteConnection() method to the Proxy class to handle the actual deletion of connections, so they could then be cleaned up by the garbage collector.

    deleteConnection(clientId: string) {
        for (let i = 0; i < this.connections.length; i++) {
            const connection = this.connections[i];

            if (connection.clientId === clientId) {
                this.connections.splice(i, 1);
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

I then added a hook on the websocket connections so that when they close, their associated Connection is deleted

    websocket.on('close', (code: number, reason: string) => {
        websocket.terminate();

        const proxy = Proxy.getInstance();

        proxy.deleteConnection(websocket.exposeShClientId);
    });
Enter fullscreen mode Exit fullscreen mode

Once this was done, connections in Proxy.connections were cleaned up as clients disconnected. No more endlessly growing array and no more memory leak.

Conclusion

Memory leaks are common in Node as servers often run as a single process. Anything left over from each connection that grows will cause a memory leak.

So keep an eye out for them next time you see your instance running out of memory.

Tip: If you want to basically almost eliminate memory leaks, consider trying out PHP, my other favorite language. Each request is a separate process so it is basically stateless. It wouldn't work for expose, because the server needs to maintain state with the connections.

To introduce a memory leak into a PHP application would take alot of effort - not just a bug in the code but also very bad misconfiguration. This is one of the better parts of PHP as you are protected from these kinds of bugs.

Discussion (0)