The Prickly Case of JavaScript Proxies

EisenbergEffect
13 min readMay 29, 2023

ES2015 introduced a ton of new features to the JavaScript programming language. One of those features was Proxy. At first glance, Proxy seems like a boon for library authors, especially those working on unobtrusive reactivity engines for front-end development. Unfortunately, proxies come with a number of limitations. Some would call them “design flaws”. In the very least, there are some prickly areas that we should all be aware of.

Let’s take a bit of time to look at the Proxy language feature, understand how to use it properly, and then dig a bit deeper into the lesser-known areas that may cause you no small amount of pain.

Proxy Purpose

According to the original Gang of Four Design Patterns book, the intent of a Proxy is to “Provide a surrogate or placeholder for another object to control access to it.” JavaScript, via the Proxy type, codifies this pattern as part of the core programming language.

Why would you want an object to simply take the place of another object or to function as a surrogate though? There are a few scenarios where this can be useful.

Remote Proxy

The real object exists in a different memory space, but you want to interact with it as if it were local. You might create a proxy to a remote service or to an object that exists on a p2p network, abstracting away all the networking code, and presenting a simpler object interface. Another common scenario in JavaScript occurs when you want to run some code in a worker thread or in WASM but prefer to interact with it as if it were in the same thread or vice versa. PartyTown is a fantastic example of using this technique to offload scripts to workers.

Protection Proxy

The object needs to be used by a third party, but you need to control access and dynamically grant/revoke privileges. For example, you may be creating a payment API that 3rd parties use in the browser, but you want to limit access to the APIs and completely revoke the object outside of the scope of the transaction process. This use of a Proxy is typically called a Membrane and there are some mature libraries for using proxies in this way.

Virtual Proxy

You want to carefully control the creation and/or destruction of critical resources. For example, perhaps creating an object, such as a DB connection, is very expensive. You may want to provide a connection proxy to the developer so you can delay the actual connection until the first API that requires it is executed. Or perhaps you have a pool of connections that you reuse, so you want to provide the next available pooled connection regardless of how many proxies have been handed out.

Smart Reference

You want to augment an existing object with new functionality, but without altering the original. For example, you may want to log all interactions with the object as part of a debugging process. Perhaps you are creating a reactivity system and you want to track all reads and writes of properties so a view engine can be notified when it needs to update associated DOM.

These are the most common proxy types, for which there are many variations. In each case, the Proxy is a layer of code that looks like the Subject but stands in between the user and the Real Subject, hereafter referred to as the Target.

Proxy in Practice

With the explanation out of the way, let’s create a simple JavaScript proxy. The easiest way to understand how the platform works is to start with a smart reference proxy that logs interactions with the underlying target. For our examples, we’re going to be using an instance of a simple Person class as our target.

person.js

export class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}

get fullName() {
return `${this.firstName} ${this.lastName}`;
}

introduceYourselfTo(other = "friend") {
console.log(`Hello ${other}! My name is ${this.fullName}.`)
}
}

Let’s create a basic proxy to intercept all property access and log it to the console.

person-proxy.js

import { Person } from "./person.js";

const john = new Person("John", "Doe");

const proxy = new Proxy(john, {
get(target, property) {
console.log(`Access: "${property}"`);
return Reflect.get(target, property);
}
});

proxy.introduceYourselfTo("Jane");

To setup the Proxy, we instantiate a Proxy instance, providing our target as the first argument and the “traps” as the second argument.

The “traps” are hooks into the language runtime that let you intercept interactions with the target. In the example above, we have provided an implementation of the get trap, which will do two things:

  1. First, the implementation will log the object key that is being retrieved. The trap provides the key as the second argument.
  2. Since we still want the object to function properly, we use the Reflect API to get the property value from the target’s “internal slot”, which is then returned from the trap.

IMPORTANT: All JavaScript objects store their data in internal slots, which are not directly accessible from code. The Reflect API provides a way to invoke internal runtime methods which are able to interact with the internal slots of objects.

What do you think the console will display? Let’s have a look…

Access: "introduceYourselfTo" 
Access: "fullName"
Hello Jane! My name is John Doe.

