Skip to content

Proxy Pattern and Lazy Loading

advanced18 min read

Invisible Interception

What if you could make an object that looks and behaves exactly like a normal object — same properties, same methods — but secretly runs your code every time someone reads a property, writes a value, or even checks if a key exists? No special API. No method calls. Just obj.name triggers your interceptor.

const user = createReactiveObject({ name: "Alice", age: 30 });

user.name;      // Reads "Alice" — AND tracks that you read "name"
user.age = 31;  // Writes 31 — AND notifies all watchers of "age"

This is exactly how Vue 3's reactivity works. And it's powered by a single ES6 feature: Proxy.

Mental Model

Think of a Proxy like a transparent bodyguard standing in front of a celebrity (the target object). Fans think they're interacting with the celebrity directly — they can ask questions, hand over gifts, take photos. But the bodyguard intercepts every interaction first. They might allow it, modify it, block it, or log it. The fan never knows the bodyguard exists. The celebrity never sees interactions the bodyguard blocks. Total transparent control.

Proxy Fundamentals

A Proxy wraps a target object and intercepts operations via handler traps:

const target = { name: "Alice", age: 30 };

const handler: ProxyHandler<typeof target> = {
  get(target, property, receiver) {
    console.log(`Reading: ${String(property)}`);
    return Reflect.get(target, property, receiver);
  },
  set(target, property, value, receiver) {
    console.log(`Writing: ${String(property)} = ${value}`);
    return Reflect.set(target, property, value, receiver);
  },
};

const proxy = new Proxy(target, handler);

proxy.name;       // "Reading: name" → "Alice"
proxy.age = 31;   // "Writing: age = 31"

Always use Reflect methods inside traps. Using target[property] directly bypasses the proxy for nested access and breaks getter/setter behavior.

Quiz
Why should you use Reflect.get instead of target[property] inside a Proxy get trap?

Building a Validation Proxy

One of the most practical uses of Proxy is automatic validation on property assignment:

interface UserSchema {
  name: string;
  email: string;
  age: number;
}

type Validator<T> = {
  [K in keyof T]: (value: unknown) => value is T[K];
};

function createValidated<T extends object>(
  initial: T,
  validators: Validator<T>
): T {
  return new Proxy(initial, {
    set(target, property, value, receiver) {
      const key = property as keyof T;
      const validator = validators[key];

      if (validator && !validator(value)) {
        throw new TypeError(
          `Invalid value for "${String(property)}": ${JSON.stringify(value)}`
        );
      }

      return Reflect.set(target, property, value, receiver);
    },
  });
}

const user = createValidated(
  { name: "Alice", email: "alice@example.com", age: 30 },
  {
    name: (v): v is string => typeof v === "string" && v.length > 0,
    email: (v): v is string => typeof v === "string" && v.includes("@"),
    age: (v): v is number => typeof v === "number" && v >= 0 && v <= 150,
  }
);

user.name = "Bob";        // Works
user.age = 25;            // Works
// user.age = -5;         // TypeError: Invalid value for "age": -5
// user.email = "invalid" // TypeError: Invalid value for "email": "invalid"

The consumer uses user like a plain object. They never know validation exists. This is the key advantage of Proxy over explicit setter methods — the API surface doesn't change.

Quiz
What advantage does a validation Proxy have over explicit setter methods?

Lazy Loading with Virtual Proxy

A virtual proxy stands in for an expensive object and loads it only when actually needed:

function createLazyProxy<T extends object>(loader: () => Promise<T>): T {
  let target: T | null = null;
  let loading: Promise<T> | null = null;
  const pending: Array<{ resolve: (v: unknown) => void; prop: string | symbol }> = [];

  const proxy = new Proxy({} as T, {
    get(_target, property, receiver) {
      if (target) {
        return Reflect.get(target, property, receiver);
      }

      if (!loading) {
        loading = loader().then(result => {
          target = result;
          pending.forEach(({ resolve, prop }) => {
            resolve(Reflect.get(target!, prop));
          });
          pending.length = 0;
          return result;
        });
      }

      return new Promise(resolve => {
        pending.push({ resolve, prop: property });
      });
    },
  });

  return proxy;
}

A simpler synchronous version for configuration objects:

function lazy<T extends object>(factory: () => T): T {
  let instance: T | null = null;

  return new Proxy({} as T, {
    get(_target, property, receiver) {
      if (!instance) instance = factory();
      return Reflect.get(instance, property, receiver);
    },
    has(_target, property) {
      if (!instance) instance = factory();
      return Reflect.has(instance, property);
    },
    ownKeys() {
      if (!instance) instance = factory();
      return Reflect.ownKeys(instance);
    },
  });
}

const config = lazy(() => {
  console.log("Loading heavy config...");
  return {
    apiUrl: "https://api.example.com",
    features: computeFeatureFlags(),
    theme: loadThemeConfig(),
  };
});

