Skip to content

Container Dual-State in the Event Loop: Client Global Singleton vs. Server Request Isolation with Memoized Heavy Singletons ​

Perspective: Full-Stack State Isolation / Single-Threaded Event Loop / Production Patterns
Read Time: ~10 min
Core Dilemma: Why is the true architectural boundary between container lifecycles "Client vs. Server" rather than "Resident vs. Edge"? Under the single-threaded Event Loop of Node.js and Bun, how do you completely eradicate cross-request state pollution without repeatedly recreating heavy singletons like database connection pools?


1. The Architectural Boundary: Client Apps vs. Server Apps ​

When architecting Inversion of Control (IoC) and dependency injection in TypeScript, developers often fall into a common categorization trap: distinguishing container lifecycles by "Resident Long-Running Node.js Servers" versus "Stateless Edge/Serverless Functions".

In reality, the fundamental architectural boundary is dictated by the runtime execution model: Client Applications versus Server Applications.

┌─────────────────────────────────────────────────────────────┐
│                    Client Applications                      │
│   • SPA (React/Vue) / Electron / React Native / Mobile Apps │
│   • Physical Characteristic: Single user exclusive runtime  │
│   • Container Pattern: [Global Singleton Container]         │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                    Server Applications                      │
│   • Node.js / Bun / Cloudflare Workers / Hono / Express     │
│   • Physical Characteristic: Single-threaded Event Loop,    │
│     thousands of concurrent requests interleaving           │
│   • Container Pattern: [Per-Request Isolated Container]     │
│             + [Higher-Order Closure Memoized Singletons]    │
└─────────────────────────────────────────────────────────────┘

2. Client Applications: The Pure Global Singleton ​

In client-side applications, the entire JavaScript runtime is exclusively owned by a single user:

  • Cross-user concurrency and state bleed do not exist;
  • The container is initialized once during application bootstrap;
  • All UI components, page routers, and state managers share the identical container instance.

Standard Client Pattern ​

In client applications, unplugin automatically scans the src/modules directory. The application entry simply invokes the virtual module once during bootstrap without manually collecting or maintaining import lists:

typescript
// src/main.tsx
import { createModularContainer } from "virtual:modular-container";

// Robust bootstrap: Pre-bind the global container object reference to prevent undefined errors during sync lookups
const container = (globalThis.modularContainer = {});
await createModularContainer(container);

Why Pre-Bind the Object Reference?
In frontend codebases, module initialization or early lifecycle hooks often directly access globalThis.modularContainer. If one writes globalThis.modularContainer = await createModularContainer();, the global reference remains undefined throughout the entire asynchronous bootstrap sequence. Pre-binding const container = (globalThis.modularContainer = {}) and passing it to createModularContainer(container) ensures that the container object reference is synchronously accessible from tick zero while the engine fills module instances.

Inside components or views:

tsx
// Directly destructure and consume inside React / Vue components
export function UserProfile() {
  const { userState } = modularContainer;
  return <div>Welcome, {userState.name}</div>;
}

Direct, predictable, and zero cognitive overhead.


3. The Server-Side Dilemma: Single-Threaded Event Loop & State Bleed ​

Server applications inhabit a radically different physical world: Node.js and V8 operate on a single-threaded Event Loop.

When 1,000 HTTP requests arrive concurrently:

  • If the server utilizes a single global singleton container;
  • And any service retains request-scoped state (e.g., currentUserId, tenant context, or trace IDs):
    typescript
    // Dangerous Anti-Pattern: Retaining request state in a global singleton
    export const main = () => {
      let currentUser: User | null = null; // Catastrophic memory sharing!
      return {
        setCurrentUser(user: User) {
          currentUser = user;
        },
        getUser() {
          return currentUser;
        },
      };
    };
  • Request A sets the user to Alice and initiates an async await db.query();
  • The Event Loop yields execution to Request B, which overwrites the user as Bob;
  • Request A resumes execution, now reading Bob's data—a critical cross-request data corruption and security vulnerability.

The Heavy Compromises of Legacy Frameworks ​

  • Java Spring: Relies on OS-level physical threads combined with ThreadLocal storage to segregate request scopes;
  • NestJS: Introduced Scope.REQUEST. However, the official NestJS documentation explicitly warns that request-scoped providers cause severe performance degradation and memory bloat—because NestJS must dynamically recreate and re-evaluate an entire dependency tree for every incoming HTTP request.

4. The Path-IoC Architecture: Per-Request Containers + Closure Memoization ​

Rather than introducing bloated @Scope(Scope.REQUEST) metadata abstractions, Path-IoC leverages the native strength of TypeScript: Higher-Order Function Closures.

  1. Per-Request Container Instantiation: Each HTTP request receives its own lightweight container seeded with a request-scoped requestContext (e.g., Hono's Context or Express's Request);
  2. Memoized Heavy Singletons: Resource-heavy components—such as database connection pools, Redis clients, and ORM entity schemas—are cached across the process lifetime using a pure higher-order closure.

1. Process-Level Singleton Closures: memoizeModule ​

Path-IoC rejects complex framework-level Scope machinery, returning cross-request caching to pure function closures. For an in-depth exploration covering falsy-value safety, Promise cache poisoning defense, and self-healing resilience:
👉 Process-Level Singletons via Closure Caching: The memoizeModule Pattern in Production

In application codebases, maintain this pure utility under utils/memoizeModule.ts:

