JS Design Patterns — Singleton State Leaks in Tests
Tests pass alone but fail together? Singleton mutations persist across Node.js test files.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- Design patterns are reusable blueprints for solving recurring architectural problems
- Creational patterns (Factory, Singleton) control how objects are created
- Behavioral patterns (Observer, Strategy) manage communication between objects
- Modern JS (ES2022+ private fields, modules) makes implementations cleaner
- Biggest mistake: over-engineering with a pattern when a simpler solution exists
- Production insight: wrong pattern choice adds coupling and makes debugging harder
Imagine you're building with LEGO. Instead of figuring out from scratch how to build a car every time, you follow a proven blueprint. Design patterns are those blueprints — battle-tested solutions to problems that developers have already solved a thousand times before. You don't copy the blueprint exactly, you adapt it. The point isn't to memorise shapes — it's to recognise the problem and know which blueprint fits.
Every production JavaScript codebase eventually grows to a point where 'just making it work' stops being enough. Components start talking to each other in ways nobody planned. State changes ripple unpredictably through the app. A bug fix in one place breaks three things elsewhere. That's not bad luck — it's what happens when code lacks structural intent. Design patterns are the antidote. They're a shared vocabulary between developers and a set of proven architectural decisions that prevent entire categories of bugs before they're written.
The problem design patterns solve is coupling — the invisible glue that binds unrelated parts of your code together. When your PaymentService directly instantiates a Logger, and your Logger directly reads from Config, and your Config mutates global state — you've built a house of cards. Change one thing, rebuild everything. Patterns like Singleton, Observer, and Factory give you controlled, intentional relationships between components instead of accidental ones.
By the end of this article you'll be able to recognise which pattern fits a given problem, implement each one correctly in modern JavaScript (including ES2022+ class features), understand the performance and memory implications of each, and spot the subtle bugs that come from applying them carelessly. This isn't a glossary tour — it's a practical field guide you'll actually use.
Why Singleton State Leaks in Tests
A design pattern is a reusable solution to a recurring problem in software design. In JavaScript, design patterns are implemented using the language's prototypal inheritance, closures, and module systems. The Singleton pattern ensures a class has only one instance and provides a global point of access to it. This is achieved by caching the instance in a static property or module scope, returning the same object on every instantiation attempt. The core mechanic is simple: a private constructor or factory function that checks for an existing instance before creating a new one. In practice, Singletons in JavaScript often rely on module caching (e.g., ES6 modules are singletons by default) or explicit instance management. Key properties: global state, lazy initialization, and a single point of control. This makes them useful for managing shared resources like configuration objects, database connections, or logging services. However, the same global state that makes Singletons convenient also makes them dangerous in test environments. When tests run in parallel or sequentially, a Singleton's cached state can persist across test cases, causing false positives or failures. Use Singletons sparingly, only for truly application-wide resources where state mutation is minimal or nonexistent. In real systems, they are often replaced with dependency injection or context objects to improve testability and isolate side effects.
The Creational Powerhouse: The Factory Pattern
The Factory pattern provides an interface for creating objects, but allows subclasses or a central logic unit to alter the type of objects that will be created. In JavaScript, this is exceptionally useful when your application needs to generate different types of components (like different UI buttons or database connectors) based on environment variables or user input without hardcoding new throughout your logic.ClassName()
Simple enough. But the real win isn't just centralised creation — it's that your callers never need to know the concrete class. They work against an interface or a common contract. Swap the implementation later without touching callers.
Where people trip up: using a Factory for a single implementation. That's just indirection without benefit. And factories that return different shapes under the same name cause runtime errors that type checkers won't catch.
- Client orders by type (e.g., 'SQL'), not by class name
- Kitchen (factory) decides which chef (class) to dispatch
- Menu (interface) guarantees a product with expected methods
- Adding a new dish (class) doesn't change the ordering process
- Overusing the kitchen for a single dish adds needless complexity
require caches the module. If your factory returns a new instance each time, you avoid singleton side effects. If you accidentally return a cached instance, you share state..toBeInstanceOf or a duck-type check. Never trust the returned shape.new directly or a simple function.The Singleton Pattern: Ensuring a Single Source of Truth
A Singleton restricts a class to a single instance and provides a global point of access to it. While often criticized if overused as 'global state,' it is vital for resource-heavy objects like Database Pools or Global Configuration managers where multiple instances would cause memory leaks or race conditions.
JavaScript's module system makes singletons deceptively easy — a module-level variable survives as long as the process. But that's also the trap: in a serverless environment, each cold start gets a new instance, but warm starts reuse the same. That inconsistency breaks assumptions.
Modern ES2022+ private static fields (#instance) give you real encapsulation. The old pattern of storing the instance on the constructor function itself is still common but less clean.
The Behavioral King: The Observer Pattern
The Observer pattern (the core of 'Pub/Sub' logic) allows an object (the Subject) to notify a list of 'Observers' about state changes. This is how event listeners work in the browser and how frameworks like React and Vue trigger UI updates when state changes.
The simplicity of Observer is also its biggest risk: observers hold references to subscribers, and if those subscribers aren't properly cleaned up, you get memory leaks and ghost callbacks. In a single-page application, forgetting to unsubscribe on component unmount is the number one cause of stale state and unexpected behaviour.
Modern JavaScript provides WeakRef and FinalizationRegistry to help, but most common implementations just store arrays of callbacks — manual cleanup is your responsibility.
subscribe(). Use a Set instead of Array to prevent duplicate subscriptions. Subscribe in component mount, unsubscribe in unmount.The Module Pattern: Encapsulation Before ES Modules
The Module pattern was the go-to way to create private scope before ES2015 modules. It uses an IIFE (Immediately Invoked Function Expression) to return a public API while keeping internal variables hidden. Even now, understanding it helps you read older codebases and makes you appreciate what import/export gives you.
The key insight: closures provide true private state. No prefix conventions like _private needed — the variable simply isn't accessible from outside. But the downside: each module creates its own closure, consuming memory for the lifetime of the module. And if you need to share a module across files, you need a script tag loader or a bundler.
Today, most teams use ES modules instead. But the Module pattern is still useful for inline scripts or when you can't use a bundler.
The Strategy Pattern: Swap Algorithms at Runtime
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The calling code uses a common interface, so you can swap strategies without touching the caller. This is invaluable for things like payment gateways, sorting algorithms, or validation rules that differ by user type or region.
In JavaScript, strategies are often just functions or objects with a common method signature. The pattern becomes especially powerful when combined with dependency injection or configuration files.
Common trap: creating a new strategy for every minor variation. If the variation is just a parameter, a simple function with a parameter is cleaner. Strategy is for when the entire behaviour changes, not just a value.
- Context delegates to the current strategy
- Strategies implement a common interface (same method signature)
- You can swap strategies at runtime or at construction
- Adding a new strategy doesn't change existing code (Open/Closed Principle)
- Don't use Strategy if you only need a simple parameter switch
pay() method, not through the constructor.Classic Design Patterns Quick Reference Table
The following table categorizes the classic Gang of Four design patterns into Creational, Structural, and Behavioral types, with brief descriptions and typical use cases in JavaScript.
| Pattern | Category | Description | Use Case |
|---|---|---|---|
| Singleton | Creational | Ensures a class has only one instance | Global config, logging |
| Factory Method | Creational | Defines an interface for creating an object, but lets subclasses decide which class to instantiate | Creating UI components based on type |
| Abstract Factory | Creational | Provides an interface for creating families of related or dependent objects | Cross-platform UI toolkit |
| Builder | Creational | Separates construction of a complex object from its representation | Building complex objects like HTTP requests |
| Prototype | Creational | Creates new objects by copying an existing object (clone) | Object replication when construction is expensive |
| Adapter | Structural | Allows incompatible interfaces to work together | Wrapping legacy API for modern interface |
| Decorator | Structural | Attaches additional responsibilities to an object dynamically | Adding logging, validation, caching to functions |
| Facade | Structural | Provides a simplified interface to a complex subsystem | API gateway pattern, DB abstraction |
| Proxy | Structural | Provides a surrogate or placeholder for another object to control access | Lazy loading, caching, access control |
| Observer | Behavioral | Defines a one-to-many dependency between objects | Event systems, real-time updates |
| Strategy | Behavioral | Defines a family of algorithms, encapsulates each one, and makes them interchangeable | Payment methods, sorting algorithms |
| Command | Behavioral | Encapsulates a request as an object | Undo/redo, task queues |
| Iterator | Behavioral | Provides a way to access elements of an aggregate object sequentially | Custom collection traversal |
| Mediator | Behavioral | Defines an object that encapsulates how a set of objects interact | Chat room, event bus |
| State | Behavioral | Allows an object to alter its behavior when its internal state changes | State machines (login states, order states) |
| Template Method | Behavioral | Defines the skeleton of an algorithm in a method, deferring some steps to subclasses | Code generation, game AI |
| Visitor | Behavioral | Represents an operation to be performed on elements of an object structure | AST traversal, export operations |
This table provides a high-level overview. In modern JavaScript development, some patterns are less common due to native language features (e.g., Iterator pattern is built-in with generators). Others like Observer and Strategy remain essential.
Modern JS Alternatives: Hooks, Context & ES Modules
Classic design patterns were conceived in an era of class-heavy OOP languages. JavaScript has evolved significantly since the GoF book. Modern language features can simplify or outright replace several patterns:
- Singleton → Module-level state +
importsingleton: Instead of a class with instance control, just export an object literal or use a module-scoped variable. Node.js modules are singletons by default. - Observer → React Hooks (
useEffect+ custom events): The Observer pattern is built into React's state system. Instead of a custom event bus, useuseEffectfor subscriptions anduseCallbackto memoize callbacks. For cross-component communication, the Context API +useReducercan replace a global event bus. - Strategy → Higher-order functions: Instead of creating separate Strategy classes, pass functions directly. React's
useEffectdependency array and function composition achieve the same goal. - Factory → Component factories with JSX: In React, you can create factory functions that return JSX elements. Dynamic rendering is straightforward.
- Command → Async queues with Promises: The Command pattern for undo/redo can be implemented with a simple array of state snapshots or using libraries like Immer for immutable state.
However, understanding the original patterns is crucial even when using modern alternatives. The vocabulary helps you communicate architectural decisions, and the patterns' pitfalls (like memory leaks in Observer) still apply to modern implementations.
The Decorator Pattern: Wrapping Behavior Without Inheritance
You need to add logging to an API client, but you can't touch the base class because it's in a third-party library. Inheritance breaks. The Decorator pattern lets you wrap an object with additional behavior at runtime. You pass the original object into a wrapper that implements the same interface, then delegate calls with extra logic. In ES2024, this pattern maps directly to higher-order functions and Proxy objects. Don't build a class hierarchy for every cross-cutting concern. Decorate instead. Your test suite stays clean because you can mock the original object and test the decorator in isolation. The WHY: inheritance couples you to a fixed tree of behavior at compile time. Decorators compose behavior at runtime, one wrapper at a time.
The Prototype Pattern: Cloning State Instead of Rebuilding
You've got a complex configuration object with 15 nested fields. Every new request needs a slightly tweaked copy. Don't call a constructor with 20 parameters. The Prototype pattern says: clone an existing object, then mutate only what you need. JavaScript already implements this with Object.create() and the spread operator. For deep clones, structuredClone() (ES2024) handles circular references and typed arrays without third-party libs. The WHY: object construction is expensive when you have deep defaults or runtime-derived state. Cloning preserves that state without re-initializing. Use this when your object creation involves I/O, heavy computation, or complex defaults. But watch out — shallow clones create aliasing bugs that burn production.
The Singleton That Held State Across Tests
static reset() { this.#instance = null; } and called it in beforeEach hooks. Used a WeakMap to detect test environment and force reset.- Singletons in Node.js are per-process, not per-request. Always provide a way to clear state in test environments.
- Never assume a fresh import gives a fresh instance — Node module cache ensures the singleton lives as long as the process.
- Prefer dependency injection over direct singleton access in code that needs to be testable.
Object.freeze() to prevent accidental mutations.Add `static reset() { this.#instance = null; }`Use `AsyncLocalStorage` to isolate request-scoped state.Key takeaways
Common mistakes to avoid
5 patternsOver-Engineering with Factory
LoggerFactory.getLogger() returning the same Logger every time.Singleton Global State Abuse
beforeEach or setup. Better: inject the singleton via constructor so tests can provide a mock.Memory Leaks in Observer Subscriptions
useEffect return, Angular: ngOnDestroy). Use WeakRef or a subscription manager with automatic cleanup.Ignoring Native ES Modules
import/export). They provide proper encapsulation, tree-shaking, and better tooling support.Strategy Pattern for Simple Logic
Interview Questions on This Topic
Explain the 'Open/Closed Principle' and which design pattern helps implement it best.
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Advanced JS. Mark it forged?
9 min read · try the examples if you haven't