[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-fetch-retry-exponential-backoff":44,"search-suggestions":60,"quiz-article-fetch-retry-exponential-backoff":107},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a0fb3c-4ed5-749b-8bd5-67e5a44a923c","fetch-retry-exponential-backoff","PRACTICE_QUIZ","Fetch retry & exponential backoff","Check what actually sticks about retrying failed requests safely: why immediate retries backfire, how backoff and jitter fix it, which status codes deserve a retry, and how to retry safely without duplicating side effects.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,100,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",127,{"slug":66,"name":67,"articles":68},"javascript","Javascript",105,{"slug":70,"name":71,"articles":72},"frontend","Frontend",80,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",49,{"slug":78,"name":79,"articles":80},"css","Css",40,{"slug":82,"name":83,"articles":84},"typescript","Typescript",18,{"slug":86,"name":87,"articles":88},"performance","Performance",16,{"slug":90,"name":91,"articles":92},"react","React",15,{"slug":94,"name":95,"articles":96},"browser","Browser",12,{"slug":98,"name":99,"articles":96},"node","Node",{"slug":101,"name":102,"articles":103},"html","Html",9,{"slug":105,"name":106,"articles":51},"accessibility","Accessibility",{"id":108,"slug":46,"title":109,"subtitle":52,"excerpt":110,"coverUrl":111,"locale":13,"readingMinutes":112,"publishedAt":113,"viewCount":76,"likeCount":19,"commentCount":19,"author":114,"vertical":119,"topic":120,"tags":122,"_count":129,"playground":131,"body":133,"bodyMd":275,"seo":276,"translationGroupId":279,"series":52,"podcastUrl":52,"verticalId":5,"thread":280,"assessments":282,"translations":285,"quiz":287},"01a0fb3c-4e87-7604-9b6e-d9f518d232be","Stop Retrying Immediately. Exponential Backoff Fixes It.","Retrying a failed fetch() right away feels responsible — until the server that failed was already overloaded, and five instant retries per client turn a blip into an outage. Exponential backoff with jitter is the fix.","\u002Fmedia\u002Fcovers\u002Ffetch-retry-exponential-backoff.png",6,"2026-10-07T06:10:20.335Z",{"id":115,"name":116,"username":117,"avatarUrl":52,"headline":118},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":121},"JavaScript",[123,124,125,128],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":126,"name":127,"color":52},"programming","Programming",{"slug":98,"name":99,"color":52},{"assessments":130},1,{"slug":46,"title":132},"Exponential backoff — interactive playground",{"blocks":134,"version":130},[135,139,142,146,149,155,158,161,164,167,170,177,180,184,187,194,197,200,203,206,209,212,215,218,221,224,227,230,233,236,239,242,245,248,251,257,260,263,266,269],{"id":136,"html":137,"type":138},"b1","\u003Cp>The API call fails. Your retry logic kicks in — of course it does, you wrote it for exactly this. Five attempts, back to back, as fast as the event loop allows. The first one failed in 40ms. By 200ms you&#39;ve fired all five.\u003C\u002Fp>","paragraph",{"id":140,"html":141,"type":138},"b2","\u003Cp>That&#39;s the whole bug. Not that you retried — that you retried \u003Cem>immediately\u003C\u002Fem>, which is the one thing a failing server can least afford.\u003C\u002Fp>",{"id":143,"html":144,"text":144,"type":145,"level":31},"b3","The retry loop that feels obviously right","heading",{"id":147,"html":148,"type":138},"b4","\u003Cp>Here&#39;s the version almost everyone writes first:\u003C\u002Fp>",{"id":150,"code":151,"type":152,"language":153,"highlight":154},"b5","async function fetchWithRetry(url, retries = 5) {\n  for (let i = 0; i \u003C retries; i++) {\n    try {\n      const res = await fetch(url);\n      if (res.ok) return res;\n    } catch {\n      \u002F\u002F network error — fall through and try again\n    }\n  }\n  throw new Error(`Failed after ${retries} attempts: ${url}`);\n}","code","js",[],{"id":156,"html":157,"type":138},"b6","\u003Cp>It reads like good engineering. A flaky request gets a second chance, then a third, up to five, before you give up and surface an error. Test it against a healthy endpoint that you fail on purpose for one call, and it works perfectly — the second attempt succeeds, nobody notices.\u003C\u002Fp>",{"id":159,"html":160,"type":138},"b7","\u003Cp>The test that never runs is the one where the \u003Cem>server\u003C\u002Fem> is the problem. A deploy goes out with a bad config, a dependency times out, a traffic spike pushes response times past your fetch timeout. Now every client hitting that endpoint gets a failure — and every client&#39;s retry loop fires its five attempts in the same few hundred milliseconds. The server that was struggling under normal load now gets 5x the request volume, instantly, from every caller that was already in flight. That&#39;s not recovery. That&#39;s a second, self-inflicted spike landing directly on top of the first one.\u003C\u002Fp>",{"id":162,"html":163,"type":138},"b8","\u003Cp>This has a name — a \u003Cstrong>retry storm\u003C\u002Fstrong> — and it&#39;s a well-documented failure mode in distributed systems, not a hypothetical. The fix isn&#39;t &quot;retry less.&quot; It&#39;s &quot;retry differently.&quot;\u003C\u002Fp>",{"id":165,"html":166,"text":166,"type":145,"level":31},"b9","What the loop is actually missing",{"id":168,"html":169,"type":138},"b10","\u003Cp>Three things, and none of them are exotic:\u003C\u002Fp>",{"id":171,"type":172,"items":173,"ordered":17},"b11","list",[174,175,176],"\u003Cstrong>A growing delay between attempts\u003C\u002Fstrong>, so a struggling server gets breathing room instead of an immediate second hit.","\u003Cstrong>Randomness in that delay\u003C\u002Fstrong>, so thousands of clients that all failed at the same moment don&#39;t all retry at the same moment too.","\u003Cstrong>A reason not to retry everything\u003C\u002Fstrong> — a \u003Ccode>404\u003C\u002Fcode> will fail the same way on attempt five as attempt one. Retrying it five times doesn&#39;t help; it just makes you look five times slower at reporting the real error.",{"id":178,"html":179,"type":138},"b12","\u003Cp>Put those together and you get \u003Cstrong>exponential backoff with jitter\u003C\u002Fstrong>: each retry waits roughly twice as long as the last, capped at some maximum, and the exact wait is randomized within that window instead of fixed.\u003C\u002Fp>",{"id":181,"code":182,"type":152,"language":153,"highlight":183},"b13","async function fetchWithRetry(url, { retries = 4, baseDelay = 300, maxDelay = 8000 } = {}) {\n  for (let attempt = 0; ; attempt++) {\n    let res;\n    try {\n      res = await fetch(url, { signal: AbortSignal.timeout(5000) });\n    } catch (err) {\n      if (attempt === retries) throw err;\n      await backoff(attempt, baseDelay, maxDelay);\n      continue;\n    }\n\n    if (res.ok) return res;\n    if (res.status \u003C 500 && res.status !== 429) return res; \u002F\u002F a client error won't fix itself\n    if (attempt === retries) return res;\n\n    await backoff(attempt, baseDelay, maxDelay, res.headers.get(\"Retry-After\"));\n  }\n}\n\nfunction backoff(attempt, base, cap, retryAfter) {\n  if (retryAfter) {\n    const seconds = Number(retryAfter);\n    const ms = Number.isFinite(seconds) ? seconds * 1000 : new Date(retryAfter).getTime() - Date.now();\n    return sleep(Math.max(0, ms));\n  }\n  const windowMs = Math.min(cap, base * 2 ** attempt);\n  return sleep(Math.random() * windowMs); \u002F\u002F \"full jitter\" — pick anywhere in the window, not the edge\n}\n\nconst sleep = (ms) => new Promise((r) => setTimeout(r, ms));",[],{"id":185,"html":186,"type":138},"b14","\u003Cp>Walk through what changed, because each line is answering one of the three gaps above:\u003C\u002Fp>",{"id":188,"type":172,"items":189,"ordered":18},"b15",[190,191,192,193],"\u003Ccode>AbortSignal.timeout(5000)\u003C\u002Fcode> caps how long a single attempt can hang before it counts as a failure and moves on to the backoff — without this, one slow attempt can eat your whole retry budget just sitting in \u003Ccode>fetch\u003C\u002Fcode>&#39;s pending state.","\u003Ccode>res.status &lt; 500 &amp;&amp; res.status !== 429\u003C\u002Fcode> stops the loop from wasting attempts on a \u003Ccode>400\u003C\u002Fcode> or \u003Ccode>404\u003C\u002Fcode> — those are the server telling you the request itself is wrong, and no amount of waiting changes that. \u003Ccode>429\u003C\u002Fcode> (rate limited) and \u003Ccode>5xx\u003C\u002Fcode> (server trouble) are the ones worth retrying.","\u003Ccode>Retry-After\u003C\u002Fcode> is a real HTTP response header — servers send it on \u003Ccode>429\u003C\u002Fcode> and \u003Ccode>503\u003C\u002Fcode> to tell clients how long to wait before trying again (it also shows up on some \u003Ccode>3xx\u003C\u002Fcode> redirects, for a different reason) — as a number of seconds, or an HTTP date. When it&#39;s there, use it instead of guessing; the server is telling you its own recovery estimate.","\u003Ccode>Math.random() * windowMs\u003C\u002Fcode> is &quot;full jitter&quot;: instead of every client waiting exactly \u003Ccode>300ms\u003C\u002Fcode>, then exactly \u003Ccode>600ms\u003C\u002Fcode>, then exactly \u003Ccode>1200ms\u003C\u002Fcode> — which just re-synchronizes everyone&#39;s retries into new, smaller storms — each client picks a random point inside that window. Spread the same total number of retries over time instead of in lockstep, and the server sees a trickle instead of a wave.",{"id":195,"html":196,"type":138},"b16","\u003C!-- playground:start -->",{"id":198,"html":199,"text":199,"type":145,"level":31},"b17","🎮 Try it yourself",{"id":201,"html":202,"type":138},"b18","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-retry-exponential-backoff\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":204,"html":205,"type":138},"b19","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":207,"html":208,"type":138},"b20","\u003C!-- playground:end -->",{"id":210,"html":211,"text":211,"type":145,"level":31},"b21","The attempt nobody should auto-retry",{"id":213,"html":214,"type":138},"b22","\u003Cp>One more thing the naive loop glossed over: it retried \u003Cem>everything\u003C\u002Fem>, including requests that aren&#39;t safe to repeat. A \u003Ccode>GET\u003C\u002Fcode> is idempotent — running it five times has the same effect as running it once. A \u003Ccode>POST\u003C\u002Fcode> that creates an order is not. If that request actually reached the server and created the order, but the response got lost on the way back, retrying blindly creates a second order.\u003C\u002Fp>",{"id":216,"html":217,"type":138},"b23","\u003Cp>The honest fix is smaller in scope than it sounds: only wrap genuinely idempotent requests (\u003Ccode>GET\u003C\u002Fcode>, \u003Ccode>HEAD\u003C\u002Fcode>, a \u003Ccode>PUT\u003C\u002Fcode> that fully replaces a resource) in automatic retry by default. For a \u003Ccode>POST\u003C\u002Fcode> you need retried — payment confirmation, for instance — the real fix is an \u003Cstrong>idempotency key\u003C\u002Fstrong>: a unique ID you generate once and send with every attempt, so the server can recognize &quot;I&#39;ve already done this one&quot; and return the original result instead of doing it twice. That&#39;s a server-side contract, not something \u003Ccode>fetchWithRetry\u003C\u002Fcode> can fake on its own — which is exactly why it&#39;s worth calling out instead of quietly auto-retrying a \u003Ccode>POST\u003C\u002Fcode> and hoping.\u003C\u002Fp>",{"id":219,"html":220,"text":220,"type":145,"level":31},"b24","What this actually buys you",{"id":222,"html":223,"type":138},"b25","\u003Cp>None of this makes requests succeed that were always going to fail — a backoff loop retrying against a server that&#39;s down for an hour will still, eventually, give up. What it changes is the shape of the load your retries put back on a server that&#39;s recovering: spread out instead of synchronized, bounded instead of infinite, and aimed only at the failures that stand a chance of succeeding on a second try.\u003C\u002Fp>",{"id":225,"html":226,"type":138},"b26","\u003Cp>The three-line fix really is three lines — a growing delay, a random window inside it, and a check for which status codes deserve a second attempt. Getting there meant admitting the first version wasn&#39;t &quot;retrying too little&quot; or &quot;retrying too much.&quot; It was retrying at exactly the wrong \u003Cem>tempo\u003C\u002Fem>.\u003C\u002Fp>",{"id":228,"html":229,"type":138},"b27","\u003Cp>What&#39;s your current retry count, and have you ever actually watched what it does to a server that&#39;s already struggling?\u003C\u002Fp>",{"id":231,"html":232,"type":138},"b28","\u003C!-- quiz:start -->",{"id":234,"html":235,"text":235,"type":145,"level":31},"b29","🧠 Test yourself",{"id":237,"html":238,"type":138},"b30","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-retry-exponential-backoff\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":240,"html":241,"type":138},"b31","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":243,"html":244,"type":138},"b32","\u003C!-- quiz:end -->",{"id":246,"html":247,"type":138},"b33","\u003C!-- related:start -->",{"id":249,"html":250,"text":250,"type":145,"level":31},"b34","📚 Read next",{"id":252,"type":172,"items":253,"ordered":18},"b35",[254,255,256],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpromise-try-synchronous-throw-safety\">My .catch() Never Ran. The Bug Was Two Lines Above It\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream\">Your Fetch Already Streams. You&#39;re Buffering It Anyway.\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftemporal-api-date-mutation-trap\">addDays() Mutated a Date Three Components Away From Where I Called It\u003C\u002Fa>",{"id":258,"html":259,"type":138},"b36","\u003C!-- related:end -->",{"id":261,"type":262},"b37","divider",{"id":264,"html":265,"type":138},"b38","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":267,"html":268,"type":138},"b39","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":270,"type":172,"items":271,"ordered":18},"b40",[272,273,274],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","The API call fails. Your retry logic kicks in — of course it does, you wrote it for exactly this. Five attempts, back to back, as fast as the event loop allows. The first one failed in 40ms. By 200ms you've fired all five.\n\nThat's the whole bug. Not that you retried — that you retried *immediately*, which is the one thing a failing server can least afford.\n\n## The retry loop that feels obviously right\n\nHere's the version almost everyone writes first:\n\n```js\nasync function fetchWithRetry(url, retries = 5) {\n  for (let i = 0; i \u003C retries; i++) {\n    try {\n      const res = await fetch(url);\n      if (res.ok) return res;\n    } catch {\n      \u002F\u002F network error — fall through and try again\n    }\n  }\n  throw new Error(`Failed after ${retries} attempts: ${url}`);\n}\n```\n\nIt reads like good engineering. A flaky request gets a second chance, then a third, up to five, before you give up and surface an error. Test it against a healthy endpoint that you fail on purpose for one call, and it works perfectly — the second attempt succeeds, nobody notices.\n\nThe test that never runs is the one where the *server* is the problem. A deploy goes out with a bad config, a dependency times out, a traffic spike pushes response times past your fetch timeout. Now every client hitting that endpoint gets a failure — and every client's retry loop fires its five attempts in the same few hundred milliseconds. The server that was struggling under normal load now gets 5x the request volume, instantly, from every caller that was already in flight. That's not recovery. That's a second, self-inflicted spike landing directly on top of the first one.\n\nThis has a name — a **retry storm** — and it's a well-documented failure mode in distributed systems, not a hypothetical. The fix isn't \"retry less.\" It's \"retry differently.\"\n\n## What the loop is actually missing\n\nThree things, and none of them are exotic:\n\n1. **A growing delay between attempts**, so a struggling server gets breathing room instead of an immediate second hit.\n2. **Randomness in that delay**, so thousands of clients that all failed at the same moment don't all retry at the same moment too.\n3. **A reason not to retry everything** — a `404` will fail the same way on attempt five as attempt one. Retrying it five times doesn't help; it just makes you look five times slower at reporting the real error.\n\nPut those together and you get **exponential backoff with jitter**: each retry waits roughly twice as long as the last, capped at some maximum, and the exact wait is randomized within that window instead of fixed.\n\n```js\nasync function fetchWithRetry(url, { retries = 4, baseDelay = 300, maxDelay = 8000 } = {}) {\n  for (let attempt = 0; ; attempt++) {\n    let res;\n    try {\n      res = await fetch(url, { signal: AbortSignal.timeout(5000) });\n    } catch (err) {\n      if (attempt === retries) throw err;\n      await backoff(attempt, baseDelay, maxDelay);\n      continue;\n    }\n\n    if (res.ok) return res;\n    if (res.status \u003C 500 && res.status !== 429) return res; \u002F\u002F a client error won't fix itself\n    if (attempt === retries) return res;\n\n    await backoff(attempt, baseDelay, maxDelay, res.headers.get(\"Retry-After\"));\n  }\n}\n\nfunction backoff(attempt, base, cap, retryAfter) {\n  if (retryAfter) {\n    const seconds = Number(retryAfter);\n    const ms = Number.isFinite(seconds) ? seconds * 1000 : new Date(retryAfter).getTime() - Date.now();\n    return sleep(Math.max(0, ms));\n  }\n  const windowMs = Math.min(cap, base * 2 ** attempt);\n  return sleep(Math.random() * windowMs); \u002F\u002F \"full jitter\" — pick anywhere in the window, not the edge\n}\n\nconst sleep = (ms) => new Promise((r) => setTimeout(r, ms));\n```\n\nWalk through what changed, because each line is answering one of the three gaps above:\n\n- `AbortSignal.timeout(5000)` caps how long a single attempt can hang before it counts as a failure and moves on to the backoff — without this, one slow attempt can eat your whole retry budget just sitting in `fetch`'s pending state.\n- `res.status \u003C 500 && res.status !== 429` stops the loop from wasting attempts on a `400` or `404` — those are the server telling you the request itself is wrong, and no amount of waiting changes that. `429` (rate limited) and `5xx` (server trouble) are the ones worth retrying.\n- `Retry-After` is a real HTTP response header — servers send it on `429` and `503` to tell clients how long to wait before trying again (it also shows up on some `3xx` redirects, for a different reason) — as a number of seconds, or an HTTP date. When it's there, use it instead of guessing; the server is telling you its own recovery estimate.\n- `Math.random() * windowMs` is \"full jitter\": instead of every client waiting exactly `300ms`, then exactly `600ms`, then exactly `1200ms` — which just re-synchronizes everyone's retries into new, smaller storms — each client picks a random point inside that window. Spread the same total number of retries over time instead of in lockstep, and the server sees a trickle instead of a wave.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-retry-exponential-backoff\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The attempt nobody should auto-retry\n\nOne more thing the naive loop glossed over: it retried *everything*, including requests that aren't safe to repeat. A `GET` is idempotent — running it five times has the same effect as running it once. A `POST` that creates an order is not. If that request actually reached the server and created the order, but the response got lost on the way back, retrying blindly creates a second order.\n\nThe honest fix is smaller in scope than it sounds: only wrap genuinely idempotent requests (`GET`, `HEAD`, a `PUT` that fully replaces a resource) in automatic retry by default. For a `POST` you need retried — payment confirmation, for instance — the real fix is an **idempotency key**: a unique ID you generate once and send with every attempt, so the server can recognize \"I've already done this one\" and return the original result instead of doing it twice. That's a server-side contract, not something `fetchWithRetry` can fake on its own — which is exactly why it's worth calling out instead of quietly auto-retrying a `POST` and hoping.\n\n## What this actually buys you\n\nNone of this makes requests succeed that were always going to fail — a backoff loop retrying against a server that's down for an hour will still, eventually, give up. What it changes is the shape of the load your retries put back on a server that's recovering: spread out instead of synchronized, bounded instead of infinite, and aimed only at the failures that stand a chance of succeeding on a second try.\n\nThe three-line fix really is three lines — a growing delay, a random window inside it, and a check for which status codes deserve a second attempt. Getting there meant admitting the first version wasn't \"retrying too little\" or \"retrying too much.\" It was retrying at exactly the wrong *tempo*.\n\nWhat's your current retry count, and have you ever actually watched what it does to a server that's already struggling?\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-retry-exponential-backoff\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [My .catch() Never Ran. The Bug Was Two Lines Above It](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpromise-try-synchronous-throw-safety)\n- [Your Fetch Already Streams. You're Buffering It Anyway.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream)\n- [addDays() Mutated a Date Three Components Away From Where I Called It](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftemporal-api-date-mutation-trap)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":109,"canonical":277,"description":278},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-retry-exponential-backoff","Retrying a failed fetch() right away feels responsible — until the server that failed was already overloaded, and five instant retries per client turn a blip into an outage. Expone","01a0fb3c-4e87-7604-9b6e-de1bbb6bb520",{"id":281,"locked":18},"01a0fb3c-4eb7-71a4-a4d4-7ce1c87dd78b",[283],{"id":45,"slug":46,"title":48,"_count":284},{"questions":51},[286],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":288,"questionCount":51},{"questions":51}]