· tips · 8 min read
The Invisible Observer: Using Proxies for Debugging JavaScript Applications
Learn how to use JavaScript Proxies to build an invisible observer that logs property reads/writes, method calls, and object construction-plus practical utilities, pitfalls, and best practices for debugging complex applications.

Why an “Invisible Observer”?
Debugging complex JavaScript applications often means hunting for where state changes or function calls originate. You could sprinkle console.log all over the codebase-but that quickly becomes noisy, invasive, and hard to maintain.
Enter the JavaScript Proxy: a lightweight mechanism to observe (and optionally intercept) operations on objects and functions without modifying the original code. Think of it as a transparent wrapper that watches operations and reports them: an invisible observer.
This article explains how Proxies work, shows practical patterns to log property access, assignments and function calls, and describes important caveats and best practices to use proxies for debugging efficiently.
Quick refresher: what is a Proxy?
A Proxy wraps a target (object or function) and intercepts operations through handler traps. The handler exposes a set of traps such as get
, set
, apply
(for function calls), construct
(for new
) and many more.
Use Reflect
inside traps to forward default behavior safely.
- MDN reference for Proxy: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
- MDN reference for Reflect: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect
Minimal example: logging property reads and writes
This proxy logs reads and writes using get
and set
traps.
function createSimpleObserver(target) {
return new Proxy(target, {
get(obj, prop, receiver) {
const value = Reflect.get(obj, prop, receiver);
console.log(`[GET] ${String(prop)} ->`, value);
return value;
},
set(obj, prop, value, receiver) {
console.log(`[SET] ${String(prop)} <-`, value);
return Reflect.set(obj, prop, value, receiver);
},
});
}
const user = createSimpleObserver({ name: 'Ava' });
console.log(user.name); // logs GET then prints 'Ava'
user.age = 30; // logs SET
This is already useful, but real apps need more detail: the property path, nested objects, collection operations, and function calls.
Observing nested objects and arrays
A naive Proxy only observes the immediate target. To observe nested structures you must wrap nested objects as well. Use a WeakMap to avoid double-wrapping and to allow garbage collection.
const proxyCache = new WeakMap();
function isObject(x) {
return x !== null && (typeof x === 'object' || typeof x === 'function');
}
function createObserver(target, path = '') {
if (!isObject(target)) return target;
if (proxyCache.has(target)) return proxyCache.get(target);
const handler = {
get(obj, prop, receiver) {
const nextPath = path ? `${path}.${String(prop)}` : String(prop);
const value = Reflect.get(obj, prop, receiver);
console.log(`[GET] ${nextPath} ->`, value);
return isObject(value) ? createObserver(value, nextPath) : value;
},
set(obj, prop, value, receiver) {
const nextPath = path ? `${path}.${String(prop)}` : String(prop);
console.log(`[SET] ${nextPath} <-`, value);
return Reflect.set(obj, prop, value, receiver);
},
deleteProperty(obj, prop) {
const nextPath = path ? `${path}.${String(prop)}` : String(prop);
console.log(`[DELETE] ${nextPath}`);
return Reflect.deleteProperty(obj, prop);
},
};
const proxy = new Proxy(target, handler);
proxyCache.set(target, proxy);
return proxy;
}
const state = createObserver({ user: { name: 'Ava', tags: ['dev'] } });
state.user.name; // logs GET user -> { ... } then GET user.name -> 'Ava'
state.user.tags.push('js'); // will log the get of tags and then push call (see function-trap example)
Note: array methods mutate arrays by calling internal operations that eventually go through property sets (e.g., length
) and index writes. To capture method calls like push
as calls, we need to use a function-proxy or inspect the method invocation.
Observing function calls and class construction
To observe function invocations you can wrap functions with a Proxy that implements the apply
trap. For constructors (calls with new
) use construct
.
function observeFunction(fn, name = fn.name || '<anonymous>') {
return new Proxy(fn, {
apply(target, thisArg, args) {
console.log(
`[CALL] ${name}(${args.map(a => JSON.stringify(a)).join(', ')})`
);
const result = Reflect.apply(target, thisArg, args);
console.log(`[RETURN] ${name} ->`, result);
return result;
},
construct(target, args, newTarget) {
console.log(
`[NEW] ${name}(${args.map(a => JSON.stringify(a)).join(', ')})`
);
return Reflect.construct(target, args, newTarget);
},
});
}
function sum(a, b) {
return a + b;
}
const observedSum = observeFunction(sum, 'sum');
observedSum(2, 3); // logs CALL and RETURN
class Worker {
constructor(id) {
this.id = id;
}
}
const ObservedWorker = observeFunction(Worker, 'Worker');
new ObservedWorker(42); // logs NEW
This is particularly helpful to detect unexpected function invocations or to trace how frequently functions are called.
A combined utility: createInvisibleObserver
Below is a more featureful observer that:
- Recursively wraps nested objects
- Observes property gets, sets, deletes
- Observes function calls (methods and standalone functions)
- Keeps a WeakMap cache to avoid double wrapping
- Emits structured log records (you can replace console.log with a remote logger)
function makeObserver({ logger = console.log } = {}) {
const cache = new WeakMap();
function wrap(target, path = '') {
if (!isObject(target)) return target;
if (cache.has(target)) return cache.get(target);
const handler = {
get(obj, prop, receiver) {
const nextPath = path ? `${path}.${String(prop)}` : String(prop);
let value = Reflect.get(obj, prop, receiver);
// If the property is a function, wrap it so we can log invocations
if (typeof value === 'function') {
value = wrapFunction(value, nextPath);
}
logger({ type: 'get', path: nextPath, value });
return isObject(value) ? wrap(value, nextPath) : value;
},
set(obj, prop, value, receiver) {
const nextPath = path ? `${path}.${String(prop)}` : String(prop);
logger({ type: 'set', path: nextPath, value });
return Reflect.set(obj, prop, value, receiver);
},
deleteProperty(obj, prop) {
const nextPath = path ? `${path}.${String(prop)}` : String(prop);
logger({ type: 'delete', path: nextPath });
return Reflect.deleteProperty(obj, prop);
},
};
const p = new Proxy(target, handler);
cache.set(target, p);
return p;
}
function wrapFunction(fn, path) {
if (cache.has(fn)) return cache.get(fn);
const proxy = new Proxy(fn, {
apply(target, thisArg, args) {
logger({ type: 'call', path, args, thisArg });
const res = Reflect.apply(target, thisArg, args);
logger({ type: 'return', path, result: res });
return isObject(res) ? wrap(res, `${path}()`) : res;
},
construct(target, args, newTarget) {
logger({ type: 'construct', path, args });
const instance = Reflect.construct(target, args, newTarget);
return wrap(instance, `${path}#instance`);
},
});
cache.set(fn, proxy);
return proxy;
}
return { observe: wrap };
}
const observer = makeObserver({ logger: msg => console.info('[OBS]', msg) });
const app = observer.observe({ api: { fetchUser: id => ({ id }) } });
app.api.fetchUser(7); // logs call and return
You can adapt logger
to send structured logs to a remote endpoint (WebSocket, HTTP), or integrate with your existing telemetry system.
Useful debugging patterns
- Filter logs: allow a predicate to skip logging of noisy keys (e.g.,
length
, internal symbols). - Path-aware logs: include a timestamp and a trace of the path causing the change.
- Batching: group frequent updates (e.g., array splices) into a single log event via debounce.
- Silent mode: set a
silent
option to temporarily suppress logs for known operations. - Diffing: when logging sets, include the previous value to highlight changes.
Example: logging previous value
set(obj, prop, value, receiver) {
const prev = Reflect.get(obj, prop, receiver);
logger({ type: 'set', path: nextPath, from: prev, to: value });
return Reflect.set(obj, prop, value, receiver);
}
Pitfalls and limitations
- Non-configurable properties: Proxy invariants require certain behavior for non-configurable properties and extensibility. If you override behavior in a way that breaks invariants, you’ll get a TypeError. See MDN for details.
- Identity: Proxies are a different object identity than their target.
proxy === target
is false. This can confuse code that relies on strict equality. - Performance: Wrapping many objects or high-frequency traps (like
get
on hot loops) can add measurable overhead. Use selective or filtered observation for performance-critical code. - Built-ins & host objects: Some native objects and host APIs might not behave as normal objects. Proxies around DOM nodes, certain host objects, or typed arrays can behave unexpectedly.
- Symbol-keyed properties: Code that uses symbol keys (including internal symbols) can be noisy or require special handling.
- Prototype chain: Get/set traps are involved with prototype lookups; ensure you use
Reflect.get
withreceiver
to preservethis
semantics.
Read MDN’s Proxy caveats: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy#description
Memory management and cleanup
Because observers often store per-target state (e.g., caches or listener lists), prefer WeakMap for caches so that wrapping doesn’t prevent garbage collection. If you need to stop observing explicitly, consider Proxy.revocable
to produce a revoke()
function:
const { proxy, revoke } = Proxy.revocable(target, handler);
// when done
revoke();
MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/revocable
When to use Proxies vs. other debugging tools
- Use Proxies when you want lightweight, localized observability without changing many call sites.
- For general runtime profiling or long-term telemetry, integrate instrumentation with profiling tools or APM products-Proxies are best for targeted debugging or development-only observability.
- For UI reactivity, frameworks (Vue 3) use Proxies to implement reactivity; you can borrow patterns but avoid coupling debugging proxies with production reactive subscriptions.
Example: remote logging via WebSocket (practical)
Imagine you need to observe a client app running on a user’s machine and stream events to your debug server. Replace logger
with a function that sends structured JSON over WebSocket.
const ws = new WebSocket('wss://debug.example.com');
function remoteLogger(msg) {
if (ws.readyState === WebSocket.OPEN)
ws.send(JSON.stringify({ ts: Date.now(), ...msg }));
}
const observer = makeObserver({ logger: remoteLogger });
const obj = observer.observe({ x: 1 });
obj.x = 2; // event sent to server
Be careful with PII and large objects-redact or limit payload size.
Best practices
- Limit scope: only observe the parts of the app you need.
- Use filters and batching to reduce noise.
- Prefer structured logs (objects) instead of raw console strings so logs are easier to query.
- Use WeakMap for caching to prevent memory leaks.
- Keep debug observers out of production or behind feature flags; or implement strict sampling and PII redaction if used in production.
- When wrapping libraries or frameworks, test thoroughly; prototypes and hidden invariants may cause subtle issues.
Summary
JavaScript Proxies are a powerful, unobtrusive tool to implement an “invisible observer” for debugging. They let you monitor property accesses, mutations, function calls, and construction without changing the original code. Use them thoughtfully: wrap selectively, use WeakMap caches, avoid breaking invariants, and integrate with structured logging or remote telemetry for the best debugging experience.
Further reading
- MDN Proxy reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
- MDN Reflect reference: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect
- MDN WeakMap: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap