Mid-level 4 min · March 06, 2026

JS Design Patterns — Singleton State Leaks in Tests

Tests pass alone but fail together? Singleton mutations persist across Node.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
Plain-English First

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.

io/thecodeforge/patterns/ForgeFactory.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
 * io.thecodeforge - Factory Pattern Implementation
 */
class DatabaseConnector {
    constructor(config) {
        this.config = config;
    }
    connect() { throw new Error("Method 'connect()' must be implemented."); }
}

class PostgreSqlConnector extends DatabaseConnector {
    connect() { return `Connected to Postgres at ${this.config.host}`; }
}

class MongoDbConnector extends DatabaseConnector {
    connect() { return `Connected to MongoDB at ${this.config.uri}`; }
}

class DBFactory {
    static createConnection(type, options) {
        switch(type) {
            case 'SQL': return new PostgreSqlConnector(options);
            case 'NoSQL': return new MongoDbConnector(options);
            default: throw new Error("Unsupported DB type");
        }
    }
}

const db = DBFactory.createConnection('SQL', { host: 'localhost' });
console.log(db.connect());
Output
Connected to Postgres at localhost
Factory as Restaurant Kitchen
  • 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
Production Insight
Factory pattern in Node.js: each 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.
Common production failure: a factory that returns a promise but is treated synchronously — you get a Promise object instead of the actual connector.
Rule: always test factory output with .toBeInstanceOf or a duck-type check. Never trust the returned shape.
Key Takeaway
Factory decouples creation from usage.
Add new implementations without changing callers.
Don't abstract a single concrete class — that's just ceremony.
Should You Use a Factory?
IfOnly one implementation of the product exists
UseSkip the factory. Use new directly or a simple function.
IfYou need to switch implementations at runtime
UseFactory is the right choice. It centralises the switching logic.
IfProduct creation requires complex configuration or async operations
UseFactory method is fine. Consider Abstract Factory if you have families of related products.

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.

io/thecodeforge/patterns/ForgeSingleton.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * io.thecodeforge - Modern ES6 Singleton using Static Private Fields
 */
class GlobalConfig {
    static #instance = null;

    constructor() {
        if (GlobalConfig.#instance) {
            return GlobalConfig.#instance;
        }
        this.settings = { theme: 'dark', version: '2.1.0' };
        GlobalConfig.#instance = this;
    }

    static getInstance() {
        if (!this.#instance) {
            this.#instance = new GlobalConfig();
        }
        return this.#instance;
    }
}

const configA = new GlobalConfig();
const configB = GlobalConfig.getInstance();
console.log(configA === configB);
Output
true
Singleton Testing Trap
Singletons make unit testing a nightmare because they persist state between tests. Always provide a static reset method. Better yet, use a factory that returns the singleton lazily, and inject it where needed.
Production Insight
In Node.js, singletons are process-scoped. If your app runs in a cluster or serverless environment, each worker has its own singleton. That means shared state is not truly shared across workers — you'll need Redis or similar.
Common production failure: a singleton that holds a database connection pool. If the pool is exhausted, the entire process becomes unresponsive because every request shares the same limited resource.
Rule: for singletons that manage external resources, add a health check and auto-recovery mechanism. Never let a broken singleton kill your whole process.
Key Takeaway
Singleton = one instance per process.
Provide reset for tests.
Don't use it for mutable global state across requests.
When to Use a Singleton
IfYou need exactly one instance of a resource (DB pool, config, logger)
UseUse a singleton with a reset method for testing.
IfThe object doesn't hold state or is stateless
UseDon't use a singleton. A module with exported functions works fine.
IfYou're in a serverless environment with concurrent invocations
UseAvoid singletons with mutable state. Use external storage for shared state.

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.

io/thecodeforge/patterns/ForgeObserver.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
 * io.thecodeforge - Simple Event Bus (Observer Pattern)
 */
