JS Design Patterns — Singleton State Leaks in Tests
Tests pass alone but fail together? Singleton mutations persist across Node.
- 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.
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 ClassName() throughout your logic.
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.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.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
That's Advanced JS. Mark it forged?
4 min read · try the examples if you haven't