davesmithhayes.com

Understanding JavaScript Modules

Posted on 2026-10-07 | Tagged: javascript, development | Back to Blog

I started writing JavaScript for Node in early 2018. Back then, there was a lot to be desired with the platform. The module structure wasn't great, there was no async/await keywords yet, and everything ended up in callback hell.

Coming from PHP (and a little bit of C#), I was really indoctrinated in OOP principals and understanding how scope and objects worked within the Request/Response lifecycle of PHP applications. At the same time I was working more and more with Magento 2 which has as very sophisticated Dependency Injection framework. You can really learn to appreciate writing good interfaces and passing them around the code to achieve your software goals.

Eventually TypeScript was released and I was very excited about this. A type-checker in JavaScript made me believe that it was more and more suitable for serious projects at our company. So I started writing TypeScript exactly like how I would write PHP (or C#). Only after a few years did I realize that this was a mistake.

Singletons and Shared Memory Module

One thing I never took enough time to understand in the Node.js world is how modules actually worked under the hood and how code was executed. I would see the need for a singleton in our codebase and I would literally create a class that instanciates itself and then returns a reference to itself throughout the codebase.

I also would inject instantiated classes into a global object like the Express.js Application object that's bootstrapped. Actually, I would bootstrap everything I need in a single server.ts file that starts the application.

Modules are evaluated once on import. In the OOP world, this means every single Module in JS is, in itself, a Singleton. That means you can write some code like this:

// greeter.ts

class Greeter {
    private calls: number = 0;

    greet(name: string): string {
        this.calls++;
        return `Hello ${name}!`;
    }

    get called(): number {
        return this.calls;
    }
}

const greeter = new Greeter();
export greeter;

This instantiates a single Greeter class and exports it from the module to be used elsewhere. The state of the object is maintained throughout the lifetime of the application. The value of calls will always be accurate no matter who else is asking for how many times the function has been called.

Modules as Objects

This means you can masquerade a JS module as an Object in and of itself.

// greeter.ts

let calls = 0;

export function greet(name: string): string {
    calls++;
    return `Hello ${name}`;
}

export function called(): number {
    return calls;
}

And when you use that module in the code you can pull it in as an alias:

import * as greeter from './greeter.ts';

console.log(greeter.greet('Dave'));        // "Hello Dave"
console.log(`Calls: ${greeter.called()}`); // Calls: 1

Where Does This Matter?

At work, we built a service that uses the AWS SDK. We instantiated a new S3Client for each request to the service. When we did load testing we saw that the memory usage ballooned like crazy. This was unacceptable.

So instead, we moved to a singleton model and created a single module called utils/s3.ts which would bootstrap the S3Client and with all the proper configuration. We saw that the application could handle much more load with way less memory when we thought about what code doens't need to be isolated to the single request.

We do this same thing with Database connection pools. One module that pulls in all the configuration required, sets up the connections and exports the pool to the rest of the application. So we end up having code that looks like this:

import { s3 } from './utils/s3.ts';
import { dbPool } from './utils/db.ts';

const conn = await dbPool.getConnection();

// do something with the connection
// set up a Command object to send to S3
const command = ...;
const response = await s3.send(command);

// release the connection back to the pool
conn.release();

Conclusion

I had to really wrap my head around that that the entire scope of the application in Node.js is the running process - not the request and response lifecycle I was used to. The more you know.