@syncsoft/tiny-fetch
Tiny, zero-dependency, axios-compatible HTTP client.
Built on fetch. ~2.5 KB min+gzip. First-class TypeScript. Interceptors,
timeouts, upload progress, and full body-type support — all without a single runtime
dependency.
npm install @syncsoft/tiny-fetch
Why tiny-fetch?
~2.5 KB gzipped
Versus axios' ~13.5 KB. A 5× shrink with no functional loss for the common 90%.
Zero runtime deps
Nothing in your node_modules tree but tiny-fetch itself. No
supply-chain footprint.
Axios-compatible
response.data, error.response.data/status, interceptors —
drop-in for most code.
First-class TypeScript
Types ship with the package. No @types/*. Generic verb helpers infer
your response types.
Interceptors + timeouts
Same interceptor API as axios. Timeouts via AbortController. Upload
progress via XHR.
Browser & Node 20+
Native fetch everywhere. Ships ESM + CJS with proper
exports conditions.
Install
No peer dependencies. No @types/* to install. Node 20+ or any modern
browser.
npm install @syncsoft/tiny-fetch
yarn add @syncsoft/tiny-fetch
pnpm add @syncsoft/tiny-fetch
Quick start
import { HttpClient } from '@syncsoft/tiny-fetch';
const api = HttpClient.create({
baseURL: 'https://api.example.com',
timeout: 10_000,
headers: { 'X-Client': 'my-app' },
});
type User = { id: string; name: string };
const { data } = await api.get<User[]>('/users');
// ^? User[]
API
Creating an instance
import { HttpClient } from '@syncsoft/tiny-fetch';
const api = HttpClient.create(config?: HttpConfig);
Each instance has isolated config and its own interceptor chain.
Config
HttpConfig applies at instance level; RequestConfig extends it
with per-call fields.
| Field | Type | Description |
|---|---|---|
baseURL |
string |
Prepended to relative request URLs. |
timeout |
number |
Milliseconds before the request is aborted via AbortController.
|
headers |
HeadersInit |
Default headers merged into every request. Accepts a record, a
Headers instance, or [string, string][].
|
withCredentials |
boolean |
Send cookies cross-origin (maps to credentials: 'include'). |
responseType |
'json' | 'text' | 'blob' | 'arraybuffer' |
Force response body parser. Inferred from Content-Type when
omitted.
|
paramsSerializer |
(params) => string |
Replace the built-in query-string serializer. |
Per-request only (RequestConfig):
| Field | Type | Description |
|---|---|---|
method |
string |
HTTP method. Verb helpers set this automatically. |
url |
string |
Relative or absolute URL. |
params |
Record<string, unknown> |
Query parameters. Arrays expand to repeated keys; null/undefined
are skipped.
|
data |
unknown |
Request body. See body handling below. |
signal |
AbortSignal |
External cancellation signal. |
Body handling
The data field is passed through unchanged for every native
BodyInit type:
-
FormData— browser setsContent-Typewith multipart boundary -
URLSearchParams— browser setsapplication/x-www-form-urlencoded -
Blob/File—Content-Typecomes fromblob.type ArrayBuffer/ typed arrays (Uint8Array, etc.)ReadableStreamstring
Anything else (plain objects, arrays) is JSON.stringify'd and sent with
Content-Type: application/json. A caller-supplied
Content-Type is always respected.
Verb helpers
api.get<T>(url, config?)
api.post<T>(url, data?, config?)
api.put<T>(url, data?, config?)
api.patch<T>(url, data?, config?)
api.delete<T>(url, config?)
api.head<T>(url, config?)
api.options<T>(url, config?)
api.request<T>(config) // low-level escape hatch
api.upload<T>(url, formData, onProgress?, config?) // XHR-based upload with progress
All return Promise<HttpResponse<T>>.
Response shape
interface HttpResponse<T> {
data: T;
status: number;
statusText: string;
headers: Headers;
config: RequestConfig;
}
Errors
Non-2xx responses throw an HttpError. The shape mirrors axios:
import { isHttpError } from '@syncsoft/tiny-fetch';
try {
await api.get('/users/99');
} catch (err) {
if (isHttpError(err)) {
err.status; // 404
err.response.status; // 404 ← axios-compatible
err.response.data; // parsed body
err.response.headers; // Headers
err.config; // RequestConfig
}
}
isHttpError(error) is the type guard — use it instead of
instanceof across bundler/realm boundaries.
Interceptors
Same model as axios: use(onFulfilled?, onRejected?) returns an id;
eject(id) removes.
Attach an auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (!token) return config;
return {
...config,
headers: { ...config.headers, Authorization: `Bearer ${token}` },
};
});
Redirect to /login on 401 / 403
import { isHttpError } from '@syncsoft/tiny-fetch';
api.interceptors.response.use(undefined, (error) => {
if (isHttpError(error) && (error.status === 401 || error.status === 403)) {
if (typeof window !== 'undefined') window.location.href = '/login';
}
return Promise.reject(error);
});
Retry with exponential backoff
Retry is intentionally not built in — it's ten lines:
export async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 2 ** i * 500));
}
}
throw new Error('unreachable');
}
const { data } = await retry(() => api.get('/flaky'));
Recipes
File upload with progress
fetch cannot report upload progress. upload() uses
XMLHttpRequest under the hood, runs request interceptors (so auth headers
still apply), and exposes a progress callback.
const form = new FormData();
form.append('file', file);
form.append('name', 'report.pdf');
const { data } = await api.upload<{ url: string }>('/files', form, (percent) =>
console.log(`${percent}%`),
);
Binary responses
const { data } = await api.get<Blob>('/report.pdf', { responseType: 'blob' });
const url = URL.createObjectURL(data);
Cancellation
const controller = new AbortController();
setTimeout(() => controller.abort(), 2000);
try {
await api.get('/slow', { signal: controller.signal });
} catch (err) {
// AbortError
}
Custom params serialization
Built-in serializer matches axios defaults (skip null/undefined, repeat arrays). Override per-request or per-instance:
import qs from 'qs';
const api = HttpClient.create({
baseURL: '...',
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
});
Migration from axios
Most code works with a minimal import/create swap:
- import axios from 'axios';
- const api = axios.create({ baseURL: '...' });
+ import { HttpClient } from '@syncsoft/tiny-fetch';
+ const api = HttpClient.create({ baseURL: '...' });
const { data } = await api.get<User[]>('/users');
| axios | tiny-fetch |
|---|---|
axios.create(config) |
HttpClient.create(config) |
response.data |
response.data ✓ |
error.response.data / .status |
error.response.data / .status ✓ |
axios.isAxiosError(e) |
isHttpError(e) |
instance.interceptors.request.use(...) |
same ✓ |
CancelToken |
AbortSignal (pass via config.signal) |
responseType |
same ✓ |
paramsSerializer |
same ✓ |
transformRequest / transformResponse |
use interceptors |
Known gaps vs axios
tiny-fetch is intentionally small. If you need these, reach for axios (or add them on top):
-
Node HTTP agents / proxy config — use
undici.Agentor an HTTPS proxy-aware fetch - Auto CSRF / XSRF token attachment — trivial to add as a request interceptor
- Automatic retry / backoff — see recipe above
-
maxContentLength/maxRedirects— not exposed
Browser & Node support
-
Browsers — any that implement
fetch,AbortController,FormData, andXMLHttpRequest(all evergreen browsers). -
Node.js — 20+ (native
fetch). Upload progress is browser-only since Node has noXMLHttpRequest; userequest()with aReadableStreambody instead.
Build target: ES2020. Ships ESM and CommonJS with proper
exports conditions.