The Singleton Pattern in JavaScript
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 acts as 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:
import config from './config.js';
console.log(config.apiUrl); // https://api.example.com
config.apiUrl = 'http://localhost'; // silently fails in strict mode
console.log(config.apiUrl); // https://api.example.com
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
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
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
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;
}
}
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.
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
Consider these alternatives:
- 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