nanofetch
Zero-dependency TypeScript HTTP client (~5KB). Axios-compatible API built entirely on native fetch: interceptors, retries, typed errors, token refresh.
nanofetch is a zero-dependency TypeScript HTTP client I built and published to npm. It's about 5KB, works across browsers, Node.js, and React Native, and exposes an API compatible with Axios, so the switching cost is low.
The origin: a colleague mentioned he wasn't concerned about bundle size because his project was “just using fetch.” That made me curious. Could you get Axios-level ergonomics (interceptors, retries, response type inference, error normalization) purely on top of native fetch, with no dependencies at all? The answer is yes, but with more edge cases than I expected.
nanofetch npm page or README
What's Actually Hard About a Fetch Wrapper
The surface area of a good HTTP client is deceptively large. Axios doesn't just send requests. It handles serialization, interceptors, response type inference, structured error handling, retries, and cancellation. Reimplementing that on native fetch means confronting several problems the browser intentionally leaves to the caller.
Timeout and cancellation are the same mechanism, but not the same thing. The fetch API uses AbortControllerfor cancellation, but a timeout also aborts a request, and both produce the same error type. From a catch block they're identical. nanofetch distinguishes them using a Symbol as the abort reason on the timeout path, so the error handler can reliably tell whether a request was cancelled by the user or cut off by a timeout, and surface the appropriate error code for each.
Retry loops can't reuse the same controller.Once an abort signal fires, it's consumed and can't be reset. A retry loop that reuses the previous attempt's controller finds the signal already in an aborted state and fails immediately. nanofetch creates a fresh controller per attempt, with proper cleanup (clearing the timeout and removing listeners) in the finally block of each attempt.
JSON parse errors should be typed, not raw exceptions. When fetch returns a response with a malformed JSON body, calling .json() throws a raw SyntaxError. nanofetch reads the body as text first, then parses in a try/catch, and maps failure to a structured client error with an isParseError flag. The caller gets consistent error types regardless of where in the request lifecycle something went wrong.
nanofetch README, interceptor or retry code example
Interceptors and Token Refresh
The interceptor chain supports both request and response paths. On the response error path, the first rejected handler that resolves successfully short-circuits the chain. This is how token refresh works in practice: a 401 triggers the error handler, which refreshes the access token, then calls replay() to re-fire the original request transparently. The caller never sees the 401; they just get the response they originally asked for.
replay() reconstructs the original request from metadata attached to the error object (method, URL, config), which is why every ApiErrorcarries that context even when it doesn't seem immediately relevant.