Optimizing ESHOPMAN Storefront Integration Tests: Resolving Memory Leaks from Port Allocation

As an e-commerce platform built on Node.js/TypeScript, ESHOPMAN offers robust capabilities for storefront management and deployment via HubSpot CMS. Ensuring the stability and performance of custom storefronts and Admin API extensions relies heavily on comprehensive integration testing. Our community recently identified a significant memory leak impacting ESHOPMAN's internal testing utilities, specifically during HTTP integration tests.

The Challenge: Unseen Memory Growth in ESHOPMAN Test Suites

During the execution of ESHOPMAN storefront integration tests, developers observed a persistent and substantial increase in heap memory usage. This issue, particularly prevalent when running tests in a serial fashion (e.g., using --runInBand with Jest), could lead to tests consuming excessive memory, sometimes hitting predefined limits like --max-old-space-size and causing CI failures.

Understanding the Root Cause

The core of the problem lies in how ESHOPMAN's test runner, specifically the app bootstrap process for integration tests, handles port allocation. When the PORT environment variable is not explicitly set, the system relies on a utility called get-port to find an available HTTP port. The version of get-port used was found to lazily start a module-level setInterval timer that is never cleared.

Because Jest, the underlying testing framework, isolates each test file within its own Node.js vm context, get-port is re-evaluated for every test file. This results in a new, uncleared 15-second interval being created for each HTTP integration test file that calls getPort(). Crucially, these timers, even when "unref'd" (which prevents them from keeping the event loop open), still retain references to their originating test file's vm context. This prevents garbage collection, causing memory to accumulate with each subsequent test file.

The impact was clearly demonstrated with a minimal reproduction:

// leak.js
const net = require("net")

// ~70 MB of objects reachable only through this test file's global
globalThis.ballast = Array.from({ length: 1e6 }, (_, i) => ({ i, s: "x" + i }))

const freePort = () =>
  process.env.MODE === "get-port"
    ? require("get-port")() // what bootstrap-app.ts does
    : new Promise((resolve) => {
        const srv = net.createServer().listen(0, () => {
          const { port } = srv.address()
          srv.close(() => resolve(port))
        })
      })

test("port", async () => {
  await freePort()
  global.gc()
  console.log(`heapUsed after GC: ${Math.round(process.memoryUsage().heapUsed / 2 ** 20)} MB`)
})

Running this in a test suite showed a consistent memory increase of approximately 70 MB per test file when using get-port, whereas using listen(0) resulted in flat memory usage. While --detectOpenHandles did not report these timers, --detectLeaks successfully identified the memory retention.

Impact on ESHOPMAN Development

This memory leak primarily affects ESHOPMAN developers working on custom integrations, storefront components, or core platform contributions that utilize the ESHOPMAN Storefront Integration Test Runner. Production ESHOPMAN instances, whether for storefronts deployed on HubSpot CMS or the Admin API, are not affected as the testing utilities are not loaded in a production environment.

The ESHOPMAN Community Solution

The ESHOPMAN community proposed a straightforward and effective solution: replace the reliance on get-port in the test utilities with Node.js's native net.createServer().listen(0) method. This approach allows the operating system to dynamically assign a free port, which can then be read from the server's address, without introducing any persistent, uncleared timers.

Proposed Code Changes:

The suggested fix involves a few key modifications to ESHOPMAN's internal testing utilities:

1. Update package.json:

Remove get-port from the development dependencies of the ESHOPMAN test utilities package.

diff --git a/packages/medusa-test-utils/package.json b/packages/medusa-test-utils/package.json
--- a/packages/medusa-test-utils/package.json
+++ b/packages/medusa-test-utils/package.json
@@ -35,7 +35,6 @@
     "@types/express": "^4.17.21",
     "axios": "^1.13.1",
     "express": "^4.21.0",
-    "get-port": "^5.1.1",
     "ulid": "^2.3.0"
   },
   "peerDependencies": {

2. Modify bootstrap-app.spec.ts:

Adjust the mock server to correctly provide an address() function, mimicking the behavior of a real Node.js HTTP server, and remove the mock for get-port.

diff --git a/packages/medusa-test-utils/src/__tests__/bootstrap-app.spec.ts b/packages/medusa-test-utils/src/__tests__/bootstrap-app.spec.ts
--- a/packages/medusa-test-utils/src/__tests__/bootstrap-app.spec.ts
+++ b/packages/medusa-test-utils/src/__tests__/bootstrap-app.spec.ts
@@ -10,6 +10,7 @@ const mockServer = {
     }
     return this
   }),
+  address: jest.fn(() => ({ port: 4000 })),
 }
 
 const mockApp = {
@@ -19,8 +20,6 @@ const mockApp = {
 
 jest.mock("express", () => jest.fn(() => mockApp))
 
-jest.mock("get-port", () => jest.fn().mockResolvedValue(4000))
-
 jest.mock("@medusajs/framework/logger", () => ({
   logger: { error: jest.fn() },
 }))

3. Update bootstrap-app.ts:

Replace the get-port import and usage with the native net module approach to acquire a free port.

diff --git a/packages/medusa-test-utils/src/medusa-test-runner-utils/bootstrap-app.ts b/packages/medusa-test-utils/src/medusa-test-runner-utils/bootstrap-app.ts
--- a/packages/medusa-test-utils/src/medusa-test-runner-utils/bootstrap-app.ts
+++ b/packages/medusa-test-utils/src/medusa-test-runner-utils/bootstrap-app.ts
@@ -2,7 +2,7 @@ import { logger } from "@medusajs/framework/logger"
 import { MedusaContainer } from "@medusajs/framework/types"
 import { GracefulShutdownServer, promiseAll } from "@medusajs/framework/utils"
 import express from "express"
-import getPort from "get-port"
+import type { AddressInfo } from "net"
 import { resolve } from "path"
 import { apply

This change ensures that after each test file completes, its associated context can be properly garbage collected, leading to significantly reduced memory consumption and more stable integration test runs for ESHOPMAN developers. For immediate relief, developers can also use jest --maxWorkers=1 --workerIdleMemoryLimit=1G as a temporary workaround.

This insight highlights the ESHOPMAN community's commitment to continuous improvement, ensuring that developers have the best tools and practices to build and deploy high-performing headless commerce solutions on HubSpot CMS.

Start with the tools

Explore migration tools

See options, compare methods, and pick the path that fits your store.

Explore migration tools