class EventBus {
    constructor() {
        this.subscribers = {};
    }

    subscribe(event, callback) {
        if (!this.subscribers[event]) this.subscribers[event] = [];
        this.subscribers[event].push(callback);
    }

    publish(event, data) {
        if (!this.subscribers[event]) return;
        this.subscribers[event].forEach(cb => cb(data));
    }

    unsubscribe(event, callback) {
        if (!this.subscribers[event]) return;
        this.subscribers[event] = this.subscribers[event].filter(cb => cb !== callback);
    }
}

const forgeBus = new EventBus();
forgeBus.subscribe('USER_LOGIN', (user) => console.log(`Welcome, ${user}!`));
forgeBus.publish('USER_LOGIN', 'ForgeAdmin');
Output
Welcome, ForgeAdmin!
Performance Note
Publishing with many subscribers (100+) can become a bottleneck. If your event bus triggers heavy computation, consider scheduling notifications as microtasks or using a message queue for async processing.
Production Insight
The biggest production issue with Observer: unbounded callback arrays. If your app publishes events faster than subscribers process them, the subscriber list grows. Worse, if a component crashes before unsubscribing, its callback remains and may operate on stale DOM elements.
Another failure: circular event loops — A updates B, B updates A, causing infinite recursion. Always guard against reentrant calls.
Rule: always return an unsubscribe function from subscribe(). Use a Set instead of Array to prevent duplicate subscriptions. Subscribe in component mount, unsubscribe in unmount.
Key Takeaway
Observer = notify many without them knowing each other.
Always unsubscribe to avoid memory leaks.
Guard against circular updates.
Observer vs Pub/Sub vs Direct Call
IfOne sender, many receivers that shouldn't know each other
UseUse Observer pattern (Subject maintains list of Observers directly).
IfLoose coupling between publisher and subscriber via a broker
UseUse Pub/Sub (Event Bus) — publisher and subscriber never directly interact.
IfOnly one receiver needed, or callers know each other
UseDon't use Observer — just call the function directly. Simpler and faster.

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.

io/thecodeforge/patterns/ForgeModule.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
 * io.thecodeforge - Module Pattern (IIFE)
 */
const ForgeCounter = (function () {
    // Private
    let count = 0;

    function increment() {
        count++;
    }

    function decrement() {
        count--;
    }

    // Public API
    return {
        add: function (amount) {
            count += amount;
        },
        getCount: function () {
            return count;
        },
        reset: function () {
            count = 0;
        }
    };
})();

ForgeCounter.add(5);
console.log(ForgeCounter.getCount()); // 5
console.log(ForgeCounter.count); // undefined (private)
Output
5
Modern Alternative
In 2026, reach for ES modules first. But the Module pattern lives on in bundler configurations, inline scripts, and legacy codebases. Knowing it helps you understand closures and IIFEs at a deeper level.
Production Insight
The Module pattern's closure keeps variables alive as long as the module reference exists. If you accidentally create many modules in a loop (e.g., in a factory), you may retain large objects indefinably. Debugging is harder because you can't inspect private variables from DevTools.
Performance tip: if the module doesn't need internal state, export plain functions from a file — that's just a single scope, not a closure.
Rule: prefer ES modules for new code. Only use the Module pattern when you need true privacy without a build step.
Key Takeaway
Module pattern uses closure for privacy.
ES modules are the modern replacement.
Private state in a closure is memory that lives as long as the module does.

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.

io/thecodeforge/patterns/ForgeStrategy.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * io.thecodeforge - Strategy Pattern for Payment
 */
class PaymentContext {
    constructor(strategy) {
        this.strategy = strategy;
    }

    setStrategy(strategy) {
        this.strategy = strategy;
    }

    executePayment(amount) {
        return this.strategy.pay(amount);
    }
}

const stripeStrategy = {
    pay(amount) { return `Paid $${amount} via Stripe`; }
};

