A proxy is a stand-in. It implements the same interface as the real object, so callers cannot tell the difference, but it gets to decide what happens before and after each call: answer from a cache, check permissions, delay creating an expensive object, log, rate limit. Think of an assistant who handles a manager's calendar. You still "book a meeting with the manager", but the assistant decides whether it reaches them.

Difficulty: ★★★☆☆. Popularity: ★★★★☆. JavaScript is unusual in having proxies built into the language, which is why Vue's reactivity system, MobX and Immer are built on them.

The problem: cross-cutting rules leaking into every caller

A product catalog client talks to a slow upstream service:

interface Product {
  id: string;
  name: string;
  price: number;
}

interface Catalog {
  getProduct(id: string): Promise<Product>;
  updatePrice(id: string, price: number): Promise<void>;
}

class HttpCatalog implements Catalog {
  calls = 0;
  async getProduct(id: string): Promise<Product> {
    this.calls++;
    await new Promise((resolve) => setTimeout(resolve, 50)); // pretend network latency
    return { id, name: `Product ${id}`, price: 199_000 };
  }
  async updatePrice(id: string, price: number) {
    this.calls++;
    console.log(`upstream: ${id} -> ${price}`);
  }
}

Soon you need caching (the product page calls getProduct on every render), and only staff should be able to change prices. The tempting fix is to add those rules where getProduct and updatePrice are called. Now every caller has its own cache map with its own expiry bugs, and one forgotten permission check is a security hole.

Caching and access control are not the catalog's job and not the callers' job. They belong in between.

A caching proxy

The proxy implements Catalog and holds a reference to the real one:

class CachingCatalog implements Catalog {
  private cache = new Map<string, { value: Product; expiresAt: number }>();
  private inFlight = new Map<string, Promise<Product>>();

  constructor(
    private readonly inner: Catalog,
    private readonly ttlMs = 30_000,
  ) {}

  async getProduct(id: string): Promise<Product> {
    const hit = this.cache.get(id);
    if (hit && hit.expiresAt > Date.now()) return hit.value;

    // Collapse concurrent misses for the same key into one upstream call.
    const pending = this.inFlight.get(id);
    if (pending) return pending;

    const request = this.inner
      .getProduct(id)
      .then((value) => {
        this.cache.set(id, { value, expiresAt: Date.now() + this.ttlMs });
        return value;
      })
      .finally(() => this.inFlight.delete(id));
    this.inFlight.set(id, request);
    return request;
  }

  async updatePrice(id: string, price: number) {
    await this.inner.updatePrice(id, price);
    this.cache.delete(id); // invalidate on write
  }
}

Two details make this a production-grade cache rather than a toy: it invalidates on write, and it collapses concurrent misses, so 100 simultaneous requests for a cold product make one upstream call instead of 100 (the "thundering herd").

A protection proxy

Access control is the same shape with a different rule:

type User = { id: string; role: "customer" | "staff" | "admin" };

class ForbiddenError extends Error {}

class GuardedCatalog implements Catalog {
  constructor(
    private readonly inner: Catalog,
    private readonly currentUser: () => User,
  ) {}

  getProduct(id: string) {
    return this.inner.getProduct(id);
  }

  async updatePrice(id: string, price: number) {
    const user = this.currentUser();
    if (user.role === "customer") throw new ForbiddenError(`${user.id} cannot change prices`);
    if (price <= 0) throw new RangeError("Price must be positive");
    return this.inner.updatePrice(id, price);
  }
}

Because every proxy implements Catalog, they stack. The composition root decides the order once:

let me: User = { id: "u-1", role: "customer" };
const upstream = new HttpCatalog();
const catalog: Catalog = new GuardedCatalog(new CachingCatalog(upstream), () => me);

await Promise.all(Array.from({ length: 100 }, () => catalog.getProduct("p-42")));
await catalog.getProduct("p-42");
console.log("upstream calls after 101 reads:", upstream.calls); // 1

await catalog.updatePrice("p-42", 149_000).catch((e) => console.log(e.message)); // u-1 cannot change prices

me = { id: "u-2", role: "staff" };
await catalog.updatePrice("p-42", 149_000); // upstream: p-42 -> 149000

The page code still calls catalog.getProduct(id). It has no idea there is a cache or a permission check, which is exactly the point.

The kinds of proxy

The GoF book names several variants. They are all the same structure with a different reason to intercept:

  • Virtual proxy: delays creating an expensive object until it is first used (lazy loading).
  • Protection proxy: checks permissions before forwarding.
  • Caching proxy: remembers results.
  • Remote proxy: looks like a local object but forwards calls over the network. Every generated API client (gRPC stubs, tRPC, OpenAPI clients) is one.
  • Logging or metrics proxy: records calls, timings and errors.

