The JavaScript Proxy Pattern: Complete Guide

The proxy pattern places a substitute object between the caller and the real target. Learn virtual proxies, caching proxies, lazy initialization, and access control patterns in JavaScript.

7 min read

The JavaScript proxy pattern is a structural design pattern where a surrogate object stands in for a real object. Any code that wants to interact with the real object talks to the proxy instead. The proxy decides whether and how to forward each request.

This is not the same as the Proxy API, though the API is a convenient way to implement the pattern. The proxy pattern existed decades before new Proxy() was added to JavaScript. It is about the relationship between three players:

Proxy pattern structure

The client only knows the proxy's interface. It never interacts with the real subject directly. The proxy controls access, deferring creation, caching results, logging calls, or blocking unauthorized access.

Virtual Proxy -- Lazy Initialization

A virtual proxy delays the creation of an expensive object until the moment it is actually needed. It matches the interface of the real object, so callers cannot tell the difference until they check timing or memory use. The example below wraps an image loader that is slow to construct:

javascriptjavascript
class ImageLoader {
  constructor(filename) {
    this.filename = filename;
    console.log(`Loading ${filename} from disk...`);
  }
 
  display() {
    console.log(`Displaying ${this.filename}`);
  }
}

This loader class does the expensive work in its constructor, printing a message as soon as it is built. Nothing here defers that cost yet.

The proxy class below matches that same interface but only builds a loader instance the first time its display method actually runs, not before:

javascriptjavascript
class LazyImageProxy {
  constructor(filename) {
    this.filename = filename;
    this.realImage = null;
  }
 
  display() {
    if (!this.realImage) {
      this.realImage = new ImageLoader(this.filename);
    }
    this.realImage.display();
  }
}

Notice the constructor only stores the filename. The real loader is created inside display, and only on the first call. Creating the proxy and calling display later shows exactly when each message logs:

javascriptjavascript
// The real ImageLoader is never created until display() is called
const img = new LazyImageProxy("photo.jpg");
// ... many lines later ...
img.display();
// Loading photo.jpg from disk...
// Displaying photo.jpg

The proxy holds the filename but does not load the image from disk. Only when display() is called for the first time does it create the real loader and forward the call. If display is never called, the expensive object is never created.

This pattern shines when you have objects that are expensive to construct (database connections, large file parsers, WebGL contexts) and may never be used in every code path. A page that renders a gallery of a hundred thumbnails, for example, does not need to decode all hundred images if the user only scrolls past ten of them.

Virtual Proxy with the Proxy API

Using the Proxy API, you can make a virtual proxy fully transparent. The caller does not even know a proxy is in play:

javascriptjavascript
function createLazyProxy(factory) {
  let instance = null;
 
  return new Proxy({}, {
    get(_, prop) {
      if (!instance) {
        instance = factory();
      }
      const value = instance[prop];
      return typeof value === "function"
        ? value.bind(instance)
        : value;
    }
  });
}

The get trap only calls factory() the first time a property is read, then reuses that instance on every later access. Try it with a factory that opens a database connection:

javascriptjavascript
// Factory is never called until a property is accessed
const db = createLazyProxy(() => {
  console.log("Opening database connection...");
  return { query: (sql) => `Result for: ${sql}` };
});
 
console.log("Proxy created, no connection yet.");
console.log(db.query("SELECT 1"));
// Opening database connection...
// Result for: SELECT 1

The factory function is only called the first time a property is accessed. Until then, the proxy is an empty shell. After initialization, every access forwards to the real instance.

Caching Proxy -- Memoization

A caching proxy stores the results of expensive operations and returns the stored result when the same inputs appear again:

javascriptjavascript
function createCachingProxy(fn) {
  const cache = new Map();
 
  return new Proxy(fn, {
    apply(target, thisArg, args) {
      const key = JSON.stringify(args);
      if (cache.has(key)) {
        console.log(`Cache hit for args: ${key}`);
        return cache.get(key);
      }
      console.log(`Cache miss for args: ${key}`);
      const result = Reflect.apply(target, thisArg, args);
      cache.set(key, result);
      return result;
    }
  });
}

The apply trap fires whenever the proxy is called as a function. It serializes the arguments into a cache key and only runs the real function on a miss. Wrap a slow function with it to see the effect:

javascriptjavascript
function expensiveCalculation(n) {
  // Simulate heavy work
  let result = 0;
  for (let i = 0; i < n * 1_000_000; i++) {
    result += i;
  }
  return result;
}
 
const cachedCalc = createCachingProxy(expensiveCalculation);
 
cachedCalc(5); // Cache miss: runs the real function
cachedCalc(5); // Cache hit: returns stored result instantly
cachedCalc(10); // Cache miss: new argument

The proxy intercepts function calls via the apply trap, serializes the arguments as a cache key, and skips the real function when a matching result exists. This is transparent memoization: the caller uses the function normally and never knows about the cache.

For simpler memoization without the Proxy API, you can also implement this pattern with a plain wrapper function. The Proxy API version has the advantage that the cached function is indistinguishable from the original at the call site.

Protection Proxy -- Access Control

A protection proxy checks permissions before allowing operations on the target:

javascriptjavascript
function createProtectionProxy(target, allowedRoles, getCurrentRole) {
  return new Proxy(target, {
    get(obj, prop) {
      const role = getCurrentRole();
      if (!allowedRoles.includes(role)) {
        throw new Error(`Access denied: "${String(prop)}" requires one of [${allowedRoles}]`);
      }
      return Reflect.get(obj, prop);
    },
    set(obj, prop, value) {
      const role = getCurrentRole();
      if (!allowedRoles.includes(role)) {
        throw new Error(`Modification denied: "${String(prop)}" requires one of [${allowedRoles}]`);
      }
      return Reflect.set(obj, prop, value);
    }
  });
}

Both traps call getCurrentRole() and compare it against the allowed list before forwarding. Wrap a user record so only admins can read the salary field:

javascriptjavascript
const userData = { name: "Alex", role: "user", salary: 75000 };
 
// Only admins can access salary
const protectedUser = createProtectionProxy(
  userData,
  ["admin"],
  () => "user" // Simulated current user role
);
 
console.log(protectedUser.name);   // "Alex"
console.log(protectedUser.salary); // Error: Access denied: "salary" requires one of [admin]

The proxy checks the current user's role on every access. The getCurrentRole function is called fresh each time, so role changes take effect immediately. The target object contains the data as usual, but no code path can reach sensitive fields without passing through the proxy.

Logging Proxy -- Audit Trail

A logging proxy records every interaction for debugging or compliance:

javascriptjavascript
function createAuditProxy(target, log = []) {
  return new Proxy(target, {
    get(obj, prop) {
      const value = Reflect.get(obj, prop);
      log.push({ operation: "read", property: String(prop), timestamp: Date.now() });
      return value;
    },
    set(obj, prop, value) {
      log.push({ operation: "write", property: String(prop), value, timestamp: Date.now() });
      return Reflect.set(obj, prop, value);
    }
  });
}

Every read and write pushes a record onto the shared log array before forwarding the operation to the real object underneath, so nothing can happen without leaving a trace:

javascriptjavascript
const auditLog = [];
const settings = createAuditProxy({ theme: "dark" }, auditLog);
 
settings.theme = "light";
console.log(settings.theme);
 
console.log(auditLog);
// [
//   { operation: "write", property: "theme", value: "light", timestamp: 1750000000000 },
//   { operation: "read", property: "theme", timestamp: 1750000000100 }
// ]

The log array is shared between the proxy and the calling code. You can inspect it at any time to see exactly which properties were accessed and modified, in what order, and when. This is useful for debugging state changes, tracking user actions, or building undo/redo systems.

Combining Patterns -- Lazy and Cached Proxy

Real applications often layer patterns. Here is a proxy that combines lazy initialization with caching in one wrapper:

javascriptjavascript
function createSmartProxy(factory) {
  let instance = null;
  const cache = new Map();
 
  return new Proxy({}, {
    get(_, prop) {
      if (!instance) {
        console.log("[SmartProxy] Initializing instance...");
        instance = factory();
      }
 
      const value = instance[prop];
      if (typeof value !== "function") return value;
 
      return (...args) => {
        const cacheKey = `${String(prop)}:${JSON.stringify(args)}`;
        if (cache.has(cacheKey)) return cache.get(cacheKey);
        const result = value.apply(instance, args);
        cache.set(cacheKey, result);
        return result;
      };
    }
  });
}

The get trap creates the real instance on first use, then wraps every method it returns so repeated calls with the same arguments skip straight to the cache. Try it with an API client that connects lazily:

javascriptjavascript
// Usage: an API client that lazily connects and caches responses
const api = createSmartProxy(() => ({
  fetch: (url) => `Data from ${url} (fetched at ${Date.now()})`
}));
 
console.log(api.fetch("/users")); // Initializes proxy, fetches data
console.log(api.fetch("/users")); // Cache hit: returns stored result