// Config is NOT loaded yet — zero cost
// ...later when actually needed:
console.log(config.apiUrl); // "Loading heavy config..." then "https://api.example.com"

Production Scenario: Vue 3 Reactivity

Vue 3's entire reactivity system is built on Proxy. Here's a simplified version of how reactive() works:

type EffectFn = () => void;

let activeEffect: EffectFn | null = null;
const targetMap = new WeakMap<object, Map<string | symbol, Set<EffectFn>>>();

function track(target: object, key: string | symbol): void {
  if (!activeEffect) return;

  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }

  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }

  deps.add(activeEffect);
}

function trigger(target: object, key: string | symbol): void {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;

  const effects = depsMap.get(key);
  effects?.forEach(effect => effect());
}

function reactive<T extends object>(target: T): T {
  return new Proxy(target, {
    get(target, key, receiver) {
      track(target, key);
      const result = Reflect.get(target, key, receiver);
      if (typeof result === "object" && result !== null) {
        return reactive(result);
      }
      return result;
    },
    set(target, key, value, receiver) {
      const oldValue = Reflect.get(target, key, receiver);
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key);
      }
      return result;
    },
  });
}

function watchEffect(fn: EffectFn): void {
  activeEffect = fn;
  fn();
  activeEffect = null;
}
const state = reactive({ count: 0, name: "Alice" });

watchEffect(() => {
  console.log(`Count is: ${state.count}`);
});
// Immediately logs: "Count is: 0"

state.count = 1;
// Automatically logs: "Count is: 1"

state.name = "Bob";
// Nothing logs — the effect only reads count, not name

The magic: watchEffect runs the function, and during execution, every property read goes through the Proxy's get trap. The trap records which properties this effect depends on. When those properties change (via the set trap), only the relevant effects re-run. This is fine-grained reactivity — more precise than React's component-level re-rendering.

Quiz
How does Vue 3's reactive system know which properties an effect depends on?

Proxy Performance Considerations

Proxies add overhead to every property access. V8 cannot inline or optimize through proxy traps the way it can with plain property access:

const plain = { x: 1 };
const proxied = new Proxy({ x: 1 }, {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver);
  },
});

// In a tight loop (1M iterations):
// plain.x     → ~2ms
// proxied.x   → ~15-25ms (7-12x slower)

This rarely matters for application code, but it's significant in hot paths like animation frames or tight data processing loops. Use Proxy for objects accessed at UI frequency (user actions, state changes), not for objects accessed at animation frequency (60fps rendering math).

What developers doWhat they should do
Using Proxy for performance-critical hot paths like animation calculations
Proxy adds 7-12x overhead per property access compared to plain objects. At 60fps with thousands of operations per frame, this overhead becomes visible. Vue uses Proxy for reactive state (human speed) but plain objects for internal rendering math
Use Proxy for objects accessed at human-interaction speed — state, forms, configs. Use plain objects for animation math and tight loops
Wrapping every nested object in a Proxy eagerly on creation
Eager wrapping creates proxy objects for every nested property upfront, even if they are never read. Lazy wrapping (as Vue 3 does) creates proxies on demand, saving memory and initialization time for large object trees
Create nested proxies lazily in the get trap — only wrap when actually accessed
Using target[property] inside proxy traps instead of Reflect methods
target[property] in a get trap sets the getter's this to the raw target, bypassing the proxy for nested access. Reflect.get with the receiver ensures getters run with the proxy as this, making nested property access correctly trapped
Always use Reflect.get, Reflect.set, etc. inside traps

Challenge

Build a createUndoableProxy that wraps any object and automatically tracks changes for undo/redo without requiring explicit command objects.

Challenge:

Try to solve it before peeking at the answer.

// Requirements:
// 1. Wraps any object with a Proxy
// 2. Every property set is automatically tracked
// 3. undo() reverts the last change
// 4. redo() reapplies the last undone change
// 5. getHistory() returns the list of changes

const state = createUndoableProxy({ count: 0, name: "Alice" });

state.value.count = 1;
state.value.count = 2;
state.value.name = "Bob";

state.undo(); // name → "Alice"
state.undo(); // count → 1
state.redo(); // count → 2

state.getHistory();
// [{ prop: "count", from: 0, to: 1 },
//  { prop: "count", from: 1, to: 2 },
//  { prop: "name", from: "Alice", to: "Bob" }]
Key Rules
  1. 1ES6 Proxy intercepts fundamental object operations transparently — consumers never know the proxy exists
  2. 2Always use Reflect methods inside traps, never target[property] — Reflect correctly forwards the receiver for getters and setters
  3. 3Proxy adds 7-12x overhead per property access — use for human-speed interactions, not animation-frame hot paths
  4. 4Create nested proxies lazily in the get trap, not eagerly at construction — this is how Vue 3 keeps initialization fast
  5. 5Proxy enables powerful patterns: validation, reactivity (Vue 3), lazy loading, access control, and change tracking