Smart Fetch – Advanced Fetch Wrapper with Cache and Retry

    A small yet powerful vanilla JavaScript utility that extends the native Fetch API with request timeout, automatic retries, in-memory caching (TTL), and request deduplication. Designed for real-world production use in modern browsers and Node.js without any dependencies.

    Language: javascript

    Author: Umut Bayraktar (@umut)

    13 stars · 105 views

    Smart Fetch – Advanced Fetch Wrapper with Cache and Retry

    Public
    • Updated Jan 24, 2026
    13
    smart-fetch-–-advanced-fetch-wrapper-with-cache-and-retry.js
    // smartFetch.js (vanilla JS) — timeout + retry + backoff + cache(TTL) + dedupe
    export function createSmartFetch(opts = {}) {
      const cfg = {
        baseURL: "",
        timeoutMs: 8000,
        retries: 2,
        backoffMs: 250,
        cacheTtlMs: 30_000,
        headers: {},
        ...opts,
      };
    
      const cache = new Map();   // key -> { exp, value }
      const inflight = new Map(); // key -> Promise
      const hooks = { onRequest: [], onResponse: [], onError: [] };
    
      const sleep = (ms) => new Promise(r => setTimeout(r, ms));
      const now = () => Date.now();
      const joinUrl = (base, path) => {
        if (!base) return path;
        if (!path) return base;
        return base.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "");
      };
    
      function on(event, fn) {
        if (!hooks[event]) throw new Error(`Unknown hook: ${event}`);
        hooks[event].push(fn);
        return () => hooks[event] = hooks[event].filter(x => x !== fn);
      }
      const emit = (event, payload) => hooks[event].forEach(fn => { try { fn(payload); } catch {} });
    
      async function parseBody(res) {
        const ct = res.headers.get("content-type") || "";
        if (ct.includes("application/json")) return res.json();
        const text = await res.text();
        try { return JSON.parse(text); } catch { return text; }
      }
    
      async function request(method, url, { params, json, body, headers, cacheTtlMs, timeoutMs, retries } = {}) {
        const fullUrl = (() => {
          const u = new URL(joinUrl(cfg.baseURL, url), typeof window !== "undefined" ? window.location.href : "http://x/");
          if (params) Object.entries(params).forEach(([k,v]) => u.searchParams.set(k, String(v)));
          return u.toString().replace("http://x/", cfg.baseURL ? "" : "");
        })();
    
        const key = method + " " + fullUrl + " " + (json ? JSON.stringify(json) : body ? String(body) : "");
        const ttl = cacheTtlMs ?? cfg.cacheTtlMs;
        const tmo = timeoutMs ?? cfg.timeoutMs;
        const rty = retries ?? cfg.retries;
    
        // cache hit
        const hit = cache.get(key);
        if (hit && hit.exp > now()) return hit.value;
    
        // dedupe inflight
        if (inflight.has(key)) return inflight.get(key);
    
        const p = (async () => {
          let attempt = 0;
          let lastErr;
    
          while (attempt <= rty) {
            const ac = new AbortController();
            const timer = setTimeout(() => ac.abort(new Error("Timeout")), tmo);
    
            try {
              const init = {
                method,
                signal: ac.signal,
                headers: { ...cfg.headers, ...headers },
                body: undefined,
              };
    
              if (json !== undefined) {
                init.headers["content-type"] = "application/json";
                init.body = JSON.stringify(json);
              } else if (body !== undefined) {
                init.body = body;
              }
    
              emit("onRequest", { method, url: fullUrl, attempt });
    
              const res = await fetch(fullUrl, init);
              const data = await parseBody(res);
    
              if (!res.ok) {
                const err = new Error(`HTTP ${res.status}`);
                err.status = res.status;
                err.data = data;
                throw err;
              }
    
              emit("onResponse", { method, url: fullUrl, attempt, status: res.status });
    
              if (ttl > 0) cache.set(key, { exp: now() + ttl, value: data });
              return data;
    
            } catch (e) {
              lastErr = e;
              emit("onError", { method, url: fullUrl, attempt, error: e });
    
              if (attempt >= rty) break;
    
              // backoff + small jitter
              const backoff = cfg.backoffMs * Math.pow(2, attempt) + Math.random() * 80;
              await sleep(backoff);
    
            } finally {
              clearTimeout(timer);
              attempt++;
            }
          }
          throw lastErr;
        })();
    
        inflight.set(key, p);
        try { return await p; }
        finally { inflight.delete(key); }
      }
    
      return {
        on,
        request,
        get: (url, options) => request("GET", url, options),
        post: (url, json, options) => request("POST", url, { ...options, json }),
        clearCache: () => cache.clear(),
      };
    }
    
    /*
    USAGE (Browser / Node 18+):
    
    import { createSmartFetch } from "./smartFetch.js";
    
    const api = createSmartFetch({ baseURL: "https://jsonplaceholder.typicode.com", cacheTtlMs: 10_000 });
    
    api.on("onError", ({url, error}) => console.log("ERR", url, error.message));
    
    const user = await api.get("/users/1");              // cached
    const posts = await api.get("/posts", { params:{ userId: 1 }, retries: 3 });
    console.log(user, posts);
    */

    About

    A small yet powerful vanilla JavaScript utility that extends the native Fetch API with request timeout, automatic retries, in-memory caching (TTL), and request deduplication. Designed for real-world production use in modern browsers and Node.js without any dependencies.

    13stars
    105views
    Created Jan 13, 2026