Skip to content

Singleton Pattern and Module Scope

advanced18 min read

The Most Controversial Pattern

Ask five developers about the Singleton pattern and you'll get five different opinions. "It's an anti-pattern." "It's essential for services." "Just use a module." "Never use it in React."

Here's the reality: Singleton isn't inherently good or bad. It's a tool. The problem is that most developers reach for it when they actually want shared state, and shared state is what causes the real damage. Understanding when a Singleton is the right choice — and when it's a footgun — separates senior engineers from everyone else.

// This is already a singleton. You use this pattern every day.
// dbClient.ts
const client = new DatabaseClient({ url: process.env.DATABASE_URL });
export { client };
Mental Model

Think of a Singleton like a company printer. There's one printer on the floor. Everyone sends jobs to the same printer. You don't buy a new printer for each person (wasteful), and you definitely don't want two printers with the same name fighting over print jobs (chaos). One instance, shared access, coordinated usage. But if the printer jams, everyone is stuck — that's the risk of a single point of dependency.

ES Modules Are Already Singletons

Here's what many developers miss: ES modules are evaluated once and cached. Every import gets the same instance. You get Singleton for free:

// logger.ts
class Logger {
  private logs: string[] = [];

  log(message: string): void {
    this.logs.push(`[${new Date().toISOString()}] ${message}`);
    console.log(message);
  }

  getLogs(): string[] {
    return [...this.logs];
  }
}

export const logger = new Logger();
// fileA.ts
import { logger } from "./logger";
logger.log("From A");

// fileB.ts
import { logger } from "./logger";
logger.log("From B");

// Both use the SAME Logger instance
// logger.getLogs() → ["From A", "From B"]

The module system guarantees that logger.ts is evaluated exactly once. Every import returns the same logger object. This is the most common and safest Singleton pattern in JavaScript.

Quiz
Why are ES module exports considered singletons?

The Classic Singleton (When You Need It)

Sometimes module scope isn't enough. You need lazy initialization (don't create until first use) or you need to prevent accidental re-instantiation:

class WebSocketManager {
  private static instance: WebSocketManager | null = null;
  private socket: WebSocket | null = null;
  private listeners = new Map<string, Set<(data: unknown) => void>>();

  private constructor() {}

  static getInstance(): WebSocketManager {
    if (!WebSocketManager.instance) {
      WebSocketManager.instance = new WebSocketManager();
    }
    return WebSocketManager.instance;
  }

  connect(url: string): void {
    if (this.socket?.readyState === WebSocket.OPEN) return;

    this.socket = new WebSocket(url);
    this.socket.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.listeners.get(data.type)?.forEach(fn => fn(data.payload));
    };
  }

  on(event: string, callback: (data: unknown) => void): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    const set = this.listeners.get(event)!;
    set.add(callback);
    return () => set.delete(callback);
  }

  send(type: string, payload: unknown): void {
    this.socket?.send(JSON.stringify({ type, payload }));
  }

  static resetForTesting(): void {
    WebSocketManager.instance?.socket?.close();
    WebSocketManager.instance = null;
  }
}

The private constructor prevents new WebSocketManager(). The static getInstance ensures one connection across your entire app. But notice resetForTesting — we'll get to why that's critical.

Quiz
When should you use the classic Singleton class over a simple module-scoped export?

When Singleton Is the Right Choice

Singletons are appropriate when:

  1. Shared connections — WebSocket, database connections, API clients. Multiple connections waste resources and cause race conditions.
  2. Hardware interfaces — Audio context, canvas rendering context, file system handles. The resource is physically singular.
  3. Configuration — App-wide config loaded once and shared everywhere.
  4. Caching layers — A shared cache that accumulates data across the app.
// Good singleton: Audio context (browsers limit you to one anyway)
let audioContext: AudioContext | null = null;

function getAudioContext(): AudioContext {
  if (!audioContext) {
    audioContext = new AudioContext();
  }
  return audioContext;
}

// Good singleton: In-memory cache
const cache = new Map<string, { data: unknown; expires: number }>();

export function getCached<T>(key: string): T | null {
  const entry = cache.get(key);
  if (!entry || entry.expires < Date.now()) {
    cache.delete(key);
    return null;
  }
  return entry.data as T;
}

export function setCache(key: string, data: unknown, ttlMs: number): void {
  cache.set(key, { data, expires: Date.now() + ttlMs });
}

When Singleton Is a Trap

Singletons become anti-patterns when they're used as a substitute for proper dependency injection:

// BAD: Component reaches for a global singleton
function UserProfile() {
  const api = ApiClient.getInstance(); // Hidden dependency
  const user = use(api.get("/me"));
  return <h1>{user.name}</h1>;
}

