Projects / Open Source Package

Open Source Package

patch-fetch

Every project I build ends up with the same fetch wrapper eventually. Retry on 500s. Time out after a few seconds so a hung request doesn't hang the whole app.

Every project I build ends up with the same fetch wrapper eventually. Retry on 500s. Time out after a few seconds so a hung request doesn't hang the whole app. Log what went out and what came back, so when something breaks at 11pm I have a trail instead of a guess. Inject an auth header without repeating it at every call site. I've written some version of this logic in enough projects that I stopped trusting myself to write it correctly from memory each time, which is usually the signal that something should become a package instead of a copy-paste.

patch-fetch is that logic, written once, as a middleware chain around the native fetch function instead of a full HTTP client replacing it.

The shape of it

You give it a list of middleware, functions with the signature (request, next) => Promise<Response>, and it composes them into one wrapped fetch. Retry-with-backoff, timeout via AbortController, logging hooks, fixed headers: those are the built-in pieces, but the middleware pattern means you can write your own and it slots into the same chain. The chain builds right to left, so the array order you pass in is the order things actually execute, which sounds like a small implementation detail until you're debugging why your auth header isn't present in the retry logs and it turns out you had two middlewares in the wrong order.

Retries default to the standard retryable status codes (408, 429, 500, 502, 503, 504), and because a Request body can normally only be read once, the retry logic clones the request (request.clone()) before each attempt so a POST with a body doesn't silently fail on the second try with an already consumed stream. That's the kind of bug that doesn't show up in a quick manual test and absolutely shows up three weeks later when someone's retry logic quietly stops working for anything except GET requests.

There are two ways to use it: patch(options) returns a wrapped fetch function you call explicitly, and patchGlobal(options) actually overwrites globalThis.fetch so every unmodified fetch() call in your codebase, including ones inside libraries you didn't write, goes through the middleware chain. I default to the first. The second is there because sometimes you're retrofitting resilience onto a codebase you don't want to touch call-site by call-site, and being able to patch the global is the difference between a day of find-and-replace and one line at the top of your entry file.

Why not just use a real HTTP client

Because most of what I need isn't a client, it's three or four specific behaviors layered onto something that already works everywhere: Node 18+, browsers, Deno, edge runtimes, anywhere fetch is native. Pulling in axios or a heavier client means accepting its opinions about interceptors, its own request/response shape, its own bundle weight, in exchange for features I mostly don't need. patch-fetch doesn't replace fetch. It wraps it. The API you already know still works; the middleware chain is additive, not a new mental model to learn.

What's honest about it

It's a hundred and fifty-nine lines, one file, zero runtime dependencies. No tests, no examples folder, no CHANGELOG, same story as most of what I ship solo, and I've made my peace with saying that plainly instead of pretending a .gitignore and a README constitute a mature open source project. This was built in one sitting, for me, and it happens to be generically useful enough that publishing it cost nothing extra.

If I'm being fully honest about the retry logic specifically: it's solid for the common case and untested against genuinely adversarial network conditions: flaky connections that half succeed, servers that return malformed responses instead of clean error codes, races between a timeout firing and a response arriving. I built it against the failures I actually see, which are mostly "the server briefly returned a 503 under load" and "the request just hung." It has not been battle-tested against the weirder failure modes, because I haven't personally needed it to survive them yet.

Where it actually earns its keep

Nexli's client talks to Firebase Cloud Functions constantly, and Cloud Functions cold start under load exactly the way you'd expect: occasionally slow, occasionally a transient 503 that resolves itself half a second later if you just ask again. Wrapping those calls in patch-fetch's retry-with-backoff turned "the finance dashboard sometimes fails to load and you have to refresh" into "the finance dashboard sometimes takes an extra second," which is a completely different user experience for the exact same underlying flakiness. The middleware didn't fix Cloud Functions. It made the app resilient to the parts of Cloud Functions I can't fix.

That's the honest case for a tool like this. It doesn't make anything faster. It makes failure quieter, and quieter failure is most of what production reliability actually is: not preventing every bad thing, just making sure the bad things that do happen don't become the user's problem.

The part I almost skipped explaining

Middleware order matters more than it looks like it should. Put the timeout middleware after the retry middleware in the array and you get a different, worse behavior than the reverse: a single slow request eats the timeout budget three times over instead of once per attempt, because the retry wraps the timeout instead of the timeout bounding each retry. I got this wrong once, in an early version, and spent a confused ten minutes wondering why a request that should have failed fast after three seconds was instead hanging for closer to ten. The fix wasn't a code change. It was reordering two entries in an array. That's the kind of bug middleware chains produce: not incorrect logic, just logic in the wrong sequence, which is arguably harder to spot because every individual piece is doing exactly what it says it does.

I didn't add any guardrail against that after fixing it. No warning, no runtime check for "these two middlewares are probably in the wrong order." Partly because I'm not sure there's a generically correct order, since it depends what you're actually trying to bound, and partly because once I understood the failure, it stopped happening to me, and I built this for myself first.

Logging as a middleware, not an afterthought

The logging hooks are structured the same way as retry and timeout: a middleware in the chain, not a special-cased option bolted onto the config object. That was a deliberate choice: I wanted logging to see exactly what every other middleware sees, including the retried attempts and the timeout state, not just the final resolved response. When something goes wrong in production and I'm reading through a request log at midnight, "attempt 2 of 3, previous attempt timed out after 4s" is a completely different, more useful line than just "request failed." Treating logging as a first class middleware instead of an afterthought is a small architectural choice that pays for itself exactly once a week, at the worst possible time, which is usually when you find out whether a design decision was actually good or just looked good in the README.

Full stack developer. Founder of Yashveer Labs. One wrapper, every runtime that already has fetch.

Start a conversation about this.

Whether it's patch-fetch itself or the next system worth building, the lab is reachable.