The Singleton Pattern in JavaScript
Introduction
The singleton pattern is one of the foundational design patterns, present in nearly every major programming language. In JavaScript, the module system already enforces single-instance behavior for exports, but there are situations where you want explicit singleton control — lazy initialization, guarded construction, or mutable shared state that needs different treatment than a frozen module export.
The Singleton pattern restricts the instantiation of a class to a single instance and provides global access to that instance. While JavaScript’s module system naturally behaves like a singleton, understanding this pattern helps you manage shared state and control object creation in scenarios where you explicitly need one instance.
What is the singleton pattern?
The Singleton pattern ensures that a class has only one instance while providing a global access point to that instance. This is useful when you need exactly one object to coordinate actions across your application, such as a configuration manager, database connection, or logger.
In JavaScript, ES6 modules are singletons by default—they execute only once and their exports are cached. However, there are times when you need singleton behavior within a module or using class syntax.
Implementing singletons in JavaScript
The module approach
The simplest way to create a singleton in modern JavaScript is using ES6 modules. The module itself is the singleton:
// config.js
const config = {
apiUrl: 'https://api.example.com',
maxRetries: 3,
timeout: 5000
};
Object.freeze(config);
export default config;
Every time you import this module, you get the same frozen object. The Object.freeze() call prevents accidental modifications, but silent failure in non-strict mode can hide bugs. Use 'use strict' or ES modules (which are strict by default) so the JavaScript engine throws a TypeError when code tries to modify a frozen object:
import config from './config.js';
console.log(config.apiUrl); // https://api.example.com
config.apiUrl = 'http://localhost'; // TypeError in strict mode
console.log(config.apiUrl); // https://api.example.com
The module approach works for simple cases but gives you no control over instantiation timing. Any import triggers the module to execute immediately. When you need lazy initialization or want to guard against multiple new calls, a class-based singleton gives you more control:
The class approach
For more control over instantiation, you can use a class with a static instance and getter:
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
this.connection = this.connect();
Database.instance = this;
}
connect() {
console.log('Connecting to database...');
return { connected: true };
}
query(sql) {
return `Executing: ${sql}`;
}
static getInstance() {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
}
const db1 = new Database();
const db2 = new Database();
const db3 = Database.getInstance();
console.log(db1 === db2); // true
console.log(db1 === db3); // true
The class approach works, but Database.instance is a public static property that any code can read or overwrite. Nothing stops another module from doing Database.instance = null and breaking the singleton guarantee. ES2022 private static fields solve this by hiding the instance behind the # syntax, making accidental or deliberate tampering much harder:
Modern approach: private static fields
ES2022 introduced private fields using the # prefix. This approach is cleaner and more secure:
class Singleton {
static #instance = null;
constructor() {
if (Singleton.#instance) {
return Singleton.#instance;
}
this.timestamp = Date.now();
Singleton.#instance = this;
}
static getInstance() {
if (!Singleton.#instance) {
Singleton.#instance = new Singleton();
}
return Singleton.#instance;
}
}
const a = new Singleton();
const b = new Singleton();
const c = Singleton.getInstance();
console.log(a === b); // true
console.log(a === c); // true
console.log(a.timestamp); // works
// console.log(a.#instance); // SyntaxError: Private field
Private fields protect the instance from outside access, but they still require a class. When the singleton’s behavior is simple enough to fit in a few methods with no inheritance, a plain frozen object is often easier to read and test. The following pattern works well for stateless service objects like loggers:
The object.freeze() pattern
You can also create singletons as plain objects with Object.freeze() to make them immutable:
const Logger = Object.freeze({
log(message) {
console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
},
error(message) {
console.error(`[ERROR] ${new Date().toISOString()}: ${message}`);
}
});
Logger.log('Application started');
Logger.level = 'debug'; // silently ignored
console.log(Logger.level); // undefined
When to use singletons
A singleton makes sense when exactly one instance of a resource should exist for the lifetime of an application and multiple callers need consistent access to the same state. The four scenarios below are the classic cases where a singleton pattern fits naturally because duplicating the resource would create confusion or waste:
Singletons are appropriate in these scenarios:
- Configuration objects: A single source of truth for app settings
- Database connections: One connection pool shared across the app
- Logging services: Centralized logging without creating multiple instances
- Cache managers: Single cache instance for the entire application
Common pitfalls
Tight coupling
Singletons create tight coupling between components. If you change the singleton, all dependent code breaks:
// Problem: Hard to test
class OrderService {
constructor() {
this.logger = Logger.getInstance(); // tightly coupled
}
}
// Better: Dependency injection
class OrderService {
constructor(logger) {
this.logger = logger;
}
}
The code above shows two ways to get the same logger. The first version reaches for Logger.getInstance() inside the constructor, which makes OrderService depend on a specific singleton class. The second version accepts a logger as a parameter, letting tests substitute a mock or a fresh instance. That single change makes the class usable in isolation, which matters as soon as you write tests:
Testing difficulties
Singletons maintain state across tests, which can cause flaky tests:
// Test 1
const config = Config.getInstance();
config.setUser('Alice');
// Test 2 runs after Test 1
const config = Config.getInstance();
console.log(config.getUser()); // 'Alice' from previous test!
To fix this, you need to reset the singleton between tests or use dependency injection. Beyond test isolation, singletons introduce a different kind of problem when code reads and writes shared state in the wrong order. Components that modify the singleton before others have a chance to read it create sequence-dependent bugs that are hard to reproduce:
Global state problems
Singletons essentially act as global variables, which can lead to unexpected behavior as your application grows:
// Order matters—components modifying state before others read it
const settings = Settings.getInstance();
settings.loadFromStorage();
// Another component reads before load completes
const settings = Settings.getInstance();
// May get default/unloaded state
Alternatives to singletons
Before reaching for a singleton, check whether one of these alternatives solves the problem with less hidden state. Each option below keeps the dependency graph explicit, which makes testing and refactoring easier:
- Dependency injection: Pass instances as parameters
- Factory functions: Create fresh instances when needed
- Context/React: Use React context for shared state
- Module exports: Use ES6 modules as the singleton mechanism
When singletons help
Singletons are most useful when one shared instance really does make sense across the whole app. A logger, a configuration store, or a connection manager are common examples because the application benefits from one place to read and write that state. The pattern can be handy when the shared object needs to coordinate work that would be awkward to duplicate. In those cases, a singleton gives you a stable point of contact and keeps the rest of the app from recreating the same resource over and over.
That said, the pattern works best when the shared state is small and predictable. The more behavior the singleton owns, the more it starts to feel like hidden global state. That can make tests harder to write and bugs harder to explain because different parts of the app silently depend on the same object. If a singleton is only there because it seems convenient, it is worth asking whether a module export or a plain instance passed into a function would be easier to live with.
Testing and change
Singletons deserve extra care in tests because they can keep state between cases. A test that changes the singleton may influence the next one unless you reset it or replace it. That is not a reason to avoid the pattern entirely, but it is a reason to keep the instance small and the state explicit. A singleton that stores a lot of mutable data is much harder to reset cleanly than one that only holds a few settings.
It also helps to keep the API narrow. The fewer methods the singleton exposes, the easier it is for the rest of the code to use it consistently. A tiny surface area is easier to document, easier to mock, and easier to replace if the design changes later. If the singleton starts to accumulate unrelated responsibilities, that is usually the point where the pattern stops helping.
Prefer clear ownership
A singleton should not be a shortcut for avoiding design decisions. Before adding one, ask who owns the data, who resets it, and what happens if two parts of the app want different behavior at the same time. If the answer is “only one answer is ever valid,” a singleton may fit. If the answer is “it depends on context,” another pattern is probably healthier. That simple check can save a lot of future cleanup.
Final singleton note
The best singleton is the one that stays small enough to understand at a glance. If the shared object starts to own a long list of unrelated methods or mutable fields, the pattern has probably gone too far. At that point, the app is leaning on hidden state instead of clear ownership. A narrower API makes it easier to test, easier to replace, and easier to explain when someone new reads the code.
If you ever need different behavior in different parts of the app, that is a good sign that the shared instance should be replaced with something more local. A singleton only works well when the answer is truly the same everywhere.
That simple boundary keeps the pattern useful instead of turning it into a stand-in for design work the app still needs.
Next steps
- Try refactoring an existing global object into a module-based singleton. If the object is small and stateless,
Object.freeze()plus an ES module export is often enough. - Read through the Module Pattern tutorial to compare singleton and module pattern approaches.
- Experiment with dependency injection by passing instances as constructor arguments instead of reaching for a global singleton getter. Start with one class and measure how much easier the tests become.