Introduction

Velocity is a high-performance, zero-dependency HTTP client designed for modern web applications. Built on top of the native Fetch API, it provides a powerful abstraction layer that handles common tasks like JSON parsing, request/response interceptors, sequential polling, and automatic retries without adding bulk to your bundle.

Velocity is designed to be a drop-in replacement for Axios in many scenarios, offering a similar API but with a focus on size (under 3KB gzipped) and modern TypeScript features.

Key Features

• Zero external dependencies — leverages native Fetch and AbortController. • Type-safe by default — written in TypeScript with deep generic support. • Built-in Sequential Polling — perfect for background jobs and status checks. • Advanced Retry Logic — exponential backoff and custom retry conditions. • Interceptor Hooks — modify requests and responses globally. • Lightweight & Universal — works in Browser, Node.js, and Edge environments.

Getting Started

Velocity is available via NPM and other package managers. It requires Node.js 18 or later for server-side usage.

Installation

bash
npm install velocity-http

Or using Yarn/PNPM:

bash
yarn add velocity-http
# or
pnpm add velocity-http

Your First Request

The quickest way to start is by creating a new instance. While you can use the static methods, creating an instance allows you to share configuration across multiple requests.

typescript
import Velocity from 'velocity-http';
// Create a configured instance
const api = new Velocity({
baseURL: 'https://api.example.com/v1',
headers: {
'Accept': 'application/json'
}
});
// Make a simple GET request
async function fetchUsers() {
try {
const response = await api.get('/users');
console.log('Users:', response.data);
console.log('Status:', response.status);
} catch (error) {
console.error('Failed to fetch users:', error.message);
}
}
fetchUsers();
Velocity automatically parses JSON responses if the 'Content-Type' header is set to 'application/json'. No need to call .json() manually!

The Client Instance

The Velocity constructor takes a configuration object that sets the default behavior for all requests made through that instance.

typescript
const api = new Velocity({
// The base URL for all relative request paths
baseURL: 'https://api.myapp.com',
// Default headers sent with every request
headers: {
'X-Client-Name': 'MyWebSDK',
'Content-Type': 'application/json'
},
// Request timeout in milliseconds (default: 30000)
timeout: 5000,
// Whether to send cookies for cross-origin requests
withCredentials: true,
// Custom metadata passed to interceptors
meta: { source: 'dashboard' }
});
Be careful when setting global headers like 'Authorization'. For dynamic tokens, it's often better to use Interceptors (Hooks).

Overriding Configuration

You can override the instance defaults on a per-request basis by passing a configuration object as the last argument to any request method.

typescript
// This request will ignore the instance timeout and use 10s instead
const largeData = await api.get('/heavy-report', {
timeout: 10000,
headers: {
'X-Custom-Header': 'specific-value'
}
});

Core Requests

Velocity provides shorthand methods for all common HTTP verbs. Each method is generic, allowing you to define the expected response data type.

GET Requests

Used to retrieve data. Query parameters can be passed via the 'params' object in the configuration.

typescript
// GET /users?role=admin&active=true
const response = await api.get('/users', {
params: {
role: 'admin',
active: true,
tags: ['web', 'mobile'] // Expanded to ?tags=web&tags=mobile
}
});

POST, PUT, PATCH

These methods accept a 'data' object as the second argument. Velocity automatically stringifies objects to JSON and sets the 'Content-Length' (where applicable).

typescript
const newUser = await api.post('/users', {
name: 'John Doe',
email: 'john@example.com'
});
const updatedUser = await api.put('/users/1', {
name: 'John Smith'
});

DELETE & Others

DELETE requests usually don't have a body, but you can still pass configuration as the second argument.

typescript
await api.delete('/users/1');
// For less common methods, use the generic request method
await api.request({
method: 'HEAD',
url: '/health'
});

Interceptors (Hooks)

Interceptors allow you to run code before a request is sent or after a response is received. They are powerful tools for cross-cutting concerns like authentication, logging, and error normalization.

Request Interceptors (onRequest)

Use onRequest to modify the configuration before it reaches the fetch executor. This is the ideal place to inject dynamic authentication tokens from localStorage or a state manager.