When working with proxies, it’s important to remember the details of how JavaScript objects work. When invoking a method, the runtime must first “get” the method on the object. This is why we see the first log statement show “introduceYourselfTo”. Then when that method is applied to the proxy, the runtime will “get” the “fullName” as part of executing the method.

But why aren’t we seeing “firstName” and “lastName”? Afterall, the implementation of fullName accesses those properties, doesn’t it?

Understanding this requires digging a bit deeper into what’s happening in the JavaScript runtime.

Above, I mentioned that “introduceYourselfTo” is first retrieved by the runtime through the “get” trap on the proxy. Next, the method is applied to the proxy, since we wrote proxy.introduceYourselfTo("Jane"). Since this is the proxy inside the method due to how it was applied, the runtime then gets fullName through the proxy, triggering the “get” trap again.

Here’s where it gets interesting.

When we use Reflect.get(target, property) the runtime is going to access the internal slot of fullName. Because fullName is a property, it will call get on the property descriptor. This get will be applied against the target that was passed to Reflect. Because the this of thefullName getter is thetarget and not the proxy, our trap can’t intercept the getters for firstName and lastName.

So, how do we fix this if we want to intercept everything? Your first thought might be to pass the proxy itself to Reflect.get instead of the target, like this:

const proxy = new Proxy(john, {
get(target, property) {
console.log(`Access: "${property}"`);
return Reflect.get(proxy, property);
}
});

DO NOT DO THIS.

This will cause an infinite loop. Reflect will try to get your property value through the proxy, which will invoke your trap again for the same property, which will try to get your property value through the proxy, which will…

What we need is a way to tell Reflect which object to access the internal slot on. But after it retrieves the property from the internal slot, we want to run that property’s getter against the proxy instead.

To accomplish this, we need to use the third argument of the trap with the third parameter of Reflect, called the receiver. Here’s an updated version of our trap code that shows how we can correctly handle this situation.

person-proxy-with-receiver.js

import { Person } from "./person.js";

const john = new Person("John", "Doe");

const proxy = new Proxy(john, {
get(target, property, receiver) {
console.log(`Access: "${property}"`);
return Reflect.get(target, property, receiver);
}
});

proxy.introduceYourselfTo("Jane");

With this code, we should see the following output:

Access: "introduceYourselfTo" 
Access: "fullName"
Access: "firstName"
Access: "lastName"
Hello Jane! My name is John Doe.

Provided Traps

So far, we’ve only looked at one trap: get. But there are a wide variety of traps provided, one for each internal runtime method. It is through these special runtime methods that many of the special features of objects like Array are implemented in the platform. These are referred to as “exotic objects”. By leveraging the breadth of traps available to proxies, developers can create their own exotic objects or customize the behavior of existing objects in countless ways.

Here is a list of all the traps that the runtime provides:

  • get(target, property, receiver)— Invoked when the runtime needs to get the value of an object key. Not implementing this trap will result in the default behavior provided by Reflect.get(...) corresponding to the [[Get]] internal method.
  • set(target, property, value, receiver) — Invoked when the runtime needs to set the value of an object key. Not implementing this trap will result in the default behavior provided by Reflect.set(...) corresponding to the [[Set]] internal method. The trap implementation must return a boolean value indicating whether or not the property was set.
  • deleteProperty(target, property) — Invoked when the delete operator or Reflect.deleteProperty(...) is used on an object key. This corresponds to the [[Delete]] internal method. The trap implementation must return a boolean value indicating whether or not the property was deleted.
  • ownKeys(target) — Invoked when the runtime needs to get the own keys of an object, such as when the following APIs are used: Object.keys(...), Reflect.ownKeys(...), Object.getOwnPropertyNames(...), and Object.getOwnPropertySymbols(...). This corresponds to the [[OwnPropertyKeys]] internal method.
  • getOwnPropertyDescriptor(target, property) — Invoked when the runtime needs to get an own property descriptor such as when Object.getOwnPropertyDescriptor(...) or Reflect.getOwnPropertyDescriptor(...) are used. This corresponds to the [[GetOwnProperty]] internal method.
  • hasProperty(target, property) — Invoked when the runtime needs to determine if an object has a property, such as when the in or with operators are used, or the Reflect.has(...) API. This corresponds to the [[HasProperty]] internal method.
  • getPrototypeOf(target) — Invoked when the runtime needs to lookup the prototype via Object.getPrototypeOf(...), Reflect.getPrototypeOf(...), __proto__, Object.prototype.isPrototypeOf(...), or instanceof. This corresponds to the [[GetPrototypeOf]] internal method.
  • setPrototypeOf(target, prototype) — Invoked whenever the runtime needs to set the prototype of an object via Object.setPrototypeOf(...) or Reflect.setPrototypeOf(...). This corresponds to the [[SetPrototypeOf]] internal method.
  • isExtensible(target) — Invoked when the runtime needs to check whether the object is extensible via Object.isExtensible(...) or Reflect.isExtensible(...). This corresponds to the [[IsExtensible]] internal method.
  • preventExtensions(target) — Invoked when any of the following APIs are called: Object.preventExtensions(...), Reflect.preventExtensions(...), Object.seal(...), or Object.freeze(...). This corresponds to the [[PreventExtensions]] internal method. The API must return a boolean value indicating whether extensions were successfully disabled.
  • apply(target, thisArg, args) — When the target is a function, this trap will be called when the function is invoked or via Function#apply, Function#call, or Reflect.apply(...). This corresponds to the [[Call]] internal method. Note: This hook cannot be used to make non-callable objects behave like callable objects.
  • construct(target, args, newTarget) — When the target is a class constructor or function, this trap will be called when it is used with the new operator or via Reflect.construct(...). This corresponds to the [[Construct]] internal method. The trap must return an object.

