Proxy Pattern — Code Challenges
Challenge 1 — The Eager Report
The Code
A dashboard creates a report object at startup. Parsing a large CSV runs immediately — even when the user never opens the report tab.
function createReport(csv) {
console.log("[report] parsing…");
const rows = csv.trim().split("\n").map(line => line.split(","));
return {
rows() { return rows; },
count() { return rows.length; },
};
}
const csv = "name,score\nAda,98\nGrace,99\nAlan,97\n";
console.log("[app] booting");
const report = createReport(csv);
console.log("[app] dashboard ready");
// User never calls report.rows() — parse was wasted
console.log("[app] user viewed summary only");On slow devices, startup blocks on parse. Tests that import the dashboard module pay the parse cost even when assertions never touch rows().
What’s Wrong
Run this and ask: what happens when ten heavy reports register at boot — but the user only opens one?
createReport runs at construction time. There is no way to defer parsing until first access without changing every caller to call load() first — which would change the public API.
Three problems:
- Expensive work runs eagerly — parse happens at boot, not on demand
- No drop-in deferral — delaying parse would require a new method or flag visible to callers
- Real subject and client lifecycle are tied — importers cannot hold a lightweight stand-in
Your Task
Refactor so that:
createReport(csv)stays the real subject — parsing still happens inside itcreateReportProxy(csv)exposes the same interface:rows()andcount()- Parsing runs on first call to either method — not at proxy creation
- Second calls reuse the already-loaded real subject (parse once)
Target interface:
console.log("[app] booting");
const report = createReportProxy(csv);
console.log("[app] dashboard ready");
// no "[report] parsing…" yet
console.log(report.count());
// [report] parsing…
// 3
console.log(report.rows().length);
// 3 — no second parseHints
#1 — Same interface, delayed real subject
The proxy holds let real = null. A private getReal() creates the real report on first use:
function getReal() {
if (!real) real = createReport(csv);
return real;
}rows() and count() delegate to getReal().rows() and getReal().count(). Callers still call report.count() — they never call load().
#2 — Sketch the shape
function createReportProxy(csv) {
let real = null;
function getReal() {
if (!real) real = createReport(csv);
return real;
}
return {
rows() { return getReal().rows(); },
count() { return getReal().count(); },
};
}This is a virtual proxy — it controls when the real subject is created. The interface matches createReport exactly.
#3 — Pseudocode walkthrough
function createReportProxy(csv):
real = null
function getReal():
if real is null:
real = createReport(csv) ← expensive work here only
return real
return {
rows(): return getReal().rows()
count(): return getReal().count()
}
// Boot:
report = createReportProxy(csv) ← cheap
// Later:
report.count() ← triggers parse once
report.rows() ← reuses same realContrast with Adapter: the proxy does not rename methods or change return shapes — it only defers creation.
Solution
View solution
function createReport(csv) {
console.log("[report] parsing…");
const rows = csv.trim().split("\n").map(line => line.split(","));
return {
rows() { return rows; },
count() { return rows.length; },
};
}
function createReportProxy(csv) {
let real = null;
function getReal() {
if (!real) real = createReport(csv);
return real;
}
return {
rows() { return getReal().rows(); },
count() { return getReal().count(); },
};
}
const csv = "name,score\nAda,98\nGrace,99\nAlan,97\n";
console.log("[app] booting");
const report = createReportProxy(csv);
console.log("[app] dashboard ready");
// no "[report] parsing…"
console.log("[app] user opened report tab");
console.log(report.count());
// [report] parsing…
// 3
console.log(report.rows()[0]);
// ['name', 'score'] — no second "[report] parsing…"What changed:
- Boot no longer parses CSV — the proxy is a lightweight stand-in
- First
count()orrows()triggerscreateReportonce - Public API unchanged — drop-in replacement for
createReport
Concepts at work: The Proxy and real subject share the same interface (rows, count). The proxy controls access to creation of the real object — a virtual proxy. Contrast with Decorator: a logging wrapper would add behaviour on every call, not defer instantiation.
Challenge 2 — The Repeat Fetcher
The Code
A profile page and a settings page both fetch the same user by id. Each call hits the network — cache logic is copy-pasted between them.
function createUserClient() {
return {
async fetchById(id) {
console.log(`[network] fetch user ${id}`);
await new Promise(r => setTimeout(r, 10));
return { id, name: `User ${id}` };
},
};
}
const client = createUserClient();
async function loadProfilePage(userId) {
const user = await client.fetchById(userId);
console.log(`[profile] ${user.name}`);
}
async function loadSettingsPage(userId) {
const user = await client.fetchById(userId);
console.log(`[settings] ${user.name}`);
}
await loadProfilePage(1);
await loadSettingsPage(1);
// [network] fetch user 1
// [profile] User 1
// [network] fetch user 1 ← duplicate
// [settings] User 1Adding cache-aside logic to each page duplicates TTL, invalidation, and logging. The real client should stay focused on fetching — not caching policy at every call site.
What’s Wrong
Run this and ask: what happens when a third widget — nav bar, analytics, chat header — also calls fetchById(1) on mount?
Three network calls for the same id. Scatter if (cache.has(id)) into each consumer and watch TTL bugs multiply.
Three problems:
- No transparent cache — every consumer hits the network independently
- Cache belongs at the access boundary — not duplicated in UI modules
- Real client interface is correct — callers should keep calling
fetchById; a stand-in should intercept
Your Task
Refactor so that:
createUserClient()remains the real subjectcreateCachingUserProxy(real, { ttlMs })wraps the client with the samefetchById(id)method- Cache hits log
[cache] hit user {id}and skip the network - Optional
invalidate(id)clears one cache entry loadProfilePageandloadSettingsPageaccept the proxied client — second fetch for same id is a cache hit
Target interface:
const client = createCachingUserProxy(createUserClient(), { ttlMs: 60_000 });
await loadProfilePage(1, client);
await loadSettingsPage(1, client);
// [network] fetch user 1
// [profile] User 1
// [cache] hit user 1
// [settings] User 1Hints
#1 — Proxy, not Adapter
Keep the method name fetchById — do not rename to getUser or wrap return shapes. The proxy controls access (cache before network); it does not translate the interface. That distinction marks Proxy vs Adapter.
#2 — Sketch the shape
function createCachingUserProxy(real, { ttlMs = 60_000 } = {}) {
const cache = new Map();
return {
async fetchById(id) {
const hit = cache.get(id);
if (hit && Date.now() - hit.at < ttlMs) {
console.log(`[cache] hit user ${id}`);
return hit.data;
}
const data = await real.fetchById(id);
cache.set(id, { data, at: Date.now() });
return data;
},
invalidate(id) {
cache.delete(id);
},
};
}Inject the proxy at bootstrap: const client = createCachingUserProxy(createUserClient()).
#3 — Pseudocode walkthrough
function createCachingUserProxy(real, { ttlMs }):
cache = new Map()
async fetchById(id):
hit = cache.get(id)
if hit exists and not expired:
log cache hit
return hit.data
data = await real.fetchById(id)
cache.set(id, { data, at: now })
return data
invalidate(id):
cache.delete(id)
loadProfilePage(userId, client):
user = await client.fetchById(userId) ← same call as before
render profile
loadSettingsPage(userId, client):
user = await client.fetchById(userId) ← cache hit on second page
render settingsTTL lives in one place. Invalidation clears stale entries before refetch.
Solution
View solution
function createUserClient() {
return {
async fetchById(id) {
console.log(`[network] fetch user ${id}`);
await new Promise(r => setTimeout(r, 10));
return { id, name: `User ${id}` };
},
};
}
function createCachingUserProxy(real, { ttlMs = 60_000 } = {}) {
const cache = new Map();
return {
async fetchById(id) {
const hit = cache.get(id);
if (hit && Date.now() - hit.at < ttlMs) {
console.log(`[cache] hit user ${id}`);
return hit.data;
}
const data = await real.fetchById(id);
cache.set(id, { data, at: Date.now() });
return data;
},
invalidate(id) {
cache.delete(id);
},
};
}
async function loadProfilePage(userId, client) {
const user = await client.fetchById(userId);
console.log(`[profile] ${user.name}`);
}
async function loadSettingsPage(userId, client) {
const user = await client.fetchById(userId);
console.log(`[settings] ${user.name}`);
}
const client = createCachingUserProxy(createUserClient(), { ttlMs: 60_000 });
await loadProfilePage(1, client);
await loadSettingsPage(1, client);
// [network] fetch user 1
// [profile] User 1
// [cache] hit user 1
// [settings] User 1
await client.fetchById(2);
// [network] fetch user 2
client.invalidate(1);
await client.fetchById(1);
// [network] fetch user 1What changed:
- Cache policy moved into one proxy — pages keep calling
fetchById - Real client unchanged — still logs network fetches on miss only
invalidatesupports explicit cache bust without editing consumers
Concepts at work: A caching proxy intercepts calls with the same interface as the real subject. Callers cannot tell proxy from real without observing logs — that transparency is intentional. Contrast with Decorator if you stacked logging and cache as additive layers; here one proxy owns cache access control. Contrast with Adapter if you had renamed fetchById to getUser.
When Not to Use the Proxy Pattern
A proxy earns its place when callers should not know when or whether the real subject runs. Skip it when:
- You need a different method name or return shape. Renaming
fetchProfiletogetUseris an Adapter, not a Proxy. - You are adding logging or retry on the same interface as an optional stack. That is often Decorator — especially when multiple layers compose.
- One proxy owns cache, auth, retry, and metrics. Split into focused proxies or decorators — a god proxy is hard to test.
- The object is cheap and always needed. Lazy-loading a two-field config object adds indirection with no benefit.
The rule of thumb: same interface, control access (lazy, cache, guard) → Proxy. Different interface → Adapter. Same interface, add behaviour layers → Decorator.
What to Take Away
Before moving on, answer these from memory:
- In Challenge 1, why must the proxy expose
rows()andcount()rather than adding a separateload()method? - In Challenge 2, why is
createCachingUserProxya Proxy and not an Adapter? - Your proxy adds logging inside
fetchByIdbefore the cache check. Has the primary intent changed — Proxy, Decorator, or both? - When would you use JavaScript’s
new Proxy()instead of an explicit wrapper object likecreateReportProxy?