· tips  · 7 min read

Adaptive APIs: Creating Self-Adjusting JavaScript Interfaces with Proxies

Learn how to use JavaScript Proxies to build adaptive APIs that observe user interaction, adjust responses dynamically, and improve UX. Includes practical patterns, code examples, heuristics, and production considerations.

Learn how to use JavaScript Proxies to build adaptive APIs that observe user interaction, adjust responses dynamically, and improve UX. Includes practical patterns, code examples, heuristics, and production considerations.

What you’ll get from this article

You will learn how to build APIs that watch how your users interact and automatically adjust their behavior - all within the client-side JavaScript layer using Proxies. Expect practical, copy-pasteable patterns, production considerations, and a few heuristics you can use immediately to make your interfaces feel smarter and more responsive.

Short version: Proxies let you intercept calls and property access. Use that to collect lightweight telemetry and tune API responses on the fly. End result: fewer clicks, faster perceived performance, and more relevant payloads.


Why adaptive APIs matter

Users are different. Some want a quick, compact response. Others want more detail. Network conditions vary. Attention spans evolve during a session. Hard-coding one-size-fits-all API behavior forces trade-offs that reduce satisfaction.

Adaptive APIs let the interface respond to observable patterns instead of guessing once at design time. The system can:

  • Expand detail when a user asks follow-up questions.
  • Reduce payload size on slow networks.
  • Change the default page size when users paginate quickly.
  • Turn on experimental features for engaged users.

All this can be done client-side with minimal server changes, using JavaScript Proxies as a control layer between UI and API client.

See the Proxy docs for the language primitives used here: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy


Core idea - wrap your API client with a Proxy

The pattern is straightforward:

  1. Wrap an API client object in a Proxy.
  2. Intercept method calls and property access to collect metrics (call counts, latencies, success/failure, follow-ups).
  3. Use simple heuristics to alter the arguments passed to the real API or to transform responses.
  4. Persist a tiny amount of session state (in-memory or localStorage) so adjustments carry across page navigations.

This keeps the adaptive logic centralized and non-invasive to the rest of your app.


A practical example: adaptiveClient

Below is a compact but realistic example that demonstrates key techniques: intercepting function calls, capturing async timing, adjusting parameters, and caching.

// Example API client with methods that return Promises
const api = {
  async fetchItem(id, { detail = 'medium' } = {}) {
    // simulate a call to your backend
    const url = `/api/items/${id}?detail=${detail}`;
    const r = await fetch(url);
    return r.json();
  },

  async search(q, { page = 1, pageSize = 10, verbose = false } = {}) {
    const url = `/api/search?q=${encodeURIComponent(q)}&page=${page}&pageSize=${pageSize}&verbose=${verbose}`;
    const r = await fetch(url);
    return r.json();
  },
};

function createAdaptiveClient(client, opts = {}) {
  const telemetry = new Map(); // telemetry per-method
  const defaults = Object.assign(
    {
      recentWindow: 10,
      lowLatencyMs: 300,
      aggressiveAfter: 3,
    },
    opts
  );

  function record(method, ms, success = true) {
    if (!telemetry.has(method)) telemetry.set(method, []);
    const arr = telemetry.get(method);
    arr.push({ ms, success, t: Date.now() });
    if (arr.length > defaults.recentWindow) arr.shift();
  }

  function statsFor(method) {
    const arr = telemetry.get(method) || [];
    if (arr.length === 0) return null;
    const avg = arr.reduce((s, x) => s + x.ms, 0) / arr.length;
    const success = arr.filter(x => x.success).length / arr.length;
    return { count: arr.length, avg, success };
  }

  const handler = {
    get(target, prop) {
      const orig = target[prop];
      if (typeof orig !== 'function') return orig;

      // Return a wrapped function
      return async function (...args) {
        const method = prop.toString();
        const start = performance.now();

        // --- Adaptation heuristics BEFORE call ---
        const s = statsFor(method);

        // Example: if user calls search repeatedly and latency is low -> increase pageSize
        if (
          method === 'search' &&
          s &&
          s.count >= defaults.aggressiveAfter &&
          s.avg < defaults.lowLatencyMs
        ) {
          // mutate the options argument (last arg assumed to be an options object)
          const last = args[args.length - 1];
          if (typeof last === 'object') {
            last.pageSize = Math.min((last.pageSize || 10) * 2, 100);
          } else {
            args.push({ pageSize: 20 });
          }
        }

        // Example: if fetchItem is called repeatedly for the same id -> increase detail
        if (method === 'fetchItem') {
          const id = args[0];
          const cacheKey = `${method}:${id}`;
          const recent = telemetry.get(cacheKey) || [];
          if (recent.length >= 2) {
            // ensure an options object exists and bump detail
            args[1] = Object.assign({ detail: 'high' }, args[1] || {});
          }
        }

        let success = true;
        try {
          const result = await orig.apply(target, args);
          return result;
        } catch (err) {
          success = false;
          throw err;
        } finally {
          const ms = performance.now() - start;

          // granular telemetry
          record(prop.toString(), ms, success);

          // also record per-id for fetchItem
          if (prop === 'fetchItem') {
            const id = args[0];
            const cacheKey = `${prop}:${id}`;
            if (!telemetry.has(cacheKey)) telemetry.set(cacheKey, []);
            telemetry.get(cacheKey).push({ ms, success, t: Date.now() });
            if (telemetry.get(cacheKey).length > defaults.recentWindow)
              telemetry.get(cacheKey).shift();
          }
        }
      };
    },

    // Optionally intercept sets so UI can toggle adaptation flags
    set(target, prop, value) {
      target[prop] = value;
      return true;
    },
  };

  return new Proxy(client, handler);
}

// Usage
const adaptive = createAdaptiveClient(api);

(async () => {
  // First calls normal
  await adaptive.search('javascript', { page: 1, pageSize: 10 });
  await adaptive.search('javascript', { page: 2, pageSize: 10 });
  // After repeat calls and low latency, pageSize may be increased automatically
  await adaptive.search('javascript', { page: 3 });

  // fetchItem increases detail if the same id is repeatedly requested
  await adaptive.fetchItem('42');
  await adaptive.fetchItem('42');
  await adaptive.fetchItem('42'); // becomes more detailed
})();

Explanation of the example

  • The Proxy wraps methods and returns an async wrapper that measures time, records success, and applies heuristics.
  • Heuristics are intentionally simple so they are auditable: repeat calls + low latency => increase page size; repeat access to an item => request higher detail.
  • Telemetry is intentionally ephemeral and local. You can persist small summaries in localStorage for cross-page continuity.

More advanced patterns

Here are additional ways to use Proxies for adaptation.

  1. Intercept fetch to transparently compress or filter payloads
const nativeFetch = window.fetch.bind(window);
window.fetch = new Proxy(nativeFetch, {
  apply(target, thisArg, args) {
    const [resource, init] = args;
    // read network condition hint
    const slow = navigator.connection && navigator.connection.downlink < 1;
    if (slow && init && init.headers) {
      // hint to server to send smaller response
      init.headers['X-Preferred-Size'] = 'small';
    }
    return Reflect.apply(target, thisArg, args);
  },
});
  1. Use a Proxy on the response object to lazily load details from the server only when a property is accessed

This implements progressive disclosure: you return a lightweight object first, and only fetch details when the UI touches them.

  1. Feature toggles and experiments

Wrap a client so that calls automatically include a variant token for A/B experiments, and flip behavior if the user becomes engaged.

  1. Local caching and optimistic responses

A Proxy can return cached results instantly while scheduling a background refresh. The same wrapper can also downgrade response verbosity on network errors.


Heuristics you can use (starter list)

  • Repetition: repeated requests for the same resource -> increase detail.
  • Follow-ups: short gaps between related requests -> assume interest and expand results.
  • Latency: low latency -> richer payloads; high latency -> compressed payloads.
  • Success rate: repeated failures -> reduce concurrency or switch to a simpler endpoint.
  • Engagement: many different API endpoints called -> treat user as power user and surface advanced features.

Keep heuristics transparent and reversible. Avoid sudden, irreversible changes that surprise users.


Privacy, security, and consistency considerations

  • Telemetry should be minimal and local by default. If sending telemetry to a server, make it opt-in and document what you collect.
  • Don’t leak adjusted parameters that could reveal user behavior across accounts or devices.
  • Ensure server-side semantics can accept the adjusted arguments (e.g., pageSize, detail flags) or provide a stable fallback.
  • Be careful with caching: if you adjust responses per-user, caches (CDN/proxy) need proper keys.
  • Avoid adapting in ways that break reproducibility for analytics or troubleshooting.

For secure coding guidelines see OWASP: https://owasp.org


Testing and observability

  • Unit test heuristics independently from the Proxy wrapper so they are deterministic.
  • Log adaptation decisions (lightweight events) to your client analytics pipeline for a short experiment run.
  • Provide a dev toggle to disable adaptation when debugging.
  • Monitor metrics: perceived latency, engagement, error rate, and retention.

When not to adapt

  • Regulatory or audit-sensitive endpoints where consistent behavior is required.
  • When the server cannot support the varied requests your client may emit.
  • When you can’t explain adaptive changes to users; transparency is important.

Next steps and best practices

  • Start conservative: implement one adaptation (e.g., increase pageSize after N quick paginations).
  • Run it as an experiment for a narrow user segment.
  • Measure impact on engagement and error rates.
  • Expand heuristics only if clear benefit emerges.

Adaptive APIs are low-friction ways to make interfaces feel smarter and faster. With a handful of safe heuristics and a Proxy-based wrapper you can provide a more tailored experience without heavy server changes. The code is small, auditable, and reversible - and that’s exactly the kind of engineering change that scales.


Further reading

Back to Blog

Related Posts

View All Posts »
Dynamic Function Creation

Dynamic Function Creation

Learn how to create functions on-the-fly with one-liners using arrow functions and IIFE. This post covers practical patterns-factories, currying, closures, and event-handling examples-plus pitfalls and best practices.