· 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.

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.


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

  1. 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.
  2. Identity: Proxies are a different object identity than their target. proxy === target is false. This can confuse code that relies on strict equality.
  3. 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.
  4. 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.
  5. Symbol-keyed properties: Code that uses symbol keys (including internal symbols) can be noisy or require special handling.
  6. Prototype chain: Get/set traps are involved with prototype lookups; ensure you use Reflect.get with receiver to preserve this 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

Back to Blog

Related Posts

View All Posts »
Bitwise Swapping: A Deeper Dive into JavaScript's Oddities

Bitwise Swapping: A Deeper Dive into JavaScript's Oddities

An in-depth look at swapping variables using the bitwise XOR trick in JavaScript: how it works, why it sometimes bends expectations, practical use-cases, and the pitfalls you must know (ToInt32 conversion, aliasing, typed arrays, BigInt).

Microtasks vs Macrotasks: The Great JavaScript Showdown

Microtasks vs Macrotasks: The Great JavaScript Showdown

A deep dive into microtasks and macrotasks in JavaScript: what they are, how the event loop treats them, how they affect rendering and UX, illustrative diagrams and real-world examples, plus practical guidance to avoid performance pitfalls.