The Proxy Pattern (vs the Proxy Object)
The health check took nine seconds to answer. It did nothing. It returned { ok: true }, touched no database, and it had been getting slower for a year.
The reason was three lines at the top of a file nobody opened:
// analytics.js
import { SalesCube } from "./sales-cube.js";
export const cube = new SalesCube(); // runs the moment this file is imported
new SalesCube() pulls a year of orders out of Postgres and folds them into an in-memory structure you can slice by region, product, and week. It is genuinely useful and genuinely expensive: a few seconds and a few hundred megabytes on a cold start. The dashboard needs it. The nightly export needs it.
The health check does not need it. Neither does the login page, the webhook receiver, or the tiny script that prints the app version and exits. But analytics.js gets pulled in, directly or through a chain of imports, by almost everything, and the instant it is imported that constructor runs. Every process pays for the cube whether or not it will ever ask the cube a single question.
You do not want to delete the cube. You want the thing everyone holds to look exactly like the cube, answer questions exactly like the cube, and only actually build the cube if and when someone asks it something. You want a stand-in.
A stand-in with the same interface
A proxy is an object that sits in front of another object, the real subject, exposes the same interface, and controls access to it. Callers talk to the proxy believing it is the real thing. The proxy gets to decide what to do with each call: forward it, delay it, cache it, reject it, or ship it across a network.
The word means what it means everywhere else. A proxy votes on your behalf at a meeting you did not attend. It has the same rights you have in that room (the same interface), and nobody around the table needs to know you are not there. That is the entire idea.
That identical-interface part is the whole distinction between a proxy and its structural cousins, and it is worth pinning down before you build one.
The fix: a virtual proxy
Back to the cube. The version everyone imports should build nothing until the first real question arrives. In JavaScript you do not need a class or any framework for this. A closure over the same total method is the whole pattern:
// same interface as SalesCube: { total(q) }, but nothing heavy runs up front
export function lazyCube() {
let real = null;
const load = () => (real ??= new SalesCube()); // built once, on demand
return {
total(q) { return load().total(q); },
};
}
export const cube = lazyCube(); // cheap. no Postgres, no cube, nothing.
Import that and you pay for a closure and one null. The cube is built on the first total() call, exactly once (??= assigns only when real is still nullish), and never again. The health check imports it and stays fast because it never calls total. The dashboard calls total, eats the build cost the first time, and every call after that is instant.
This variety has a name. A virtual proxy stands in for an object that is expensive to create and defers that creation until someone actually needs it.
Virtual proxies are everywhere once you know the shape. <img loading="lazy"> is one baked into the browser: the element behaves like an image, but the bytes are not fetched until it scrolls near the viewport. A dynamic import() that pulls in a heavy editor only when the user clicks Edit is one. A database connection that is not opened until the first query runs is one. The stand-in is real from the caller’s side long before the expensive thing behind it exists.
Four kinds of stand-in
Virtual is the first of four classic varieties. They differ only in what the proxy does with the calls it intercepts.
A protection proxy checks a rule before it forwards. The real object never learns that permissions exist; that concern lives entirely in the stand-in, which is exactly where you want it.
// same interface as docs: { read, remove }, with an access gate in front
function guardedDocs(docs, user) {
return {
read(id) {
return docs.read(id); // reads are open
},
remove(id) {
if (!user.can("delete", id)) throw new ForbiddenError();
return docs.remove(id); // only forwarded if allowed
},
};
}
This is the pattern under a lot of authorization code. The handler holds guardedDocs, calls remove like normal, and the check is impossible to forget because there is no other way to reach the real remove.
A remote proxy stands in for an object that lives somewhere else: another process, another machine, someone else’s servers. An API client is the everyday example. When you write client.chat.completions.create(...) to call a model, that method call is a stand-in for an HTTP round trip to a model you will never hold in memory. The client is a proxy for a remote subject.
A caching (or memoising) proxy stores results and hands them back without touching the real subject:
// same interface as source: { get(id) }, with a TTL cache in front
function cached(source, ttlMs = 60_000) {
const store = new Map();
return {
async get(id) {
const hit = store.get(id);
if (hit && hit.at + ttlMs > Date.now()) return hit.value; // no round trip
const value = await source.get(id);
store.set(id, { value, at: Date.now() });
return value;
},
};
}
The caller sees the same get. It has no idea some answers never left the process. This is the shape of most caching layers: a stand-in that answers from memory when it can and forwards when it must.
Notice that none of these four needed a class, inheritance, or any special language feature. They are plain objects that happen to expose the same methods as the thing behind them. That is the proxy pattern in idiomatic JavaScript: a wrapper you could have written in 1999.
See it defer, then cache
Here is a virtual proxy and a caching proxy in one object. The real subject is slow to build (simulated). The proxy defers that build until your first question, then reuses it. Ask for Q1, watch it build. Ask for Q2, watch it answer instantly. Drop the cube and the delay comes back.
Click Q1 twice fast and read the log. The second click does not start a second build: cubePromise already exists, so it waits on the same in-flight promise. Memoising the promise rather than the value is what makes a lazy proxy safe under concurrent callers. It is a small detail that separates a demo from something you would ship.
Now the other Proxy
Everything above was the pattern, and not once did it need the thing JavaScript happens to call Proxy. That word collision trips up a lot of people, so let us be blunt about it.
Proxy is also a built-in object in the language:
const spied = new Proxy(target, {
get(obj, key, receiver) {
console.log("read", key);
return Reflect.get(obj, key, receiver);
},
});
new Proxy(target, handler) wraps target and lets you intercept fundamental operations on it, reads, writes, in, delete, function calls, through handler functions called traps. It is a real, powerful mechanism, and the full trap catalogue plus Reflect is its own topic. It gets a whole article of its own: Proxy and Reflect. This is not the place to re-teach the traps.
The point here is the relationship between the two, because it is not the one the shared name suggests.
Read that top to bottom. The pattern is the idea. You can build it as a plain wrapper object, as a closure, or with the Proxy object, and the first two are what you reach for most days. The Proxy object is a language feature that happens to be a great tool for implementing the pattern, but it is one option among several, and the pattern predates it by decades.
So when does the language feature actually earn its place over a hand-written wrapper?
When the interface is open-ended. A wrapper has to name every method: you write read, remove, total, one forwarding function each. That is fine for a small, known surface. But if you want to intercept any property access, including keys that do not exist yet, you cannot list them by hand. That is the job Proxy does that a wrapper cannot:
// forward and log EVERY property access, without naming a single one
function traced(target, label) {
return new Proxy(target, {
get(obj, key, receiver) {
const value = Reflect.get(obj, key, receiver);
if (typeof value === "function") {
return (...args) => {
console.log(label, key, args);
return value.apply(obj, args);
};
}
return value;
},
});
}
One trap covers the entire object, present and future. Generic instrumentation, mocking libraries, and reactive state all lean on exactly this: interception you could not hand-write because you do not know the keys in advance.
When a proxy is the wrong call
Patterns earn their keep by being used sparingly. A stand-in is indirection, and indirection has a cost you pay on every read of the code and sometimes on every call.
The latency did not vanish, it moved. A virtual proxy takes the build cost off startup and drops it on whichever unlucky request arrives first. Usually that is a good trade. Sometimes it means one user in a thousand waits nine hundred milliseconds for a call that is normally five, and it looks random in your traces because it is whoever showed up after the last deploy. If that first-call stall matters, warm the proxy on boot on purpose, or make the wait visible in the UI. Do not pretend the cost is gone.
A remote proxy that hides the boundary is a trap. This is the honest limit of “same interface”. A local method call cannot time out, cannot fail because a cable was unplugged, and returns its value immediately. A remote one can do all three. If your proxy makes user.name look like a plain field read when it is really a network fetch, callers will write code that assumes it is free and cheap, and it is neither.
The libraries that get this right refuse to lie. A worker RPC layer that proxies a background thread makes every proxied call return a promise, even for a function that is synchronous on the other side, precisely because it cannot pretend the boundary is not there. That is the correct instinct: when the interface genuinely cannot stay identical, surface the difference instead of burying it.
The Proxy object has real edges. Every trapped access runs your trap function, so wrapping a hot path that does millions of reads has a measurable cost a plain object does not. It cannot see #private fields, and it breaks identity for some built-ins with internal slots, so a proxied Map or Date throws the moment a method touches its internal state. Those limits are covered in Proxy and Reflect; the short version is that it is not a free transparent skin over everything.
Over-wrapping buries the real call. A proxy around a proxy around a proxy is easy to reach one refactor at a time, and then a stack trace runs through four wrappers before it touches the object that did anything. If you cannot name the single concern each layer owns, that is the signal to collapse them back into one.
The test is the same as for any indirection. If the stand-in controls access in a way the caller genuinely should not have to think about (build it lazily, check the permission, cache the answer, reach the remote thing), it earns its keep. If it is just a passthrough that forwards every call unchanged, you built a layer that does nothing but cost you a hop, and you should delete it.
Summary
- A proxy is a stand-in that exposes the same interface as a real object and controls access to it. The caller cannot tell them apart. That identical interface is what separates it from an adapter (changes the interface), a facade (simplifies a subsystem), and a decorator (adds behaviour rather than gating access).
- The four classic kinds differ only in what the proxy does before it forwards: virtual builds an expensive subject lazily, protection checks a permission, remote reaches an object across a boundary, and caching answers from a store.
- In JavaScript the pattern is usually a plain wrapper object or a closure, not a class and not a framework. A virtual proxy is
let real = null; ... real ??= build(). - The
Proxyobject (new Proxy(target, handler)) is a language mechanism with traps. It is one way to build the pattern, not the pattern itself, and it earns its place when the interface is open-ended and you cannot name every property by hand. Its API lives in Proxy and Reflect. - Reactive frameworks build the observer pattern out of the
Proxyobject. The object is a low-level tool several patterns share; the proxy pattern just happens to share its name. - When to use: you want to control access to an object (lazily create it, gate it, cache it, or reach it remotely) without the caller knowing.
- When to avoid: the proxy would just forward every call unchanged; a remote proxy would hide that calls are now async and can fail; the
Proxyobject would sit on a hot path or wrap a built-in with internal slots; or you are three wrappers deep and can no longer name what each one controls.