This single proxy defers initialization until the first call and caches method results. The client code just calls api.fetch() and gets results. All the complexity is encapsulated in the proxy.

Proxy Pattern Without the Proxy API

The proxy pattern does not require the Proxy API. A plain class implementing the same interface works too:

javascriptjavascript
class RealDatabase {
  connect() { return "Connected to production DB"; }
  query(sql) { return `Result for: ${sql}`; }
}

RealDatabase is the expensive object the pattern protects. DatabaseProxy below exposes the exact same method names, so callers cannot tell the two apart, but it adds lazy connection and a safety check:

javascriptjavascript
class DatabaseProxy {
  constructor() {
    this.realDb = null;
  }
 
  connect() {
    if (!this.realDb) {
      console.log("[Proxy] Initializing real database...");
      this.realDb = new RealDatabase();
    }
    return this.realDb.connect();
  }
 
  query(sql) {
    if (sql.toLowerCase().includes("drop")) {
      throw new Error("DROP statements are not allowed through this proxy");
    }
    return this.connect() && this.realDb.query(sql);
  }
}

Calling query() on the proxy connects to the real database lazily on first use, and it blocks destructive statements before they ever reach that real database:

javascriptjavascript
const db = new DatabaseProxy();
console.log(db.query("SELECT 1"));  // [Proxy] Initializing real database... → Result for: SELECT 1
console.log(db.query("DROP TABLE")); // Error: DROP statements are not allowed

This implementation does not use the Proxy API at all. It is a plain class with the same method names as the real database, so the caller uses it identically.

The proxy still controls initialization and blocks dangerous operations, just through ordinary method calls instead of traps. Nothing about the calling code needs to change if you later swap this class-based proxy for a Proxy API version.

The Proxy API version would be more concise and could intercept any property dynamically. The class-based version is explicit and easier to debug. Choose based on whether you need dynamic property interception or a fixed, well-known interface.

Choosing the Right Proxy Variant

PatternUse whenExample
Virtual ProxyObject creation is expensive and may be deferredDatabase connection, image loader, WebGL context
Caching ProxyRepeated calls with same inputs return same resultsAPI responses, computed values, parsed data
Protection ProxyDifferent users or contexts get different access levelsAdmin vs user data, read-only config, API key access
Logging ProxyYou need an audit trail of every interactionDebugging, compliance, undo/redo history
Remote ProxyThe real object lives in a different process or serverAPI client wrappers, RPC stubs

Most codebases only need one or two of these variants at a time, and it is common to combine them, as the earlier lazy-and-cached example showed.

Start from the problem you actually have. If a value is slow to create, reach for a virtual proxy, and if it is slow to compute, reach for a caching proxy instead.

If a value needs guarding from certain callers, reach for a protection proxy.

Picking the variant that matches your problem keeps the wrapper small and easy to reason about, instead of building one proxy that tries to handle every concern at once. Adding traps you do not need only makes the wrapper harder to debug later.

For the underlying Proxy API mechanics (traps, Reflect, revocation), see /javascript/advanced-javascript-proxies-complete-guide. For reactive state powered by Proxy, see /javascript/building-a-reactive-ui-with-the-js-observer.

Rune AI

Rune AI

Key Insights

  • The proxy pattern wraps a real object with a surrogate that controls access to it.
  • A virtual proxy delays the creation of an expensive object until it is actually needed.
  • A caching proxy stores results and returns them for repeated identical calls.
  • A protection proxy validates permissions before forwarding operations to the target.
  • You can implement the pattern with plain classes, the Proxy API, or a mix of both.
RunePowered by Rune AI

Frequently Asked Questions

Is the proxy pattern the same as the Proxy API?

The proxy pattern is a design concept that predates the Proxy API. You can implement the pattern with plain objects, classes, or the Proxy API. The API makes the pattern more concise, but the pattern itself is about having a stand-in object that controls access to a real target.

When should I use a virtual proxy vs a caching proxy?

Use a virtual proxy when the real object is expensive to create and may not be needed at all (lazy loading). Use a caching proxy when the real object is called repeatedly with the same inputs and the results can be reused.

Conclusion

The proxy pattern is one of the most practical structural design patterns in JavaScript. Whether you are deferring expensive object creation with a virtual proxy, reusing results with a caching proxy, or guarding access with a protection proxy, the core idea is the same: put a surrogate in front of the real object and control every interaction. The Proxy API makes this pattern more concise, but the pattern itself works with plain objects and classes too.