const paypalStrategy = {
    pay(amount) { return `Paid $${amount} via PayPal`; }
};

const payment = new PaymentContext(stripeStrategy);
console.log(payment.executePayment(100));
payment.setStrategy(paypalStrategy);
console.log(payment.executePayment(50));
Output
Paid $100 via Stripe
Paid $50 via PayPal
Strategy as Interchangeable Parts
  • 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
Production Insight
Production risk: switching strategies mid-transaction can lead to inconsistency. For example, you start a payment with Stripe, then switch to PayPal before completing — don't do that. Strategy swap should happen between business transactions, not within them.
Another issue: strategies that have different setup requirements (e.g., Stripe needs an API key, PayPal needs a token). The context shouldn't know about those details — but it often leaks through configuration.
Rule: keep strategies stateless when possible. If a strategy needs state, pass it via the pay() method, not through the constructor.
Key Takeaway
Strategy = interchangeable algorithms.
Keep strategies stateless.
Don't overuse — a switch statement is sometimes enough.
Strategy vs Simple Function Parameter
IfThe algorithm varies by an enum or a few flags
UseUse a simple switch/object lookup inside one function — no need for Strategy pattern.
IfEach algorithm has completely different logic and setup
UseUse Strategy pattern. Encapsulate each algorithm in its own class/module.
IfYou need to add new algorithms without modifying existing code
UseStrategy is the right choice. It's the O in SOLID (Open/Closed).
● Production incidentPOST-MORTEMseverity: high

The Singleton That Held State Across Tests

