Back to Blog
Best Practices6 min read

Stop Building Retry Logic. Seriously.

F
FetchAPI Team
Engineering

Every backend developer has written their own retry wrapper. It's always buggy. There's a better way.

The Retry Loop Everyone Writes

At some point in every developer's career, they write something like this:

async function fetchWithRetry(url, options, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const res = await fetch(url, options);
      if (res.ok) return res;
      if (res.status === 429) {
        await sleep(Math.pow(2, i) * 1000);
        continue;
      }
      throw new Error(`HTTP ${res.status}`);
    } catch (err) {
      if (i === retries - 1) throw err;
      await sleep(Math.pow(2, i) * 1000);
    }
  }
}

It looks reasonable. It even works in development. But it has at least 5 bugs that will bite you in production:

Bug 1: It's In-Memory

If your server restarts mid-retry (deploy, crash, OOM kill), all retry state is lost. The request is gone. Your user never gets their email. Your webhook never delivers. You don't even know it happened.

Bug 2: No Idempotency

Your code retries a POST request that creates an order. The first request actually succeeded — the response just timed out. Now you've created two orders. Your retry loop needs idempotency keys, but nobody adds them.

Bug 3: No Observability

When your retry loop silently retries 3 times at 2am, you have no idea. No logs, no metrics, no alerts. The call eventually fails and you find out when a customer complains.

Bug 4: No Backpressure

When the downstream service returns 429 (rate limited), your retry loop respects the backoff. But what about the 200 other requests that are also retrying? Without global concurrency control, you create a thundering herd that makes the outage worse.

Bug 5: No Long-Running Support

Your serverless function has a 30-second timeout. Your retry loop needs to wait 60 seconds between attempts. It times out before the second retry. Game over.


The Better Way

Instead of building retry infrastructure, delegate it. Send your HTTP request to a service that handles durability, retries, idempotency, and observability for you.

// Before: Your buggy retry loop
const result = await fetchWithRetry(url, options, 3);

// After: Durable execution in one line
const { call_id } = await fetch("https://api.fetchapi.dev/v1/fetch", {
  method: "POST",
  headers: { Authorization: "Bearer YOUR_KEY" },
  body: JSON.stringify({
    url: "https://api.stripe.com/v1/charges",
    method: "POST",
    body: { amount: 2000 },
    retry: { maxAttempts: 5, backoff: "exponential" }
  })
}).then(r => r.json());

// That's it. Check status later:
const status = await fetch(`https://api.fetchapi.dev/v1/calls/${call_id}`)
  .then(r => r.json());

What You Get for Free

  • Durable state — Survives deploys, restarts, and infrastructure failures
  • Idempotency — Pass an Idempotency-Key header. Duplicates return the same result.
  • Concurrency control — Global limits prevent thundering herd
  • Full observability — Every attempt logged with latency, status codes, errors
  • Long-running support — Calls can take hours. Durable sleep, no timeouts.
  • Callback delivery — Get notified when the call completes

When Your Retry Loop Is Fine

To be fair, a simple retry loop is fine for some cases:

  • In-process retries for idempotent reads (GET requests)
  • Client-side retries in a mobile app (the user's device is the state store)
  • Prototype/hobby projects where reliability doesn't matter

But if you're building a SaaS, processing payments, delivering webhooks, or running background jobs — you need durable execution.

Delete your retry code today

FetchAPI handles retry, backoff, idempotency, and observability. One HTTP call. Done.

Get Started Free