TypeScript Client: fetch vs axios
The TypeScript client generated by protoc-gen-ts-client lets you inject the HTTP
implementation it uses. This document explains:
- What
fetchandaxiosare, and how they differ - Why teams historically reached for
axios, and whyfetchis now the default - How the injection point works in the generated sebuf client
- How to use
axioswith sebuf via an adapter — and exactly what that costs - Patches and workarounds for the adapter's limitations (SSE, per-call config, progress)
- A recommendation
1. Background: fetch vs axios#
fetch#
fetch is the web-standard HTTP API. It's built into every modern runtime —
browsers, Node 18+, Deno, Bun, and Cloudflare Workers. It needs no installation.
const resp = await fetch("https://api.example.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Ada" }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const user = await resp.json();Its contract is a function (url, init) => Promise<Response>, where Response
exposes .ok, .status, .json(), .text(), .headers, and a streaming
.body (ReadableStream).
axios#
axios is a third-party HTTP library (a dependency you install). It predates a
usable fetch and shipped a lot of convenience out of the box.
import axios from "axios";
const { data: user } = await axios.post("https://api.example.com/users", {
name: "Ada",
});Its contract is completely different: axios(config) => Promise<AxiosResponse>,
where the parsed body lives on .data (no .json(), no .ok, no streaming
.body).
Why this difference matters#
These two are not interchangeable. A function written for fetch cannot be
handed an axios instance, because:
fetch |
axios |
|
|---|---|---|
| Call shape | fetch(url, init) |
axios(config) |
| Body access | await resp.json() |
resp.data |
| Error status (404/500) | resolves; you check resp.ok |
throws by default |
| Streaming body | resp.body is a ReadableStream |
not a web stream |
2. Why people used axios — and why fetch is winning#
Why axios became popular#
When fetch was young (and absent from Node), axios offered batteries-included
ergonomics:
- Interceptors — hooks that run before every request / after every response. This is the #1 reason people love axios.
- Auth injection — usually via a request interceptor (attach a token, refresh on 401).
- Automatic retries — via
axios-retry. - Throws on non-2xx — a 404/500 rejects the promise automatically.
- Built-in timeouts —
timeout: 5000. - Upload/download progress events —
onUploadProgress/onDownloadProgress. - Automatic JSON — serializes request bodies and parses responses.
baseURL, default headers, proxy,withCredentials, XSRF handling.
Why fetch is now the default#
By 2026 most of axios's advantages have eroded:
fetchis everywhere and needs no dependency.- Timeouts are now native:
AbortSignal.timeout(ms). - Cancellation is native:
AbortController. - The interceptor/auth/retry conveniences can be reproduced in a ~15-line wrapper (see below) with no library.
fetchsupports streaming cleanly viaReadableStream, which axios does not.
So why does anyone still use axios in 2026?#
Three reasons, in descending order of legitimacy:
- Inertia (the biggest). Large existing codebases with shared interceptors, auth setups, mocks, and instrumentation built around axios. Migrating is work with little payoff, so it stays.
- Interceptor ergonomics. axios is batteries-included; fetch needs a small wrapper for the same behavior. Some teams prefer that out of the box.
- One genuine technical gap: upload progress.
fetchstill cannot natively report upload progress in 2026 (download progress is doable via streams; upload isn't). For file-upload UIs with a progress bar, axios (built on XHR) still wins here.
For a greenfield project with no upload bars, plain fetch (or a thin wrapper) is
the better default.
axios vs fetch + a wrapper#
Most of what people use axios for — interceptors, auth, retries — is achievable
with a small fetch wrapper that still matches the fetch signature, with zero
loss of streaming:
function makeFetch(getToken: () => string): typeof fetch {
return async (input, init) => {
// "request interceptor": inject auth
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${getToken()}`);
// "retry"
for (let attempt = 0; ; attempt++) {
const resp = await globalThis.fetch(input, { ...init, headers });
// "response interceptor": inspect / log / refresh on 401
if (resp.status >= 500 && attempt < 3) continue;
return resp; // a REAL Response — .body is still a live ReadableStream
}
};
}The wrapper is the interceptor: code before the call edits the request, code
after inspects the response, and a loop adds retries. Because it returns a real
Response, SSE streaming keeps working.
3. How sebuf's client injection works#
The generated client accepts an optional fetch implementation, typed as the
standard fetch signature:
export interface UserServiceClientOptions {
fetch?: typeof fetch;
defaultHeaders?: Record<string, string>;
// ...typed service headers
}
// In the constructor:
this.fetchFn = options?.fetch ?? globalThis.fetch;Every RPC calls it with the fetch shape and consumes a Response:
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(req),
signal: options?.signal,
});
if (!resp.ok) return this.handleError(resp);
return (await resp.json()) as User;For SSE streaming RPCs, the client reads resp.body as a ReadableStream and
parses data: lines.
What this means#
- You can inject any
fetch-compatible implementation: nativefetch,undici,node-fetch,cross-fetch, a custom wrapper, or a test mock. - You cannot inject
axiosdirectly — its signature and response shape don't match. You need an adapter.
4. Using axios with sebuf: the adapter#
To use axios, wrap it as a fetch-compatible function:
import type { AxiosInstance } from "axios";
/**
* Wraps an axios instance so it can be used wherever sebuf expects `fetch`.
* Works for normal request/response RPCs. Does NOT work for streaming (SSE) RPCs.
*/
export function axiosToFetch(instance: AxiosInstance): typeof fetch {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string" ? input
: input instanceof URL ? input.toString()
: input.url;
// Copy request headers across (handles all the shapes fetch allows).
const headers: Record<string, string> = {};
new Headers(init?.headers).forEach((value, key) => {
headers[key] = value;
});
const res = await instance.request<ArrayBuffer>({
url,
method: init?.method ?? "GET",
headers,
data: init?.body ?? undefined,
responseType: "arraybuffer",
validateStatus: () => true, // let sebuf handle error statuses; don't throw
signal: init?.signal ?? undefined,
});
// Copy response headers back.
const responseHeaders = new Headers();
for (const [key, value] of Object.entries(res.headers)) {
if (value != null) responseHeaders.set(key, String(value));
}
// Null-body statuses must carry no body, or the Response ctor throws.
const nullBody = [101, 204, 205, 304].includes(res.status);
return new Response(nullBody ? null : res.data, {
status: res.status,
statusText: res.statusText,
headers: responseHeaders,
});
};
}const client = new UserServiceClient(baseURL, { fetch: axiosToFetch(myAxios) });Three non-obvious correctness points#
Each is a real bug if omitted:
validateStatus: () => true— axios throws on 4xx/5xx by default;fetchresolves and you checkresp.ok. Without this, sebuf'shandleError(resp)path never runs, and you get an axios throw instead of a typedApiError/ValidationError.responseType: "arraybuffer"+new Response(...)— building a realResponsegives.ok,.status,.json(),.text(),.headersfor free and stays binary-safe (proto content-type works, not just JSON).- The null-body-status guard —
new Response(emptyBuffer, { status: 204 })throws aRangeError. axios returns a zero-length buffer for 204, so you must passnull.
5. What the adapter costs#
This is not "axios breaks sebuf" or "sebuf breaks axios." The adapter is a bridge between two things that don't naturally fit, and the cost falls on both sides.
What you lose from axios's side#
- Upload/download progress (
onUploadProgress/onDownloadProgress) — these are per-call and can't be wired through. - Per-call axios config — a custom timeout or option for one specific call. Config you set on the axios instance (the shared stuff) still works fine.
- Auto-throw on non-2xx — intentionally disabled (relocated to sebuf's typed
errors). Not really a loss, but your
try/catch-around-axios habit changes.
What you lose from sebuf's side#
- SSE streaming RPCs — these break. The adapter buffers the whole response
(
responseType: "arraybuffer"), so a stream that never closes never resolves. And you can't fix it withresponseType: "stream": that's Node-only and yields a NodeReadable, not the webReadableStreamthe sebuf SSE parser expects.
What you keep#
Everything that motivates axios in the first place — interceptors, auth tokens,
retries, timeouts, proxy, withCredentials — still works, because the adapter
calls through the configured instance.
Quick reference#
| You inject... | Unary RPCs | SSE streaming RPCs | axios extras (interceptors/auth/retry) |
|---|---|---|---|
native fetch |
✅ works | ✅ works | ❌ n/a (no axios) |
fetch wrapper |
✅ works | ✅ works | ✅ works (re-implemented in the wrapper) |
axios via adapter |
✅ works | ❌ broken | ✅ works (instance-level only) |
Plain-language summary: Through the adapter, normal calls work great and you keep your axios setup — but you give up progress bars, per-call tweaks, and sebuf's streaming endpoints. If you never use progress bars or streaming, you lose nothing that matters.
6. Patches for the three limitations#
6.1 SSE streaming — ✅ clean patch#
Route streaming requests to native fetch and everything else to axios. sebuf
marks SSE requests with Accept: text/event-stream, so you can detect them:
export function axiosOrFetch(instance: AxiosInstance): typeof fetch {
const viaAxios = axiosToFetch(instance);
return (input, init) => {
const isStream =
new Headers(init?.headers).get("Accept") === "text/event-stream";
return isStream ? globalThis.fetch(input, init) : viaAxios(input, init);
};
}Now unary calls go through axios (you keep interceptors/auth/retry) and streaming works (via native fetch).
Caveat: axios interceptors won't run on streaming requests. If auth lives in an interceptor, re-add the token in the
fetchbranch manually.
6.2 Per-call config — 🟡 partial patch#
The most common need — a per-call timeout or cancellation — already works via
the generated CallOptions.signal:
await client.getUser({ id: "1" }, { signal: AbortSignal.timeout(5000) });Arbitrary axios per-call config (custom responseType, etc.) is not
patchable from user space — sebuf would have to widen CallOptions to pass a
per-call init override down to the transport. That's a feature request, not a
workaround.
6.3 Upload/download progress — ❌ needs a sebuf change#
This can't be fixed from outside:
- sebuf consumes the response itself (
resp.json()), so it never hands you a hook to observe progress per call. - The adapter buffers, so even download progress is gone there.
- Upload progress isn't possible in
fetchnatively at all.
Getting progress would require sebuf itself to expose progress callbacks in
CallOptions and thread them through the generated client.
Summary of patches#
| Problem | Patchable? | How |
|---|---|---|
| SSE streaming | ✅ Yes | Hybrid transport — fetch for streams, axios for the rest |
| Per-call timeout/cancel | ✅ Yes | Already works via CallOptions.signal + AbortSignal.timeout |
| Arbitrary per-call config | 🟡 Needs sebuf change | Widen CallOptions to pass a per-call init override |
| Upload/download progress | ❌ Needs sebuf change | Requires progress hooks in the generated client |
7. Recommendation#
- Default to native
fetch. It needs no install, supports every sebuf feature including streaming, and is the standard the client is built around. - If you only want interceptors / auth / retry, write a thin
typeof fetchwrapper (Section 2). Zero dependency, streaming intact. - Use the axios adapter only when you have a large existing axios setup
(shared interceptors, mocks, instrumentation) you genuinely want to reuse — and
accept that streaming RPCs and per-call config won't come along, or use the
hybrid
axiosOrFetchtransport (Section 6.1) to keep streaming.
Why a fetch | AxiosInstance union type is not offered#
It's tempting to let the client accept fetch?: typeof fetch | AxiosInstance, but
it's the wrong trade:
- It forces an
axiostype dependency into otherwise dependency-free generated output — everyone pays, even pure-fetchusers. - It doubles every code path (request building, response parsing, error handling, SSE parsing) for two transports.
- It still can't stream with axios, so the union would be valid for only part of the API — a false promise.
Keeping the contract as the single, standard fetch and adapting non-fetch
clients at the edge (via axiosToFetch) keeps the generated core small,
dependency-free, and honest.
8. Adopting sebuf on a team that loves axios#
This section speaks directly to a common situation: a team is being asked to adopt sebuf, and the frontend engineers push back because they already use axios. The worry is understandable — but it rests on a misunderstanding of what sebuf actually is, and the gap is smaller than it feels. Here is the case, and the solutions.
Start with what sebuf actually replaces#
The pushback usually sounds like "sebuf wants us to drop axios." It doesn't. sebuf is not an HTTP library, and it is not competing with axios. axios answers the question "how do I send an HTTP request?" sebuf answers a completely different question: "where do my request and response types come from, and how do I stop hand-writing and hand-maintaining client code for every endpoint?"
Today, without sebuf, a frontend engineer reads the backend, hand-writes an axios call, hand-types the request and response shapes in TypeScript, and hopes those types stay in sync with the server. When the backend changes a field, nothing tells the frontend — it breaks at runtime, in production. sebuf removes that entire class of work and that entire class of bug. The protobuf definition is the single source of truth, and the Go server, the TypeScript client, and the OpenAPI spec are all generated from it. They cannot drift apart, because they all come from the same file.
So the real comparison is not "axios vs sebuf." It is "hand-written, hand-typed, drift-prone client code vs a generated, always-in-sync, type-safe client." The HTTP mechanism underneath — fetch or axios — is a detail. And that detail is the part the team is allowed to keep controlling.
The key reassurance: you are not forced off axios#
The generated sebuf client has an injection point for the HTTP implementation. The team is not locked into anything. There are four ways to wire it, and three of them let the team keep using axios in some form:
- Native fetch — pass nothing. It just works, including streaming, with zero dependencies. This is the recommended default for new code.
- A thin fetch wrapper — about fifteen lines that reproduce the axios features the team actually relies on: interceptors, auth-token injection, and retries. Everything keeps working, streaming included, and the axios dependency goes away.
- The axios adapter — if the team has a large, established axios setup with
shared interceptors, mocks, and instrumentation they genuinely want to reuse,
the
axiosToFetchadapter lets them route the generated client straight through their existing axios instance. Their interceptors, auth, retries, timeouts, and proxy settings all keep working. - The hybrid transport —
axiosOrFetch, which sends streaming requests through native fetch and everything else through axios, so the team keeps their full axios setup and keeps sebuf's streaming endpoints.
In other words, the answer to "but we use axios" is not "too bad." It is "fine — here are four supported ways to do exactly that, pick the one that fits how much of your axios investment you want to carry forward."
Be honest about the trade-offs#
Credibility matters more than a hard sell, so it is worth being upfront about the two real costs of staying on axios through the adapter.
First, the adapter cannot stream. sebuf's Server-Sent Events endpoints — the live, push-style feeds — rely on fetch's streaming response body, which axios does not provide. If the team uses streaming, they should use native fetch for it, or the hybrid transport that routes streaming through fetch automatically. This is solved; it just needs the right wiring.
Second, a couple of per-call axios conveniences don't survive the adapter: upload and download progress bars, and per-individual-call axios config. The shared, instance-level axios behavior — interceptors, auth, retries — all survives. Only the per-call extras are affected, and the most common one, a per-call timeout, is already handled by the generated client through a standard abort signal.
That honesty is itself part of the pitch. The team's instinct that they might lose something is correct in a narrow way, and naming exactly what — and showing it is either solved or rarely used — is far more persuasive than pretending there is no cost at all.
The reframe that usually lands#
The deepest version of the pushback is emotional, not technical: engineers feel that adopting a code generator takes away control of their own tooling. The reframe is that sebuf gives up the one thing they care about controlling — the HTTP layer, which stays fully injectable — while taking away the tedious, error-prone work they don't actually want to own: writing client boilerplate by hand and keeping frontend types manually in sync with a backend that changes without warning.
Most of what the team loves about axios is interceptors, auth injection, and retries. None of that is unique to axios; all of it is a small wrapper around fetch. What sebuf adds on top — end-to-end type safety, generated clients, automatic validation, and a backend and frontend that can never silently disagree about a contract — is something no HTTP library gives them, axios included.
The path forward is therefore not a fight. It is a menu. Keep axios with the adapter, keep axios plus streaming with the hybrid, or take the opportunity to replace axios with a fifteen-line fetch wrapper and shed a dependency. Every one of those options sits on top of sebuf, and every one of them ends with a frontend whose types are generated, validated, and permanently in sync with the backend. That is the win, and it is available no matter which HTTP client the team chooses to keep.