// GOOD: Dependency is explicit
function UserProfile({ api }: { api: ApiClient }) {
  const user = use(api.get("/me"));
  return <h1>{user.name}</h1>;
}

The first version has a hidden dependency. You can't tell from the component's interface that it needs an API client. You can't swap in a test mock. You can't render two UserProfile components with different API clients.

The second version is explicit. Dependencies are in the type signature. Testing is trivial — pass a mock. Reuse is easy — pass any compatible client.

Quiz
Why is using Singleton.getInstance() inside React components considered an anti-pattern?

The Service Container Alternative

If you need singleton behavior but want testability, use a service container (also called a dependency injection container):

type ServiceFactory<T> = () => T;

class ServiceContainer {
  private instances = new Map<string, unknown>();
  private factories = new Map<string, ServiceFactory<unknown>>();

  register<T>(name: string, factory: ServiceFactory<T>): void {
    this.factories.set(name, factory);
  }

  get<T>(name: string): T {
    if (!this.instances.has(name)) {
      const factory = this.factories.get(name);
      if (!factory) throw new Error(`Service "${name}" not registered`);
      this.instances.set(name, factory());
    }
    return this.instances.get(name) as T;
  }

  reset(): void {
    this.instances.clear();
  }
}

const container = new ServiceContainer();

container.register("logger", () => new Logger());
container.register("api", () => createApiClient({ baseUrl: "/api" }));

// In application code
const logger = container.get<Logger>("logger");

// In tests — swap implementations
const testContainer = new ServiceContainer();
testContainer.register("logger", () => new MockLogger());
testContainer.register("api", () => createMockApiClient());

This gives you singleton lifecycle (created once, cached) with testability (swap the container or reset it between tests).

Singleton in React with Context

React's Context API is the idiomatic way to provide singleton-like services:

import { createContext, useContext, useRef } from "react";

interface AnalyticsService {
  track(event: string, properties?: Record<string, unknown>): void;
}

const AnalyticsContext = createContext<AnalyticsService | null>(null);

function useAnalytics(): AnalyticsService {
  const ctx = useContext(AnalyticsContext);
  if (!ctx) throw new Error("useAnalytics must be used within AnalyticsProvider");
  return ctx;
}

function AnalyticsProvider({ children, service }: {
  children: React.ReactNode;
  service: AnalyticsService;
}) {
  const serviceRef = useRef(service);
  return (
    <AnalyticsContext value={serviceRef.current}>
      {children}
    </AnalyticsContext>
  );
}

The service is created once outside React and provided via context. It behaves like a Singleton within the component tree, but it's testable (provide a mock service), inspectable (visible in React DevTools), and scoped (different subtrees can get different services).

What developers doWhat they should do
Using Singleton.getInstance() directly inside components and functions
Hidden singletons make code untestable, create invisible coupling, and prevent reuse with different configurations. Explicit dependencies let you swap implementations for testing, staging, and production
Accept dependencies as parameters, props, or via context — make them explicit
Using Singleton to share mutable state across modules
Mutable singleton state has no change notification mechanism. Components reading it will not re-render when it changes. State management tools solve exactly this problem with subscriptions and reactivity
Use a proper state management solution (Zustand, Redux, or React Context)
Forgetting to add a reset mechanism for testing
Tests run in the same process. Without reset, state bleeds between tests, causing flaky failures. Module-scoped singletons are especially tricky because the module cache persists across tests unless you explicitly reset state
Always provide a static reset or destroy method on Singleton classes

Challenge

Build a feature flag service as a module-scoped singleton with lazy initialization, type-safe flag access, and a testing reset mechanism.

Challenge:

Try to solve it before peeking at the answer.

// Requirements:
// 1. Module-scoped singleton (not class-based)
// 2. Typed flag names via an interface
// 3. Lazy fetch: flags load from an API on first access
// 4. isEnabled(flagName) returns boolean
// 5. reset() clears cache for testing

interface FeatureFlags {
  darkMode: boolean;
  newCheckout: boolean;
  betaEditor: boolean;
}

// Usage:
await featureFlags.load("/api/flags");
featureFlags.isEnabled("darkMode"); // true
featureFlags.isEnabled("unknownFlag"); // TypeScript error
featureFlags.reset(); // For tests
Key Rules
  1. 1ES modules are already singletons — module code evaluates once and is cached across all imports
  2. 2Use classic Singleton (private constructor + getInstance) only when you need lazy initialization or lifecycle control beyond what module scope provides
  3. 3Never use Singleton.getInstance() inside React components — use props or context to make dependencies explicit and testable
  4. 4Always provide a reset mechanism for testing — without it, singleton state bleeds between tests and causes flaky failures
  5. 5Singleton is appropriate for shared resources (connections, caches, hardware interfaces) — not for shared application state