A virtual proxy is short enough to show in full:

class LazyCatalog implements Catalog {
  private instance?: Catalog;
  constructor(private readonly create: () => Catalog) {}
  private get real() {
    return (this.instance ??= this.create());
  }
  getProduct(id: string) {
    return this.real.getProduct(id);
  }
  updatePrice(id: string, price: number) {
    return this.real.updatePrice(id, price);
  }
}

JavaScript's built-in Proxy

Writing a proxy class means forwarding every method by hand. JavaScript's Proxy object does the forwarding for you and lets you intercept operations (property reads, writes, calls, in, delete) with traps. Reflect provides the default behaviour for each trap, so you only change what you need.

A generic timing proxy that works for any object with async methods:

function withTiming<T extends object>(target: T, label: string): T {
  return new Proxy(target, {
    get(obj, prop, receiver) {
      const value = Reflect.get(obj, prop, receiver);
      if (typeof value !== "function") return value;
      return async (...args: unknown[]) => {
        const start = performance.now();
        try {
          return await value.apply(obj, args);
        } finally {
          console.log(`${label}.${String(prop)} took ${(performance.now() - start).toFixed(0)}ms`);
        }
      };
    },
  });
}

const timed = withTiming<Catalog>(new HttpCatalog(), "catalog");
await timed.getProduct("p-1"); // catalog.getProduct took 50ms (roughly)

And a validation proxy for a plain config object, which no class-based approach can do as neatly:

type Config = { port: number; logLevel: "debug" | "info" | "warn" | "error" };

function strictConfig(initial: Config): Config {
  return new Proxy(initial, {
    set(obj, prop, value, receiver) {
      if (!(prop in obj)) throw new TypeError(`Unknown config key: ${String(prop)}`);
      if (prop === "port" && (!Number.isInteger(value) || value < 1 || value > 65_535)) {
        throw new RangeError(`Invalid port: ${value}`);
      }
      return Reflect.set(obj, prop, value, receiver);
    },
  });
}

const config = strictConfig({ port: 3000, logLevel: "info" });
config.port = 8080; // fine
try {
  (config as Record<string, unknown>).prot = 9000;
} catch (e) {
  console.log((e as Error).message); // Unknown config key: prot
}

Use built-in Proxy for generic, cross-cutting behaviour over many objects. Use a class when the proxy is specific to one interface, because the class is easier to read, debug and type.

Proxy vs Decorator vs Adapter

These three all wrap one object, and the difference is intent:

  • Proxy keeps the same interface and controls access to the real object.
  • Decorator keeps the same interface and adds behaviour the caller asked for (the TimeBoxed discount in chapter 13).
  • Adapter changes the interface so an object fits where it otherwise would not (chapter 9).

In practice the line between Proxy and Decorator is thin, and the caching proxy above could fairly be called either. What matters is that callers depend on the interface, not the wrapper.

When to use it, and when not to

Use it when:

  • A cross-cutting concern (cache, permission, lazy loading, metrics) applies to every call of an interface.
  • You cannot or should not change the real object (a vendor SDK, generated code).
  • You want the concern to be removable or reorderable in one place.

Skip it when:

  • Only one call site needs the behaviour. Do it there.
  • Your framework already provides the interception point: NestJS interceptors and guards, Express middleware and HTTP caching headers are proxies with better tooling.

Common pitfalls

  1. Stale caches. A caching proxy without invalidation serves old prices for the whole TTL. Invalidate on writes and keep TTLs short for data that changes.
  2. Wrapping the wrong layer. A protection proxy around the HTTP client protects only callers that go through it. Enforce permissions where the data lives, and use the proxy as an early, friendly check.
  3. Losing this. With built-in Proxy, call methods with value.apply(obj, args) or Reflect.apply. Calling them on the proxy can recurse; calling them unbound breaks classes with private fields.
  4. Hidden cost. A proxy that makes a network call on a property read is surprising. Keep traps cheap and predictable.

Exercises

  1. Add a maxEntries limit to CachingCatalog and evict the least recently used product when it is exceeded.
  2. Write a RetryingCatalog proxy that retries getProduct up to three times with exponential backoff, but never retries updatePrice. Why the difference?
  3. Use the built-in Proxy to build readonlyDeep(obj) that throws on any write, including to nested objects.
  4. Replace the in-memory map in CachingCatalog with Redis (GET, SET with EX) and keep the in-flight request collapsing local to each process. What is still not collapsed across processes?