Symptom
Tests passed in isolation but failed when run together. A test expecting 'dark' theme got 'light' because a previous test had mutated the singleton's settings.
Assumption
The team assumed a new test runner instance meant a clean state for each test file.
Root cause
The singleton was loaded once per Node.js process. The constructor returned the same instance repeatedly, preserving mutations across test files.
Fix
Added a static reset method: static reset() { this.#instance = null; } and called it in beforeEach hooks. Used a WeakMap to detect test environment and force reset.
Key lesson
  • 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.
Production debug guideSymptom → Action guide for pattern-related failures4 entries
Symptom · 01
A global config object changes unexpectedly between requests
Fix
Check if the config is a singleton and if any code mutates it directly. Add Object.freeze() to prevent accidental mutations.
Symptom · 02
UI components react to state changes they shouldn't
Fix
Verify observer/subscriber lists — look for duplicate subscriptions or missing unsubscribes. Use WeakRef or FinalizationRegistry for auto-cleanup.
Symptom · 03
Factory returns wrong type of object after a code change
Fix
Check the mapping logic in the factory. Add type-checking with instanceof or branded types. Write unit tests for every known type.
Symptom · 04
Memory grows continuously in a long-running server
Fix
Profile with Chrome DevTools heap snapshot. Look for retained event listeners or observer subscribers that were never removed.
★ Pattern Debugging Quick ReferenceCommon pattern misconfigurations and immediate fixes
Singleton state persists between requests in Node.js
Immediate action
Add a reset method and call it in request middleware (be careful with async contexts).
Commands
Add `static reset() { this.#instance = null; }`
Use `AsyncLocalStorage` to isolate request-scoped state.
Fix now
Wrap the singleton access in a factory that checks AsyncLocalStorage first.
Observer listener fires after component is destroyed+
Immediate action
Search for missing `unsubscribe` calls in component cleanup (e.g., `useEffect` return).
Commands
Add a WeakMap to track subscriptions per component instance.
Use a subscription manager that removes listeners when the component triggers finalisation.
Fix now
Implement a useEventBus hook that returns an unsubscribe function in the cleanup.
Factory creates an object that doesn't have a required method+
Immediate action
Enforce interface contracts using TypeScript or runtime checks.
Commands
Add a switch/case unit test that validates every branch returns the correct shape.
Use a proxy to validate returned objects at test time.
Fix now
Export a type guard function for each product type.
Design Pattern Comparison
PatternCategoryPrimary Use Case
SingletonCreationalShared state/resource (e.g., Database Pool, Config)
FactoryCreationalCreating objects without exposing creation logic
ObserverBehavioralEvent-driven updates (e.g., UI updates, WebSockets)
ModuleStructuralEncapsulation and private/public scope management
StrategyBehavioralSwapping algorithms at runtime (e.g., Payment gateways)

Key takeaways

1
Design patterns are templates for solving architectural problems, not rigid rules.
2
Creational patterns (Factory, Singleton) focus on efficient and controlled object instantiation.
3
Behavioral patterns (Observer, Strategy) manage communication between disparate objects.
4
Modern JS syntax (Private fields #, static methods) makes implementing classic patterns much cleaner and more secure.
5
Always favor readability over pattern purity—patterns should simplify your code, not complicate it.

Common mistakes to avoid

5 patterns
×

Over-Engineering with Factory

Symptom
A Factory method is created for a class that only has one implementation. Codebase has LoggerFactory.getLogger() returning the same Logger every time.
Fix
Remove the factory and export the class directly. Only introduce a factory when you actually need to vary the product type.
×

Singleton Global State Abuse

Symptom
Unit tests fail randomly because the singleton's state persists between tests. A test that expects 'dark' mode gets 'light' because a previous test changed the setting.
Fix
Add a static reset method and call it in beforeEach or setup. Better: inject the singleton via constructor so tests can provide a mock.
×

Memory Leaks in Observer Subscriptions

Symptom
Memory grows continuously in a long-running SPA. Heap snapshots show thousands of retained event listeners from unmounted components.
Fix
Always unsubscribe in the cleanup phase of component lifecycle (React: useEffect return, Angular: ngOnDestroy). Use WeakRef or a subscription manager with automatic cleanup.
×

Ignoring Native ES Modules

Symptom
Developers manually implement the Module pattern with IIFEs in a modern project where import/export is available. Increases bundle size and complexity.
Fix
Switch to ES modules (import/export). They provide proper encapsulation, tree-shaking, and better tooling support.
×

Strategy Pattern for Simple Logic

Symptom
A payment system has a Strategy class for each payment method, but the only difference is a URL and API key. Adds unnecessary class hierarchy.
Fix
Use a config object or a simple switch/if-else inside a single function. Only promote to Strategy when the algorithms differ significantly in logic.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the 'Open/Closed Principle' and which design pattern helps imple...
Q02SENIOR
How would you implement a Thread-Safe Singleton in a Node.js environment...
Q03SENIOR
Design a 'Undo/Redo' system using the Command Pattern in JavaScript.
Q04SENIOR
What are the pros and cons of using the Factory Pattern versus a simple ...
Q05SENIOR
How do you prevent memory leaks when implementing the Observer pattern i...
Q06SENIOR
Explain the Strategy Pattern and provide a code example for handling mul...
Q01 of 06SENIOR

Explain the 'Open/Closed Principle' and which design pattern helps implement it best.

ANSWER
The Open/Closed Principle states that classes should be open for extension but closed for modification. The Strategy pattern is a classic example: you can add new strategies (e.g., new payment gateway) without modifying existing code — just create a new class that implements the common interface. The Factory pattern also supports it by letting you add new product types without changing the caller.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Are design patterns still relevant in modern React/Vue development?
02
Is the Singleton pattern an Anti-Pattern?
03
What is the difference between the Observer and Pub-Sub patterns?
04
How do I implement a Private Constructor in JavaScript for a Singleton?
05
What pattern should I use for complex form validation with different rules per field?
06
How do design patterns affect performance in JavaScript?
🔥

That's Advanced JS. Mark it forged?

4 min read · try the examples if you haven't

Previous
Memoisation in JavaScript
17 / 27 · Advanced JS
Next
Functional Programming in JS