Back

Singletons in JavaScript: Useful Tool or Hidden Trap?

Singletons in JavaScript: Useful Tool or Hidden Trap?

You’ve written a module that exports a configured logger or an API client instance. Every file imports it, and you assume there’s exactly one instance running across your entire application. Then your tests start leaking state between runs. Or your microfrontend architecture suddenly has two copies of your “singleton” fighting each other. What happened?

The JavaScript singleton pattern doesn’t work the way classical design-pattern theory suggests. Understanding where ES module singletons actually live—and where they break—saves you from debugging sessions that feel like chasing ghosts.

Key Takeaways

  • ES module singletons are cached per module graph and runtime, not globally across your entire system
  • Singletons work well for immutable data and stateless operations like logging utilities and read-only configuration
  • Mutable state in singletons becomes dangerous in server-side rendering, test environments, and microfrontend architectures
  • Before using a singleton, consider whether your code runs in multiple bundles, workers, or server requests, and whether tests need to reset the instance

What “Singleton” Actually Means in Modern JavaScript

Forget the Gang of Four definition for a moment. In modern JavaScript, a singleton is typically just a module that exports an already-instantiated object:

// logger.js
class Logger {
  log(message) {
    console.log(`[${Date.now()}] ${message}`)
  }
}

export const logger = new Logger()

When you import logger from multiple files, you get the same instance—not because of clever constructor tricks, but because ES modules are cached per module graph and runtime. The module executes once, the instance is created once, and every import receives a reference to that same object.

This is the foundation of ES module singletons. It’s simple and often exactly what you need.

The Assumption That Breaks

Here’s where singleton pitfalls in JavaScript emerge: that “single instance” is scoped to a specific module graph within a specific JavaScript runtime. It’s not magically global across your entire system.

The assumption breaks in several real-world scenarios:

Multiple bundles or duplicate packages. If your monorepo has two packages that each bundle their own copy of a dependency, you get two separate module graphs. Two “singletons.” Your shared state isn’t shared anymore.

Test runners. Jest, Vitest, and similar tools often reset module caches between test files or use worker processes. Your singleton from one test file may not be the same instance in another.

Microfrontends. Each independently deployed frontend typically has its own JavaScript runtime. A singleton in one microfrontend is invisible to another unless instances are explicitly shared via the build or runtime.

Web Workers and Service Workers. These run in separate JavaScript contexts. A module imported in a worker is a completely different instance from the same module in your main thread.

Server runtimes with request isolation. In frameworks like Next.js or Nuxt running on the server, a singleton may persist across multiple user requests depending on the runtime and deployment model, risking data leakage if it holds mutable, request-specific state.

Where Singletons Work Well

Despite these pitfalls, ES module singletons remain genuinely useful for certain frontend utility and coordination patterns:

  • Logging utilities. A shared logger with consistent formatting causes no harm if duplicated and holds no sensitive per-request state.
  • Configuration snapshots. Read-only configuration loaded at startup works fine as a singleton. The key word is read-only.
  • Stateless utilities. Helper functions or classes that don’t maintain mutable state between calls are safe candidates.

The common thread: these singletons either hold immutable data or perform stateless operations.

Where Singletons Become a Liability

Mutable state is where things get dangerous. Consider:

  • User session data. In server-side rendering, a singleton holding user information can leak between requests.
  • Request-scoped caches. Data that should reset per request persists incorrectly.
  • Shared mutable configuration. One part of your app modifies settings, unexpectedly affecting another.

Modern tooling amplifies these issues. In React 18+, Strict Mode intentionally double-invokes renders and certain effects in development, exposing singleton state that isn’t properly isolated. Hot Module Replacement in Vite or webpack can preserve singleton state across code changes, creating subtle bugs where your “fresh” code operates on stale data.

A Practical Stance

The JavaScript singleton pattern isn’t inherently bad—it’s just narrower than many developers assume. Before reaching for a module-level instance, ask:

  1. Is this state truly immutable or stateless?
  2. Could this code run in multiple bundles, workers, or server requests?
  3. Will my tests need to reset or mock this instance?

If you answer “yes” to questions 2 or 3 with mutable state, consider alternatives: factory functions, dependency injection, or framework-specific patterns like React Context or request-scoped services.

Conclusion

Singletons are a useful tool when you understand their actual scope. They become a hidden trap when you assume “single instance” means something the runtime never promised. Stick to immutable or stateless data for your module-level instances, and reach for dependency injection or factory patterns when you need proper isolation across tests, requests, or runtime boundaries.

FAQs

Jest often resets module caches between test files or runs tests in isolated worker processes, creating fresh module graphs and re-instantiating your singleton. Use jest.resetModules() or jest.isolateModules() where appropriate, or avoid module-level mutable state by injecting dependencies.

No. Web Workers run in separate JavaScript contexts with their own module graphs. A module imported in a worker is a completely different instance from the same module in your main thread. To share state, you must explicitly pass data between contexts using postMessage or SharedArrayBuffer.

Only for immutable or stateless data. Singletons on the server may persist across multiple user requests depending on the runtime and deployment model, potentially leaking sensitive data between users. For request-specific state like user sessions or caches, use request-scoped patterns provided by your framework instead of module-level instances.

Factory functions let you create fresh instances on demand. Dependency injection passes instances explicitly, making testing easier. Framework-specific solutions like React Context provide scoped state management. For server applications, request-scoped services ensure proper isolation between requests while maintaining the convenience of shared utilities.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay