ScreenshotNeo

BlogGuides

JavaScript Design Patterns: A Practical Guide

Learn when JavaScript design patterns help, with practical examples of Factory, Strategy, Observer, Module, and Decorator—and the tradeoffs of each.

By the ScreenshotNeo team4 October 202610 min read

JavaScript design patterns are reusable ways to solve recurring design problems. They are useful vocabulary for choosing an implementation, not a checklist every application must follow. Start with the simplest code that makes the problem clear; add a pattern when it reduces real complexity or makes a useful relationship explicit.

This guide works through five common patterns—Factory, Strategy, Observer, Module, and Decorator—using runnable JavaScript examples. It also covers when a pattern is unnecessary, what to watch for in larger applications, and how to debug common mistakes.

1. Start with the problem, not the pattern

Before adopting a named pattern, ask what is difficult about the current code. Is object creation branching in several places? Do you need to swap algorithms? Are independent parts of the program reacting to the same event? Does a dependency need a clear file boundary? Do you need to add behavior without changing the original function?

Compare options using six questions:

  • Fit: Does this solve the problem you actually have?
  • Indirection: How many functions, objects, or files does it add?
  • Coupling: Which parts must know about each other?
  • State ownership: Who owns the data, and how long does it live?
  • Testing: Can the pieces be tested independently?
  • Native features: Does JavaScript already provide the needed boundary or behavior?

JavaScript supports functional and object-oriented styles. Its class syntax uses the prototype-based object model; use a class when an object has meaningful state and behavior, and use functions or plain objects when they express the intent more directly. See MDN’s guide to JavaScript classes.

2. Factory: centralize creation when it varies

A factory hides a choice about which implementation to create behind a stable interface. It earns its keep when creation branches based on configuration or input. If construction is one simple expression, a factory can be unnecessary indirection.

Direct construction baseline

function createEmailNotifier() {
  return {
    send(message) {
      return `Email: ${message}`;
    }
  };
}

const notifier = createEmailNotifier();
console.log(notifier.send("Build finished"));

Factory when variants are needed

function createNotifier(kind) {
  switch (kind) {
    case "email":
      return { send: (message) => `Email: ${message}` };
    case "console":
      return { send: (message) => `Console: ${message}` };
    default:
      throw new Error(`Unknown notifier kind: ${kind}`);
  }
}

const notifier = createNotifier(process.argv[2] ?? "console");
console.log(notifier.send("Build finished"));

Save this as factory.mjs and run node factory.mjs email (Node.js with ES module support). In a browser, use a <script type="module"> entry point or a module-aware toolchain. Validate external configuration before passing it to the factory; the explicit error makes unsupported variants visible.

Choose it when: callers should not know construction details, or selecting a variant is a real policy decision. Skip it when: every caller constructs the same simple object and the wrapper merely renames that operation.

3. Strategy: swap an algorithm without branching everywhere

A strategy is an interchangeable implementation with a shared call shape. In JavaScript that can be a function or a plain object; a hierarchy of strategy classes is often more machinery than needed.

const pricing = {
  regular: (subtotal) => subtotal,
  member: (subtotal) => subtotal * 0.9,
  clearance: (subtotal) => subtotal * 0.7
};

function totalFor(subtotal, strategy) {
  if (!Number.isFinite(subtotal) || subtotal < 0) {
    throw new RangeError("subtotal must be a non-negative number");
  }
  if (typeof strategy !== "function") {
    throw new TypeError("strategy must be a function");
  }
  return strategy(subtotal);
}

const kind = "member";
console.log(totalFor(100, pricing[kind]));

The dispatch table keeps the algorithm choice separate from the calculation’s caller. In real code, check that a key exists before using it, and define rounding and currency rules explicitly when working with money. Use a passed-in function when there is only one substitution point; use named strategies when they represent meaningful choices used across the application.

Tradeoff: indirection can make a call harder to trace. Keep strategy names descriptive, and avoid a registry if a direct conditional is easier to understand.

4. Observer: notify registered callbacks and clean them up