typescript
api.onRequest(async (config) => {
const token = await getAuthToken();
return {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${token}`
}
};
});
Interceptors are asynchronous. Velocity will wait for the returned promise to resolve before proceeding with the request.

Response Interceptors (onResponse)

Response interceptors allow you to transform the response data or handle errors globally before they reach your try/catch blocks.

typescript
api.onResponse((response) => {
// Transform data globally
if (response.data && response.data.wrapped) {
response.data = response.data.payload;
}
return response;
});

Ejecting Interceptors

Both onRequest and onResponse return an object with an 'eject' method, allowing you to remove the interceptor later if needed.

typescript
const logger = api.onRequest(config => {
console.log('Sending request to:', config.url);
return config;
});
// Later...
logger.eject();

Sequential Polling

Velocity includes a first-class sequential polling engine. Unlike setInterval, it waits for each request to complete (or fail) before starting the timer for the next attempt. This prevents 'request piling' and ensures your backend isn't overwhelmed.

typescript
const finalResult = await api.get('/jobs/status/123', {
poll: {
interval: 2000, // Wait 2s between attempts
maxAttempts: 20, // Total attempts before giving up
validate: (data, response, attempt) => {
console.log(`Polling attempt ${attempt}...`);
return data.status === 'COMPLETED';
}
}
});
The 'validate' function can be asynchronous. If it returns true, the poll promise resolves with the last successful response. If maxAttempts is reached without validation, it throws a PollingError.

Manual Cancellation

You can stop an active polling sequence at any time using the instance's cancelPolling() method.

typescript
api.cancelPolling();

Retry Mechanism

Network flakiness happens. Velocity can automatically retry failed requests based on your criteria.

typescript
const data = await api.get('/flaky-endpoint', {
retry: {
attempts: 3, // Retry up to 3 times
delay: 1000, // Wait 1s between retries
statuses: [502, 503, 504], // Only retry on these status codes
}
});

Custom Retry Conditions

For more complex logic, use the 'shouldRetry' predicate.

typescript
await api.post('/order', body, {
retry: {
attempts: 2,
shouldRetry: (response, attempt) => {
// Don't retry if we got a specific business error
if (response.data?.code === 'OUT_OF_STOCK') return false;
return response.status >= 500;
}
}
});

Timeout & Cancellation

Managing request lifecycles is crucial for performance and user experience. Velocity provides robust support for both automatic timeouts and manual cancellation.

Request Timeouts

You can set a timeout in milliseconds. If the request takes longer, it will be aborted and a TimeoutError will be thrown.

typescript
try {
await api.get('/slow-query', { timeout: 2000 });
} catch (error) {
if (error.kind === 'TimeoutError') {
console.error('Request timed out!');
}
}

Manual Cancellation (AbortController)

Velocity fully supports the native AbortSignal API. This is especially useful in React components to cancel requests on unmount.

typescript
const controller = new AbortController();
api.get('/long-running-task', { signal: controller.signal });
// Later, to cancel:
controller.abort();

File Handling

Uploading and downloading files is straightforward with Velocity.

Uploading Files

To upload files, use FormData. When using FormData, you should explicitly set the 'Content-Type' header to null so the browser can automatically set it with the correct boundary.

typescript
const formData = new FormData();
formData.append('file', myFileInput.files[0]);
await api.post('/upload', formData, {
headers: { 'Content-Type': null }
});

Downloading Files

Use the 'responseType' option to receive data as a Blob or ArrayBuffer.

typescript
const response = await api.get('/download/report.pdf', {
responseType: 'blob'
});
// Create a download link
const url = window.URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = 'report.pdf';
a.click();

TypeScript Mastery

Velocity is built with TypeScript from the ground up, offering deep type safety for every part of your API layer.

Strict Response Typing

Every request method accepts a generic type parameter representing the structure of the returned data. This flows through to the .data property of the response.

typescript
interface User {
id: string;
username: string;
email: string;
}
const res = await api.get<User[]>('/users');
// res.data is User[]
// res.status is number
// res.ok is boolean

Typing Configurations & Hooks

You can use the exported types to create reusable configuration objects or typed interceptors.

typescript
import type { VelocityConfig, RequestHook } from 'velocity-http';
const sharedConfig: VelocityConfig = {
timeout: 5000,
headers: { 'X-Source': 'mobile-app' }
};
const authHook: RequestHook = async (config) => {
return { ...config, headers: { ...config.headers, 'Auth': '...' } };
};

Handling Error Types

Velocity exports a 'VelocityError' class and a 'VelocityErrorKind' type to help you handle different failure scenarios gracefully.

typescript
import { VelocityError } from 'velocity-http';
try {
await api.get('/data');
} catch (err) {
if (err instanceof VelocityError) {
console.log(err.kind); // 'HTTPError' | 'TimeoutError' | 'NetworkError' ...
console.log(err.response?.status);
}
}

Best Practices

To get the most out of Velocity in production, we recommend following these patterns.

Centralized API Service

Instead of importing Velocity everywhere, create a centralized service file. This makes it easier to manage base URLs and global interceptors.

typescript
// services/api.ts
import Velocity from 'velocity-http';
export const apiClient = new Velocity({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000
});
apiClient.onRequest(config => {
const token = localStorage.getItem('token');
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});

React Integration

When using Velocity inside React components, always use an AbortController in useEffect to prevent memory leaks and state updates on unmounted components.

typescript
useEffect(() => {
const controller = new AbortController();
apiClient.get('/profile', { signal: controller.signal })
.then(res => setProfile(res.data))
.catch(err => {
if (err.kind === 'CancelError') return;
handleError(err);
});
return () => controller.abort();
}, []);

Environment Variables

Never hardcode your baseURL. Use environment variables to switch between development, staging, and production environments.

Common Gotchas

Even with a simple API, there are a few common pitfalls to be aware of when working with HTTP clients and the Fetch API.

Missing 'return' in Interceptors

Interceptors are pipelines. If you forget to return the config or response object, the chain will break, and your request will likely hang or fail.

typescript
// ❌ WRONG
api.onRequest(config => {
config.headers['X-Key'] = 'value';
// Missing return!
});
// ✅ CORRECT
api.onRequest(config => {
config.headers['X-Key'] = 'value';
return config;
});

File Uploads & Content-Type

When sending FormData, browsers need to set a specific 'boundary' string in the Content-Type header. If you manually set 'Content-Type': 'multipart/form-data', it will lack this boundary and the server will fail to parse the files.

Always set 'Content-Type': null when using FormData to let the browser handle it automatically.

React State Updates on Unmount

If a request finishes after a component has unmounted, updating state will cause a warning (and potentially memory leaks). Always use an AbortController to cancel pending requests.

Velocity makes this easy by accepting a 'signal' in the config object.

React Integration

Velocity is ideally suited for React applications. While you can use it directly in useEffect, we recommend a reactive hook pattern to handle loading states, errors, and automatic cancellation gracefully.

The 'useFetch' Pro Hook

Copy this hook into your project (e.g., hooks/useFetch.ts). it features deep dependency tracking and automatic AbortController management.

typescript
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import Velocity, {
type VelocityConfig,
type VelocityResponse,
} from "velocity-http";
type VelocityMethod =
| ((url: string, config?: VelocityConfig) => Promise<VelocityResponse<any>>)
| ((url: string, data?: any, config?: VelocityConfig) => Promise<VelocityResponse<any>>);
interface FetchPayloadType<T, R> {
client: VelocityMethod;
url: string;
options?: VelocityConfig;
body?: T;
manual?: boolean;
dontCall?: boolean;
}
export const useFetch = <T = unknown, R = any>({
client,
url,
options,
body,
manual = false,
dontCall = false,
}: FetchPayloadType<T, R>) => {
const [data, setData] = useState<R | null>(null);
const [loading, setLoading] = useState<boolean>(!manual && !dontCall);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const optionsKey = useMemo(() => JSON.stringify(options), [options]);
const bodyKey = useMemo(() => JSON.stringify(body), [body]);
const fetchData = useCallback(
async (overrides?: VelocityConfig) => {
if (abortControllerRef.current) abortControllerRef.current.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setLoading(true);
setError(null);
try {
const finalConfig = { ...options, ...overrides, signal: controller.signal };
let response;
if (body !== undefined) {
response = await (client as Function)(url, body, finalConfig);
} else {
response = await (client as Function)(url, finalConfig);
}
if (!controller.signal.aborted) {
const result = response?.data ?? null;
setData(result);
}
} catch (err: any) {
if (err.name !== "AbortError") {
const msg = err.message || "Something went wrong";
setError(msg);
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
},
[client, url, optionsKey, bodyKey]
);
useEffect(() => {
if (!manual && !dontCall) fetchData();
return () => abortControllerRef.current?.abort();
}, [fetchData, manual, dontCall]);
return { data, loading, error, refetch: fetchData };
};

Example Usage

Here is how you would use the hook in a real-world component to fetch a list of users.

typescript
import { useFetch } from './hooks/useFetch';
import { apiClient } from './services/api';
function UserProfile({ userId }) {
const { data, loading, error, refetch } = useFetch({
client: apiClient.get,
url: `/users/${userId}`,
// 🚀 Advanced options: params, timeout, and custom headers
options: {
params: { details: 'full' },
timeout: 5000,
headers: { 'X-Requested-With': 'Velocity' }
}
});
if (loading) return <LoadingSpinner />;
if (error) return <ErrorView message={error} retry={refetch} />;
return (
<div>
<h1>{data.name}</h1>
<button onClick={() => refetch()}>Refresh Data</button>
</div>
);
}

Real-world Recipes

These patterns show how to solve common production challenges using Velocity.

JWT Auth with Refresh Tokens

A robust pattern for handling expired tokens. If a request fails with 401, we try to refresh the token and then replay the original request.

typescript
let isRefreshing = false;
let failedQueue: any[] = [];
api.onResponse(async (response) => {
const { config, status } = response;
if (status === 401 && !config.meta?.isRetry) {
if (isRefreshing) {
return new Promise((resolve) => {
failedQueue.push((token: string) => {
config.headers['Authorization'] = 'Bearer ' + token;
resolve(api.request(config));
});
});
}
isRefreshing = true;
config.meta = { ...config.meta, isRetry: true };
try {
const { data } = await api.post('/auth/refresh');
const newToken = data.accessToken;
isRefreshing = false;
failedQueue.forEach(cb => cb(newToken));
failedQueue = [];
config.headers['Authorization'] = 'Bearer ' + newToken;
return api.request(config);
} catch (err) {
isRefreshing = false;
failedQueue = [];
window.location.href = '/login';
throw err;
}
}
return response;
});

Handling Paginated APIs

A simple helper to fetch all pages of a resource.

typescript
async function fetchAll<T>(url: string) {
let results: T[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const res = await api.get(url, { params: { page } });
results = [...results, ...res.data.items];
hasMore = res.data.totalPages > page;
page++;
}
return results;
}

Community & Support

Velocity is an open-source project and we love contributions from the community!

Contributing

Found a bug or have a feature request? Open an issue on our GitHub repository. We welcome pull requests for bug fixes, documentation improvements, and new features.

Check out our 'Good First Issue' label on GitHub to get started!

Need Help?

• GitHub Discussions: For general questions and architectural advice. • Stack Overflow: Use the 'velocity-http' tag. • Twitter: Tag us @velocity_http for quick updates.

API Reference

A quick reference for the most common methods and configuration options.

Velocity Instance Methods

• get<T>(url, config) - Make a GET request. • post<T>(url, data, config) - Make a POST request. • put<T>(url, data, config) - Make a PUT request. • patch<T>(url, data, config) - Make a PATCH request. • delete<T>(url, config) - Make a DELETE request. • request<T>(config) - Generic request method. • onRequest(hook) - Add a request interceptor. • onResponse(hook) - Add a response interceptor. • cancelPolling() - Abort any active polling sequence.

VelocityConfig Options

• baseURL?: string - Prepended to every relative URL. • headers?: HeadersInit - Request headers. • params?: Record<string, any> - URL query parameters. • timeout?: number - Request timeout in ms (default 30000). • responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer' - How to parse response. • signal?: AbortSignal - Native AbortSignal for manual cancellation. • withCredentials?: boolean - Send cookies with cross-origin requests.

RetryOptions

• attempts: number - Number of retry attempts on failure. • delay?: number - Milliseconds to wait between retries. • statuses?: number[] - HTTP status codes that trigger a retry. • shouldRetry?: (response, attempt) => boolean - Custom retry predicate.

PollOptions

• interval: number - Milliseconds between each poll attempt. • maxAttempts?: number - Hard cap on total attempts. • validate: (data, response, attempt) => boolean | Promise<boolean> - Return true to stop polling.

VelocityResponse Object

• data: T - The parsed response body. • status: number - HTTP status code. • statusText: string - HTTP status message. • headers: Headers - Response headers object. • config: VelocityConfig - The configuration used for the request. • ok: boolean - True if status is 2xx.

Built with care by Kamlesh Sahani