typescript
// utils/memoizeModule.ts
// Note: ModularContainer is ambiently declared globally by Path-IoC unplugin

export const memoizeModule = <
  Result,
  T extends (modularContainer: ModularContainer, moduleDeclarationNames: string[]) => Result,
>(
  main: T,
): T => {
  let result: Result;
  let initialized = false;

  return ((modularContainer: ModularContainer, moduleDeclarationNames: string[]) => {
    if (!initialized) {
      const val = main(modularContainer, moduleDeclarationNames);
      // 🛡️ Anti-Poisoning Protection: Evict cache on rejection to permit self-healing retries
      if (val && typeof (val as unknown as Promise<unknown>).then === "function") {
        (val as unknown as Promise<unknown>).catch(() => {
          initialized = false;
          result = undefined as unknown as Result;
        });
      }
      result = val;
      initialized = true;
    }
    return result;
  }) as T;
};

2. External Context Protocol: Uniformly Declare via skip: true ​

For runtime context injected dynamically by the host (such as Hono's Context or Express's Request), Path-IoC standardizes on declaring a Mesh module under src/modules/ with skip: true:

typescript
// src/modules/request-context/index.ts
import type { Context } from "hono";

// 💡 Standard External Injection Contract:
// 1. Marked with skip: true: the runtime container skips dummy main; seed object supplies the instance.
// 2. unplugin extracts the return type automatically, ambiently generating 100% type-safe completion.
export const skip = true;
export const main = (): Context => ({}) as Context;

3. Heavy Modules: Process-Wide Singletons via Closure ​

typescript
// src/modules/infrastructure/database/index.ts
import { memoizeModule } from "../../../utils/memoizeModule";
import { createPool } from "mysql2/promise";

export const dependencies = [];

export const main = memoizeModule(async () => {
  console.log("⚡ [Process Lifecycle] Initializing DB Connection Pool (once)...");
  const pool = await createPool(process.env.DATABASE_URL!);
  return {
    query: (sql: string, params: any[]) => pool.execute(sql, params),
  };
});

4. Lightweight Business Services: Pure Request Isolation ​

typescript
// src/modules/services/orderService/index.ts
export const dependencies = ["database"];

export const main = (container: ModularContainer) => {
  // Zero 'any', zero manual 'declare global'—enjoy 100% IDE auto-completion!
  const { database, requestContext } = container;
  const requestId = requestContext.req.header("x-request-id");
  const currentUser = requestContext.get("user");

  return {
    async createOrder(item: string) {
      // 100% request-isolated, zero risk of cross-request pollution
      return database.query("INSERT INTO orders (item, user_id, request_id) VALUES (?, ?, ?)", [
        item,
        currentUser.id,
        requestId,
      ]);
    },
  };
};

5. Web Framework Integration (Hono / Express / Koa) ​

Using Hono as a modern high-performance example, the host entry point maintains zero business routes, handing dispatching authority directly to the container:

typescript
// src/index.ts
import { Hono } from "hono";
import { createModularContainer } from "virtual:modular-container";

const app = new Hono();

// ❌ Anti-Pattern: Registering business routes manually on host app
// (Analogous to writing individual Java Servlets in Spring MVC instead of Controllers)
// Leaks business routing to host and completely bypasses container AOP aspect protection!
// app.post("/api/orders", async (c) => {
//   const container = await createModularContainer({ requestContext: c });
//   const body = await c.req.json();
//   const result = await container.orderService.createOrder(body.item);
//   return c.json({ success: true, data: result });
// });

// ✅ Architectural Best Practice: Wildcard Dispatcher Gateway (Spring MVC DispatcherServlet)
// Host maintains zero business routes; requests delegate to container aggregator with unified AOP governance (~20µs)
app.all("*", async (c) => {
  // Pass current request context as seed object, overriding the skip-marked requestContext module
  const container = await createModularContainer({ requestContext: c });

  // 💡 Note: apiAggregator is a user-defined gateway router Mesh module (src/modules/api-aggregator/index.ts)
  // Encapsulates internal URL contract matching, AOP telemetry, and exception shielding within the container.
  return await container.apiAggregator();
});

export default app;

Performance & Safety Benchmarks ​

  1. Connection Pool Stability: Database and Redis pools are initialized once upon the first request and safely shared across the single thread without connection exhaustion;
  2. Zero Request Bleed: Every request possesses its own orderService and requestContext; authorization tokens and trace headers can never leak across concurrent requests;
  3. Microsecond Startup: Instantiating lightweight business services in Path-IoC requires only tens of microseconds—two orders of magnitude faster than NestJS's Scope.REQUEST.

6. Architectural Summary ​

DimensionTraditional Full-Stack (NestJS Scope.REQUEST)Path-IoC Production Pattern (Per-Request + Memoization)
Request IsolationReflection metadata scanning per requestNative requestContext + lightweight container
Heavy SingletonsComplex @Injectable({ scope: DEFAULT }) annotationsPure higher-order closure (memoizeModule)
Per-Request OverheadMilliseconds of reflection parsing + GC pressureMicrosecond pure function execution
Runtime PortabilityTied to specific Node.js framework internalsUniversal across Node.js, Bun, Cloudflare Workers

Differentiating between client singletons and server request isolation is fundamental to building secure full-stack applications. By delegating request isolation to lightweight per-request containers and heavy resources to closure memoization, modern TypeScript engineers achieve peak safety and microsecond speed without concept bloat.

Released under the MIT License.