Protection Proxies

JavaScript provides special capabilities for creating Protection Proxies via the Proxy.revocable(...) API. This type of proxy can be disabled by the creator of the proxy so that all consumers that still hold a reference are blocked by the runtime from accessing the object. Here’s a revocable demonstration of our person.

import { Person } from "./person.js";

const john = new Person("John", "Doe");

const { proxy, revoke } = Proxy.revocable(john, {
get(target, property, receiver) {
console.log(`Access: "${property}"`);
return Reflect.get(target, property, receiver);
}
});

proxy.introduceYourselfTo("Jane");

revoke();

proxy.introduceYourselfTo("Bad Guy");

Running this code will produce the following output.

Access: "introduceYourselfTo" 
Access: "fullName"
Access: "firstName"
Access: "lastName"
Hello Jane! My name is John Doe.
Uncaught TypeError: Cannot perform 'get' on a proxy that has been revoked

The revocable API returns the proxy and a function to revoke it. Be sure to hang on to that with great care. If you expose the revoke function to consumers, you have opened up an attack vector. Also, beware of memory leaks when holding on to the revoker. To work around this, consider storing the revoker in a WeakMap with the proxy as key. Then store this WeakMap either as a private field or a private module-level constant.

Proxy Prickles

So far, this all sounds primo. Your mind is off to the races and you’re thinking of all sorts of cool ways to use proxies.

Not so fast.

It’s not all roses…or if it is, these are some darn prickly roses at that. Let’s take a look at a few areas that proxies are likely to cause you some pain or consternation.

Performance

Proxies aren’t a zero-cost feature. While the creation of proxies is fast, invocation of getter and setter traps were 5%-20% slower than raw access in my tests. You’ll want to keep this in mind if you are building systems where proxies are used in hot code paths.

Private Members

Unfortunately, you can’t safely use proxies on objects that have private members. To see what happens, let’s take our original Person class and modify it to use private backing fields for its properties.

private-person.js

export class Person {
#firstName;
#lastName;

constructor(firstName, lastName) {
this.#firstName = firstName;
this.#lastName = lastName;
}

get firstName() {
return this.#firstName;
}

get lastName() {
return this.#lastName;
}

get fullName() {
return `${this.firstName} ${this.lastName}`;
}

introduceYourselfTo(other = "friend") {
console.log(`Hello ${other}! My name is ${this.fullName}.`)
}
}

Now, let’s use this with our first proxy trap implementation.

private-person-proxy.js

import { Person } from "./private-person.js";

const john = new Person("John", "Doe");

const proxy = new Proxy(john, {
get(target, property) {
console.log(`Access: "${property}"`);
return Reflect.get(target, property);
}
});

proxy.introduceYourselfTo("Jane");

We get the following output:

