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:
// 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 accessglobalThis.modularContainer. If one writesglobalThis.modularContainer = await createModularContainer();, the global reference remainsundefinedthroughout the entire asynchronous bootstrap sequence. Pre-bindingconst container = (globalThis.modularContainer = {})and passing it tocreateModularContainer(container)ensures that the container object reference is synchronously accessible from tick zero while the engine fills module instances.
Inside components or views:
// 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
ThreadLocalstorage 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.
- Per-Request Container Instantiation: Each HTTP request receives its own lightweight container seeded with a request-scoped
requestContext(e.g., Hono'sContextor Express'sRequest); - 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:
// 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:
// 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
// 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
// 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:
// 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
- Connection Pool Stability: Database and Redis pools are initialized once upon the first request and safely shared across the single thread without connection exhaustion;
- Zero Request Bleed: Every request possesses its own
orderServiceandrequestContext; authorization tokens and trace headers can never leak across concurrent requests; - 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
| Dimension | Traditional Full-Stack (NestJS Scope.REQUEST) | Path-IoC Production Pattern (Per-Request + Memoization) |
|---|---|---|
| Request Isolation | Reflection metadata scanning per request | Native requestContext + lightweight container |
| Heavy Singletons | Complex @Injectable({ scope: DEFAULT }) annotations | Pure higher-order closure (memoizeModule) |
| Per-Request Overhead | Milliseconds of reflection parsing + GC pressure | Microsecond pure function execution |
| Runtime Portability | Tied to specific Node.js framework internals | Universal 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.