The Observer pattern lets a subject notify callbacks when an event or state change occurs. The subject owns the subscription list; each subscriber owns the lifetime of its registration and should unsubscribe when it no longer needs updates.

function createSignal() {
  const listeners = new Set();

  return {
    subscribe(listener) {
      if (typeof listener !== "function") {
        throw new TypeError("listener must be a function");
      }
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    emit(value) {
      for (const listener of [...listeners]) {
        listener(value);
      }
    }
  };
}

const statusChanged = createSignal();
const unsubscribe = statusChanged.subscribe((status) => {
  console.log(`Status: ${status}`);
});

statusChanged.emit("ready");
unsubscribe();
statusChanged.emit("ignored after unsubscribe");

The copied listener list means subscription changes during notification do not change the iteration in progress. Decide how listener exceptions should behave: in this small example an exception propagates and stops later callbacks. Applications may instead catch and report each failure, but that changes error semantics and should be deliberate.

Observer versus publish/subscribe

With direct Observer registration, subscribers typically know the subject they observe. A publish/subscribe event bus adds a channel between publishers and subscribers, which can reduce direct connections but makes event names, ownership, and flow harder to trace. Choose direct callbacks when the relationship is local; introduce an event channel only when its decoupling is useful.

Always retain and call the unsubscribe function when a component, request, or view ends. Forgotten subscriptions can retain objects, cause duplicate reactions, or deliver events to stale UI.

5. Module: use native imports and exports

Modern JavaScript modules already provide file-level boundaries, imports, exports, and private unexported bindings. Begin with native modules. The older Module pattern used closures and object literals to simulate encapsulation; it remains useful in specialized situations, but it is not a prerequisite for organizing modern JavaScript.

// math.mjs
const taxRate = 0.08;

export function totalWithTax(subtotal) {
  return subtotal * (1 + taxRate);
}
// app.mjs
import { totalWithTax } from "./math.mjs";

console.log(totalWithTax(50));

Run with node app.mjs in a Node.js environment that supports ES modules. For browsers, serve the files over HTTP and load the entry file with <script type="module" src="./app.mjs"></script>; module resolution depends on the host. Put imports at the top of a module so dependencies are easy to inspect. MDN’s JavaScript modules guide explains import and export forms.

Export only what callers need. Keeping internal helpers unexported preserves the option to change them without changing every importer.

6. Decorator: compose extra behavior around an operation

Here, “Decorator” means wrapping a function or object to add behavior while preserving its call shape. This is distinct from syntax-level JavaScript decorators, whose support and implementation details depend on the runtime and toolchain.

function withTiming(operation, now = () => performance.now()) {
  return (...args) => {
    const started = now();
    try {
      return operation(...args);
    } finally {
      console.log(`Duration: ${(now() - started).toFixed(2)} ms`);
    }
  };
}

function sum(values) {
  return values.reduce((total, value) => total + value, 0);
}

const timedSum = withTiming(sum);
console.log(timedSum([2, 3, 5]));

In browsers and modern Node.js, performance.now() is available; if targeting a different runtime, provide an appropriate clock function. This wrapper preserves synchronous return values and runs timing cleanup even if the operation throws. An asynchronous operation needs a wrapper that awaits its result and measures through settlement. Be clear about whether the wrapper preserves properties such as function name, this, and custom metadata; wrappers do not automatically preserve every characteristic.

Use function composition for cross-cutting behavior such as timing or logging when it keeps the operation’s purpose clear. Avoid layers of wrappers that obscure control flow.

7. Useful patterns to recognize next

  • Singleton: exposes one shared instance. It can create global mutable state and test coupling, so do not make it the default way to share dependencies. Prefer passing a dependency to the code that needs it.
  • Proxy: controls access to another object, for example to validate or log operations. Use it when access interception is the actual need; it can make behavior less visible at the call site.
  • Command: represents an action as data or an object, useful when actions need queuing, undo, or logging. For a direct one-off call, a function is simpler.
  • Dependency Injection: supplies dependencies from outside a component. Passing a function or object explicitly is often enough; a container is not required.
  • Mediator: routes interactions through a coordinating object. It can reduce direct links but risk becoming a central object that knows too much.
  • Facade: provides a smaller interface over a complex subsystem. Keep it focused on a real simplification rather than adding another pass-through layer.

These names are part of the broader JavaScript pattern vocabulary; a community catalog lists examples, but review any sample code independently before reuse: JavaScript Design Patterns repository.

8. A practical selection checklist

  1. Write down the repeated problem in plain language.
  2. Sketch the simplest direct implementation.
  3. Identify what varies: creation, algorithm, event reaction, dependency, or behavior around a call.
  4. Choose the smallest structure that isolates that variation.
  5. Make ownership and lifetime of mutable state explicit.
  6. Add cleanup for subscriptions and external resources.
  7. Test both the normal case and invalid or missing inputs.
  8. Revisit the abstraction if readers need to jump through several files to understand one operation.

9. Troubleshooting common design-pattern mistakes

Symptom Likely cause Fix
Factory throws “Unknown … kind” Input does not match a supported variant or configuration contains a typo. Validate input at the boundary, document accepted values, and keep the explicit error.
Strategy lookup returns undefined The dispatch key is absent or misspelled. Check membership before calling; provide a deliberate default or report unsupported input.
Observer runs more than once A component subscribed repeatedly and did not clean up prior registrations. Store each unsubscribe callback and invoke it at the lifecycle boundary.
Observer stops notifying after one callback fails An exception propagated from a listener. Choose and document propagation or per-listener error isolation; report failures rather than silently swallowing them.
Module import fails Wrong relative path, file extension, server MIME type, or runtime module configuration. Check the host’s module rules and path; serve browser modules over HTTP and confirm the entry point uses module loading.
Wrapped function changes behavior unexpectedly The wrapper changed this, async handling, error behavior, or return values. Document the wrapper contract and preserve the relevant call semantics explicitly.
Tests interfere with one another Singleton or module-level mutable state persists between tests. Prefer injected dependencies, reset state at a clear boundary, or provide a factory that creates isolated instances.

10. Performance, reliability, and cost

These patterns do not carry a universal performance cost or benefit; the effect depends on the work performed and how often it runs. A function call, map lookup, or listener iteration is usually less important to investigate than unnecessary work inside the selected operation. Keep observer work bounded, avoid retaining listeners after their owner ends, and do not add abstraction layers to optimize without evidence relevant to your application.

Reliability comes from explicit input validation, clear error behavior, deliberate state ownership, and cleanup. Patterns do not make code reliable by themselves. No pattern adoption or productivity statistic is needed to choose one: compare the maintenance cost of the abstraction with the complexity it removes.

If a project also needs screenshots of documentation, rendered pages, or UI states, ScreenshotNeo is a website screenshot API and MCP server for developers. Its API options include custom JavaScript and CSS, viewport and device presets, and full-page captures; see the ScreenshotNeo API documentation.

Or skip the browser setup

A single GET request can return a screenshot. This cURL example saves a WebP image:

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

Python:

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);

The Node.js example uses Bun’s file writer; on Node.js, save the response with writeFile:

import { writeFile } from 'node:fs/promises';

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed; response headers report the page verdict and billing status. An MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Every feature is on every plan. See the docs for request options and sign up for 1,000 free screenshots a month, with no card.

Frequently asked questions

Do I need to memorize design patterns?

No. Learn to recognize the problem each pattern addresses and the tradeoff it introduces. That is more useful than applying names by habit.

Should I use classes for these patterns?

Only when object state and behavior make a class clearer. Functions, closures, and plain objects are equally valid JavaScript tools.

Is a design pattern the same as a framework?

No. A pattern is a reusable design idea; a framework is software that supplies structure and behavior for an application.

Where should I begin in a React or Node.js project?

Begin with the host’s native module system and the simplest function or component boundary. Add a pattern when repeated choices or relationships become difficult to manage.

Further reading