Access: "introduceYourselfTo" 
Access: "fullName"
Hello Jane! My name is John Doe.

That seems good. It’s the same as before. What if we try it with our second proxy implementation, the one that uses the receiver?

private-person-proxy-with-receiver.js

import { Person } from "./private-person.js";

const john = new Person("John", "Doe");

const proxy = new Proxy(john, {
get(target, property, receiver) {
console.log(`Access: "${property}"`);
return Reflect.get(target, property, receiver);
}
});

proxy.introduceYourselfTo("Jane");

We get this output:

Access: "introduceYourselfTo" 
Access: "fullName"
Access: "firstName"
Uncaught TypeError: Cannot read private member #firstName
from an object whose class did not declare it

Uh oh. What happened?

Remember back to our explanation of how internal slots are accessed with Reflect.get(...)? Well, when we provide the receiver, the firstName getter is going to have the proxy as this when it’s invoked. That getter uses a private property, which the JavaScript runtime will not allow access to on anything other than this. As a result, we have a runtime error.

This is a pretty big problem for JavaScript proxies. This means that you can’t safely use proxies on any object that you don’t control and can verify the implementation of. Any object could be using private members, and depending on how the proxy is written, combined with the specific internals of the object, the proxy usage may invoke a code path that causes an error.

Notice how we didn’t really change the public API of our Person class, only the internal implementation details. This means that a 3rd party library could release a new package under a patch version, with no breaking changes to the API, but where a single private field or method was introduced as an internal improvement, and your app could begin to fail at runtime.

For these reasons, you will need to be very careful when using proxies or when handing off objects to other libraries that use proxies.

This is not an esoteric or rare issue and will potentially affect a lot of people as private members begin to be adopted more broadly. A highly relevant example of where this is a problem is the popular Vue.js library. Vue.js uses proxies to enable its reactivity system, and it must use receivers in order to track all property access. This means you can’t use Vue.js with objects that have private members. This is particularly troublesome if the objects you are trying to render are built by another team or come from another library that you don’t control.

ASIDE: Myself and a number of other community members pleaded with TC39 for months to fix this issue as well as the Map issue (below). Unfortunately, our requests were rejected, and now JavaScript is stuck with this. The only solution at this point would be to create a new type of proxy that works correctly in these situations, often referred to as a TransparentProxy. Sadly, TC39 rejected that proposal as well. Maybe we can re-open it again someday.

One final thought on this topic. Recall that all objects store their data in private slots. This includes built-in exotic objects, such as Array. So, you should exercise the same care when proxying these types, since you may run into similar issues trying to access private exotic object slots through a Proxy.

Putting Keys in Maps

Because proxies aren’t transparent, equality checks will fail between the target and a proxy of the target. Give this a try:

const original = {};
const proxy = new Proxy(original, {});

console.assert(
proxy == original,
"The original and the proxy are equal."
);

console.assert(
proxy === original,
"The original and the proxy are strictly equal."
);

You will see output that looks like this:

Assertion failed: The original and the proxy are equal.
Assertion failed: The original and the proxy are strictly equal.

This presents other problems, most notable using proxies as keys in maps. Try this out.

const original = {};
const proxy = new Proxy(original, {});
const map = new Map();

map.set(original, "original value");
map.set(proxy, "proxy value");

console.log(map.get(original));
console.log(map.get(proxy));

The results show that there are two entries in the map instead of one:

original value 
proxy value

Obviously, this can cause all sorts of problems if objects/proxies are coming from 3rd party libraries or in large codebases where you cannot easily trace the lineage of an object. Be careful!

Peroration

Thanks for sticking with me through this exploration of the JavaScript Proxy. It is a powerful language feature that needs to be handled not only with great care, but with a knowledge of how the underlying runtime works. Use them wisely, and you can accomplish some amazing things.

If you enjoyed this look into the JavaScript Proxy, you might want to check out my Web Component Engineering course. I’d also love it if you would subscribe to this blog, subscribe to my YouTube channel, or follow me on twitter. Your support greatly helps me continue writing and bringing this kind of content to the broader community. Thank you!

--

--

EisenbergEffect

W3C, WICG & TC39 contributor focused on Web Standards, UI Architecture & Engineering Culture. Former Technical Fellow, Principal Architect & VP-level tech lead.