A deep dive into the proxy pattern.
Proxy Pattern is centered around an object standing in for another object. Instead of accessing the real object directly, we interact with a proxy — a special object that controls, modifies, or monitors how we interact with the real one. A analogy would be a personal assistant, you don’t talk to the CEO directly but you pass messages through the assistant (proxy) then the assistant decides whether to pass the message along, block it, or even change it before delivering. In this article we will use Javascript Proxy object introducted in ES6, which makes this pattern far more powerful and flexible.
Proxy object in Javascript
A proxy takes two things:
- target – the real object we want to wrap.
- Handler – an object that defines traps (functions that intercept operations like get, set and deleteProperty) as shown in the below code.
// Define the target object
const target = { secret: 42 };
// Define the handler with traps
const handler = {
get: (obj, prop, receiver) => {
if (prop === "secret") {
throw new Error("Access denied to secret");
}
// Reflect is a built-in helper to safely forward the call
return Reflect.get(obj, prop, receiver);
}
};
// Create the proxy
const proxy = new Proxy(target, handler);
console.log(proxy.secret); // Error: Access denied
console.log(proxy.nonExistent); // undefined
But wait what is Reflect? Reflect in JavaScript is a built-in object that provides a set of low-level methods mirroring how operations normally work on objects (like getting, setting, or deleting properties). In our proxy example, Reflect.get(obj, prop, receiver) is crucial because it safely forwards the property access to the actual target object after the proxy finishes its own checks. Without it, we’d have to manually write obj[prop], which ignores important details like property descriptors, this binding, and inheritance chains. By using Reflect, the proxy’s handler doesn’t need to re-implement the complex semantics of property access — it simply enforces its custom rule (if (prop === "secret")) and then relies on Reflect to perform the “normal” get operation in a consistent, spec-compliant way. This ensures the receiver context is preserved and the proxy behaves predictably like the original object whenever access isn’t blocked.
Let us look at some use cases of the proxy pattern in Javascript
1. Virtual Proxy (Lazy Initialization)
Proxies can be used to delay object creation until absolutely necessary. This is useful especially for objects that are costly such as database connections.
function createLazyDBConnection() {
let realConnection = null; // The heavy object is not created yet
return new Proxy({}, {
get: (_, prop) => {
// Only create the connection on first access
if (!realConnection) {
console.log("Establishing DB connection...");
realConnection = { query: sql => `Result for: ${sql}` };
}
return realConnection[prop];
}
});
}
const db = createLazyDBConnection();
console.log("Proxy created, no connection yet.");
console.log(db.query("SELECT * FROM users")); // Connection established here
The above code defines a lazy database connection proxy: instead of immediately creating a heavy database connection when createLazyDBConnection() is called, it returns a Proxy object that defers the creation until the first time a property (like query) is accessed. At that moment, the proxy initializes the real connection (realConnection) and forwards the call. This is important because it saves resources by delaying expensive operations until they are actually needed — for example, if a program starts but never queries the database, no connection is ever established. This technique, called lazy initialization, helps optimize performance and resource usage in real-world systems.
2. Access Control (Protection Proxy)
Here we use Protection Proxy, to control access to certain parts of an object.
function secureObject(target, role) {
return new Proxy(target, {
get: (obj, prop) => {
// Block private properties unless you're admin
if (prop.startsWith("_") && role !== "admin") {
throw new Error("Unauthorized access");
}
return obj[prop];
}
});
}
const account = secureObject({ balance: 500, _pin: 1234 }, "user");
console.log(account.balance); // 500
console.log(account._pin); // Error: Unauthorized
In the above code secureObject function takes a target object (e.g., an account { balance: 500, _pin: 1234 }), then takes a role (e.g., "user" or "admin") then returns a proxy around the target.The Proxy handler (get trap) runs whenever a property is accessed on the proxy if the property name starts with "_" (e.g., _pin), the code treats it as private/sensitive data. Only "admin" role can see these while other role gets an Unauthorized access error. Note that normal properties like balance are accessible to everyone while sensitive properties (like _pin) are restricted.
3. Remote proxy
In the code below the apiProxy function creates a Proxy object that acts as a fake local API client.When you try to access a property on the proxy (like getUser), the proxy traps that property and turns it into a function.When you call that function, it automatically makes a POST request to the backend at endpoint/prop. Accessing userAPI.getUser(42) becomes a call to POST /api/users/getUser with [42] as the request body hereby we don’t need to manually define every API method (getUser, createUser, deleteUser, etc.). The proxy creates them dynamically on demand.
// Define a function that builds a Proxy for a given API endpoint
function apiProxy(endpoint) {
return new Proxy({}, {
// Trap property access on the proxy
// e.g., accessing userAPI.getUser will hit this "get" trap
get: (_, prop) => async (...args) => {
// Each property (like getUser, createUser) becomes an API call
// Make a POST request to the API endpoint + property name
// Example: endpoint = "/api/users", prop = "getUser"
// => "/api/users/getUser"
const res = await fetch(`${endpoint}/${prop}`, {
method: "POST", // All calls use POST
body: JSON.stringify(args), // Send arguments as JSON
headers: { "Content-Type": "application/json" }
});
// Return the parsed JSON response
return res.json();
}
});
}
// Create an API proxy for the "/api/users" endpoint
const userAPI = apiProxy("/api/users");
// Now you can call methods as if they exist locally
userAPI.getUser(42).then(console.log);
// Behind the scenes we make a POST request to "/api/users/getUser"with the body: [42]
// then logs the JSON response to the console.
4. Logging & Profiling Proxy
In the code below monitor creates a wrapper (proxy) around any object so that when you call a method on that object, the proxy intercepts the call. Instead of calling the method directly, it:
- Starts a timer.
- Calls the original method.
- Stops the timer and logs how long it took. This way, we get performance logging for free without modifying the original object.
// The monitor function takes an object and returns a Proxy around it
function monitor(obj) {
return new Proxy(obj, {
// Trap for when a property is accessed (e.g., obj.add or obj.mul)
get: (target, prop, receiver) => {
// Safely get the property using Reflect
// Reflect.get ensures normal "this" and inheritance behavior is preserved
const value = Reflect.get(target, prop, receiver);
// If the property is a function (like add or mul)...
if (typeof value === "function") {
// Return a wrapped version of the function
return function (...args) {
console.time(prop); // Start timing with the property name as the label
const result = value.apply(this, args); // Call the original function with arguments
console.timeEnd(prop); // Stop timing and log duration
return result; // Return the function’s result to the caller
};
}
// If the property is not a function (like a number/string), just return it normally
return value;
}
});
}
// Example object with math operations
const math = monitor({
add: (a, b) => a + b,
mul: (a, b) => a * b
});
// When you call math.add, the proxy intercepts it,
// times the execution, and logs how long it took.
math.add(10, 20);
// Output in console: add: <time taken in ms>
5. Lets create a rate limiter to protect resources from abuse
In the code below we create a wrapper (proxy) around the user object so that whenever sendMessage is called, the proxy intercepts and checks how many times the method has been called within the last second (calls) if it’s within the limit - the message is sent, if the user exceeds the limit - an error is thrown instead of sending.The setInterval resets the counter every second, so the user can send messages again in the next second.
// Function that takes a user object and a limit (default is 3 messages per second)
function rateLimitedUser(user, limit = 3) {
let calls = 0; // keeps track of how many times sendMessage has been called
// Every 1 second, reset the calls back to 0
setInterval(() => (calls = 0), 1000);
// Return a Proxy that wraps the user object
return new Proxy(user, {
// The 'get' trap intercepts property access (e.g., user.sendMessage)
get: (obj, prop) => {
// If the property being accessed is 'sendMessage'...
if (prop === "sendMessage") {
// ...return a new function that enforces rate limiting
return function (...args) {
// If the number of calls has reached the limit, throw an error
if (calls >= limit) {
throw new Error("Rate limit exceeded. Try again later.");
}
// Otherwise, increment the call counter
calls++;
// And forward the call to the real method on the user object
return obj[prop](...args);
};
}
// For all other properties, just return them normally
return obj[prop];
}
});
}
// Example user object with a sendMessage method
const user = {
sendMessage: msg => console.log("📨 Message:", msg)
};
// Create a rate-limited version of the user: max 2 messages per second
const limitedUser = rateLimitedUser(user, 2);
// Try sending messages
limitedUser.sendMessage("Hello 1"); // Works
limitedUser.sendMessage("Hello 2"); // Works
limitedUser.sendMessage("Hello 3"); // Error: Rate limit exceeded
6. Some tips to using Proxy pattern
- Performance overhead Every property access through a proxy goes through a trap function, in direct access - obj.value (fast)proxy access - goes through handler then its forwarded (slower). At scale (millions of operations), this matters. Use proxies for logic boundaries, not tight loops. Check the code below
const obj = { value: 10 };
const proxy = new Proxy(obj, { get: (o, p) => Reflect.get(o, p) });
console.time("Direct");
for (let i = 0; i < 1e6; i++) obj.value;
console.timeEnd("Direct");
console.time("Proxy");
for (let i = 0; i < 1e6; i++) proxy.value;
console.timeEnd("Proxy");
Non-transparent Behavior Some built-in optimizations are lost with proxies:
Object.keys(proxy) may not behave the same.
Internal V8 optimizations can be disabled. If you override traps like ownKeys or getOwnPropertyDescriptor, you need to replicate normal behavior carefully.
Memory Leaks via Closures If proxy lazily creates heavy objects (like DB connections), but the proxy itself lives long, you risk holding onto memory forever, so its good to use weak references or explicit cleanup strategies.
Security Illusion Proxies can hide or block runtime access, but don’t mistake this for real security.
In browsers, devtools can still inspect objects.
On the backend, minified code can be reverse-engineered. Use proxies for guardrails, not as your only security layer.
javascript book
If this interested you, check out my Javascript Book