@jaseeey/request-manager
v2.2.0
Published
De-duplicate concurrent Axios HTTP requests so independent callers share one in-flight promise and response
Maintainers
Readme
RequestManager
@jaseeey/request-manager is a small TypeScript library that de-duplicates concurrent HTTP requests made through Axios.
If several parts of your application call the same endpoint at the same time (same Axios client instance, method, and URL), only one network request is made. Every caller receives the same shared promise and resolves or rejects together.
It is intentionally focused: not a full HTTP client, cache layer, or request queue. Use it when concurrent duplicate in-flight calls are the problem you need to solve.
Table of contents
- When to use it
- Related tools
- Installation
- Quick start
- How de-duplication works
- Choosing a manager instance
- API reference
- Effective usage patterns
- TypeScript tips
- CommonJS
- FAQ
- Known limitations
- Background and scope
- Contributing
- License
When to use it
Good fit
- Multiple components mount at once and each load the same resource (
GET /users/me,GET /config, …). - A user double-clicks a button and you want a single in-flight
POST/PUTuntil it finishes. - You already use Axios (or an Axios-compatible
request()surface) and want de-duplication without rewriting your client.
Usually not the right tool
- You need response caching after the request has finished (this library only de-duplicates in-flight requests).
- You need keys based on body or headers (params are part of the key; bodies are not — see Known limitations).
- You need automatic retries, offline queues, or GraphQL batching.
Related tools
Libraries such as TanStack Query, SWR, and Vue Query solve a broader problem: server-state caching, background revalidation, stale-while-revalidate UX, and often retries.
RequestManager is narrower:
| Concern | RequestManager | Query libraries (typical) |
|---------|----------------|---------------------------|
| Collapse concurrent identical in-flight HTTP calls | Yes | Sometimes, as part of a larger cache model |
| Keep a cache after the request finishes | No | Yes |
| Revalidate on focus / interval | No | Yes |
| Requires Axios | Yes (client with request) | Usually fetch or pluggable clients |
You can use both: a query library for cache and lifecycle, and RequestManager underneath an Axios-based API module when multiple non-query call sites still risk duplicate in-flight requests. Do not expect RequestManager alone to replace a query library.
Installation
npm install @jaseeey/request-manager axiosAxios is a peer dependency (axios >= 1). The package does not bundle a client for you; you pass your own AxiosInstance into call(). Install Axios alongside this library if it is not already in your project:
npm install axiosRequires Node.js 18+ for development tooling; the published package is plain ESM/CJS JavaScript for bundlers and Node.
Quick start
ESM (recommended)
import axios from 'axios';
import requestManager from '@jaseeey/request-manager';
const client = axios.create({
baseURL: 'https://api.example.com',
timeout: 10_000
});
// Only one HTTP request is sent; both callers share the result.
const [a, b] = await Promise.all([
requestManager.call(client, 'GET', '/users/me'),
requestManager.call(client, 'GET', '/users/me')
]);
console.log(a === b); // true (same resolved value / same shared completion)Return response data only
import axios from 'axios';
import requestManager from '@jaseeey/request-manager';
const client = axios.create({ baseURL: 'https://api.example.com' });
const user = await requestManager.call(
client,
'GET',
'/users/me',
undefined,
undefined,
(response) => response.data // non-undefined return becomes the promise result
);Handle errors without throwing
import axios from 'axios';
import requestManager from '@jaseeey/request-manager';
const client = axios.create({ baseURL: 'https://api.example.com' });
const result = await requestManager.call(
client,
'GET',
'/users/me',
undefined,
undefined,
undefined,
(error) => {
console.error('Load failed', error);
// Promise resolves to undefined instead of rejecting
}
);How de-duplication works
Request key
Each in-flight request is stored under a key built from:
- Axios client instance (identity, not config equality)
- HTTP method (compared case-insensitively, e.g.
GETandgetmatch) - URL string (exact string match of the
urlargument—not Axios's fully resolved URL) config.params(deterministic serialisation; missing/undefined/empty params are equivalent)
identity = `${clientId}:${method.toLowerCase()}:${url}:${stableParams}`
mapKey = sha256Hex(identity) // fixed-length hex; used as Map key onlyObject key order in params does not matter ({ a: 1, b: 2 } matches { b: 2, a: 1 }). Nested plain objects and arrays are included. URLSearchParams is supported.
The Map key is a SHA-256 hash so long URLs/params do not bloat the map. Full details stay on each entry:
for (const [hash, entry] of requestManager.activeRequests) {
entry.hash; // same as Map key
entry.identity.method; // e.g. 'get'
entry.identity.url;
entry.identity.params; // as supplied (or null)
entry.identity.paramsSerialised;
entry.original; // underlying Axios promise
entry.processed; // shared caller promise
}See also baseURL is not part of the key.
Lifecycle
- First
call()for a key creates the Axios request and stores its promise. - Further
call()s with the same key while the HTTP request is still in flight return the existing processed promise. - When the HTTP request settles (success or failure), the key is removed from the active map. This happens before
onSuccessfinishes if that callback is async or slow. - A later
call()with the same key—including one made from insideonSuccessafter the HTTP response arrived—starts a new network request.
Keeping the key only for the HTTP phase avoids deadlocking when onSuccess itself issues another same-key call() (joining the still-running processed promise would wait forever).
There is no cache of completed responses. De-duplication applies only to concurrent in-flight HTTP work, not to success-callback execution time.
What is and is not part of the key
| Input | Same key? | Notes |
|--------|-----------|--------|
| Same client, method, URL, and params | Yes | Concurrent callers share one Promise |
| Different config.params (different subsets/filters) | No | Separate HTTP requests — correct for different data slices |
| Missing / undefined / {} params | Yes (equivalent) | Treated as “no params” |
| Different data bodies | Yes (same key) | Body is not in the key (mutations are usually user actions) |
| Different headers, timeout, signal, etc. | Yes (same key) | Other config is not in the key |
| Same path, different full URL strings | No | '/users?id=1' and '/users?id=2' are different keys |
| Different Axios instances | No | Separate clients never share de-duplication |
| Different methods | No | GET and POST to the same URL are independent |
Shared promise and callbacks
When a second caller joins an in-flight request:
- It receives the same processed promise as the first caller.
- Only the first caller’s
onSuccess/onErrorrun for that network attempt. - Callbacks passed by later callers are ignored for that in-flight request.
If you need per-caller side effects, prefer:
const response = await requestManager.call(client, 'GET', url);
// each caller runs its own logic after await
updateUi(response.data);Joiners also share promise identity: concurrent callers for the same key receive the same Promise instance, not merely the same eventual value.
const p1 = requestManager.call(client, 'GET', '/users/me');
const p2 = requestManager.call(client, 'GET', '/users/me');
console.log(p1 === p2); // true while the request is in flightAxios interceptors run once
Request and response interceptors on the Axios client run for the single underlying client.request(...). Joined callers do not re-enter interceptors.
That is usually what you want for auth headers, logging, and token refresh: one network attempt, one interceptor chain, many awaiters.
Choosing a manager instance
Default shared instance (most apps)
import requestManager from '@jaseeey/request-manager';Use this when you want app-wide de-duplication for a given Axios client and endpoint. This is the recommended default in browser single-page apps.
The default export is a module-level singleton: one shared RequestManager for the entire JavaScript realm that loaded the module (typically one per browser tab, or one per Node process).
Isolated managers
import { RequestManager } from '@jaseeey/request-manager';
const billingRequests = new RequestManager();
const adminRequests = new RequestManager();Each instance has its own active-request map. The same client/method/URL can run in parallel across different manager instances.
Use isolated managers when:
- You need separate de-duplication domains (e.g. multi-tenant tabs, micro-frontends).
- Tests should not share state with the default singleton (or clear
activeRequestscarefully). - You run SSR or multi-request Node code and must not share in-flight maps across concurrent HTTP requests or users.
Server-side rendering and multi-request Node
On the server, the default export can be shared across concurrent incoming requests in the same process. That may incorrectly join unrelated users' in-flight calls if they hit the same client/method/URL key.
Prefer creating a manager (and often an Axios client) per request or per app context:
import axios from 'axios';
import { RequestManager } from '@jaseeey/request-manager';
export function createRequestContext() {
const api = axios.create({ baseURL: process.env.API_URL });
const requests = new RequestManager();
return { api, requests };
}
// inside a single incoming request handler / RSC context
const { api, requests } = createRequestContext();
await requests.call(api, 'GET', '/users/me');In the browser, the default singleton is usually correct because one tab is one user session.
Legacy static API
import axios from 'axios';
import { RequestManager } from '@jaseeey/request-manager';
const client = axios.create();
await RequestManager.call(client, 'GET', 'https://example.com/health');RequestManager.call(...) is a deprecated compatibility helper. It always delegates to the default shared instance, not to new RequestManager(). Prefer the default export.
API reference
Imports
| Import | Description |
|--------|-------------|
| import requestManager from '@jaseeey/request-manager' | Default shared instance |
| import { RequestManager } from '@jaseeey/request-manager' | Class for new instances / static legacy API |
| import requestManager, { RequestManager } from '@jaseeey/request-manager' | Both |
| import … from '@jaseeey/request-manager/request-manager' | Subpath export of the same surface |
requestManager.call(client, method, url, data?, config?, onSuccess?, onError?)
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| client | AxiosInstance | required | Axios instance used to perform client.request(...) |
| method | Method | required | HTTP method ('GET', 'POST', …) |
| url | string | required | Request URL (relative or absolute, as Axios expects) |
| data | any | {} | Request body (also used when null/undefined is passed → coerced to {}) |
| config | AxiosRequestConfig \| null | {} | Extra Axios config merged into the request (null/undefined → {}) |
| onSuccess | ((response) => T \| Promise<T \| undefined>) \| null | — | Optional success hook; see below |
| onError | ((error) => void) \| null | — | Optional error hook; see below |
Return value
- With no
onSuccess(oronSuccessreturnsundefined): resolves to the fullAxiosResponse. - If
onSuccessreturns a value other thanundefined: resolves to that value (may be async). - If the request fails and
onErroris provided:onErroris invoked and the promise resolves toundefined(does not rethrow). - If the request fails and
onErroris omitted: the promise rejects with the error.
Notes
onSuccessmay return aPromise; it is awaited.onErroris not awaited; keep it synchronous or fire-and-forget async work carefully.- Method matching for de-duplication is case-insensitive.
Instance fields
| Member | Description |
|--------|-------------|
| activeRequests | Map<hash, ActiveRequest> of in-flight entries. Keys are SHA-256 digests; values include identity, original, and processed. Prefer not to depend on hash format in production code. |
ActiveRequest keeps backward-compatible promise fields (original, processed) and adds hash + identity for inspection.
Clearing the map mid-flight is not recommended except in tests. If you clear an in-flight key, a later call() with the same client/method/URL/params will start a new HTTP request while the original promise may still settle independently.
Constructor
const manager = new RequestManager();Creates an isolated manager. The generic type parameter on the class is historical; prefer generics on call() for response typing.
Effective usage patterns
1. Central Axios client + default manager
// api/client.ts
import axios from 'axios';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true
});
api.interceptors.request.use((config) => {
// attach auth headers, etc.
return config;
});// api/users.ts
import requestManager from '@jaseeey/request-manager';
import { api } from './client';
export function fetchCurrentUser() {
return requestManager.call(
api,
'GET',
'/users/me',
undefined,
undefined,
(res) => res.data
);
}Several UI components can call fetchCurrentUser() on mount safely; only one HTTP request runs at a time for that key.
2. Distinguish list/filter loads with URL query or config.params
Either style works; both affect identity:
// Different keys via URL string
await requestManager.call(api, 'GET', `/items?id=${id}`);
// Different keys via config.params (included in the de-dupe key)
await requestManager.call(api, 'GET', '/items', undefined, { params: { id } });
// Same params → same key → one in-flight request for concurrent callers
await Promise.all([
requestManager.call(api, 'GET', '/jobs', {}, { params: { includeArchived: false } }),
requestManager.call(api, 'GET', '/jobs', {}, { params: { includeArchived: false } })
]);Prefer one convention in a given app (query in the URL or params) so keys stay predictable. Mixing both for the same logical filter can create two keys that look “the same” to humans but differ in the key string.
3. Avoid accidental de-duplication of different POST bodies
Concurrent POSTs to the same URL with different bodies currently share one request (first body wins for the network call; all callers share that outcome). If that is wrong for your API:
- Use distinct URLs, or
- Use separate manager instances, or
- Call Axios directly for those operations.
4. Prefer post-await logic over dual callbacks for multi-caller UI
// Preferred when many callers may join one request
try {
const res = await requestManager.call(api, 'GET', '/settings');
applySettings(res.data);
}
catch (err) {
showError(err);
}5. Double-submit protection
async function saveProfile(payload: Profile) {
return requestManager.call(
api,
'PUT',
'/profile',
payload,
undefined,
(res) => res.data
);
}
// Rapid repeated calls while the first is in flight share one PUT.
await Promise.all([saveProfile(data), saveProfile(data)]);Remember: body is not in the key—only use this when concurrent calls are intentionally identical or the first body is acceptable for all joiners.
6. Testing
import requestManager from '@jaseeey/request-manager';
afterEach(() => {
requestManager.activeRequests.clear();
});Or construct new RequestManager() per test file to avoid shared state.
7. Cancellation
This library does not cancel Axios requests. To cancel, pass an AbortSignal in config as you would with Axios. Joined callers still share the same promise; aborting affects the single underlying request.
const controller = new AbortController();
const promise = requestManager.call(
api,
'GET',
'/slow',
undefined,
{ signal: controller.signal }
);
controller.abort();TypeScript tips
import type { AxiosResponse } from 'axios';
import axios from 'axios';
import requestManager from '@jaseeey/request-manager';
interface User {
id: string;
name: string;
}
const client = axios.create();
// Full AxiosResponse
const response = await requestManager.call<User>(client, 'GET', '/users/me');
// response is AxiosResponse<User> when onSuccess is omitted
// Mapped result type via onSuccess return value
const user = await requestManager.call<User, User>(
client,
'GET',
'/users/me',
undefined,
undefined,
(res: AxiosResponse<User>) => res.data
);CommonJS
const axios = require('axios');
const requestManager = require('@jaseeey/request-manager').default;
// or: const { RequestManager } = require('@jaseeey/request-manager');
const client = axios.create();
requestManager.call(client, 'GET', 'https://example.com').then((res) => {
console.log(res.status);
});Package exports:
require('@jaseeey/request-manager')→ CJS buildimport … from '@jaseeey/request-manager'→ ESM build- Types resolve from the ESM declaration entry
FAQ
Does RequestManager cache responses?
No. It only de-duplicates in-flight requests. After a call settles, the next call() with the same key performs a new network request. For caching, revalidation, and background refresh, use a data library (see Related tools) or your own cache.
Why did two different POST bodies share one request?
The de-duplication key ignores data. Concurrent POSTs to the same client, method, and URL string join the first request; later bodies are not sent separately. Use distinct URLs, separate manager instances, or call Axios directly when bodies must not merge. See Avoid accidental de-duplication of different POST bodies.
Do different config.params create separate keys?
Yes. config.params is part of the de-duplication key (stable serialisation). Different filters/subsets produce different keys and separate HTTP requests; identical params still share one in-flight Promise. See Request key.
Can I use fetch instead of Axios?
Not with this package as-is. call() expects an Axios-like client with request(config). You could wrap fetch behind a minimal request() adapter, but that is outside the supported surface.
What if onSuccess returns undefined?
The promise still resolves to the full AxiosResponse. Only a non-undefined return value from onSuccess replaces the result.
How can I see what is in flight?
Inspect requestManager.activeRequests.size or iterate values and read entry.identity for debugging and tests. Treat hash keys as opaque; use identity for human-readable details.
Should I migrate off RequestManager.call?
Yes, when convenient. The static helper is deprecated and always uses the default shared instance:
// Before
await RequestManager.call(client, 'GET', '/users/me');
// After
import requestManager from '@jaseeey/request-manager';
await requestManager.call(client, 'GET', '/users/me');Known limitations
In-flight only (no response cache)
After a request completes, a new call() hits the network again. Add your own cache if you need longer-lived memoisation.
First caller owns callbacks
Only the first in-flight caller’s onSuccess / onError execute. Joiners share the processed promise only.
Key ignores body and most config
De-duplication includes config.params but not request bodies, headers, timeout, or abort signals. Concurrent mutations with different bodies on the same URL can still share one request unless you isolate them.
Exact URL string matching
/users and /users/ are different keys. Relative URLs are not normalised against baseURL for keying—the string you pass is the key segment.
baseURL is not part of the key
Axios may resolve a relative url against the client's baseURL when sending the request, but RequestManager keys only on the url argument string.
const client = axios.create({ baseURL: 'https://api.example.com' });
// These share one in-flight request (same url string: '/users/me')
await Promise.all([
requestManager.call(client, 'GET', '/users/me'),
requestManager.call(client, 'GET', '/users/me')
]);
// This is a different key (different url string), even if it hits the same origin
await requestManager.call(client, 'GET', 'https://api.example.com/users/me');Keep the url argument consistent across call sites (usually the same relative path) so de-duplication works as intended.
Client identity, not configuration equality
Two axios.create({ baseURL: 'https://api.example.com' }) instances are different clients and will not de-duplicate against each other.
Lifecycle / unmount
If multiple components share one in-flight request, unmounting one should not cancel for all unless you coordinate abort signals carefully.
onError is not awaited
Async work inside onError is not tracked by the returned promise.
Default export is shared process-wide
The default requestManager is a single module-level instance for the whole realm. Use new RequestManager() when you need isolation—especially under SSR or multi-tenant Node servers (see Server-side rendering and multi-request Node).
Background and scope
This library was written to solve a concrete problem: concurrent duplicate HTTP calls in application UIs. It is not intended as a general-purpose request framework.
Accepted trade-offs (key design, first-callback wins, in-flight-only) match that goal. If you need different behaviour, fork or wrap the class—RequestManager is small and MIT-licensed.
Features summary
- De-duplicates concurrent requests per Axios instance + method + URL +
config.params - Shared promise for all joiners until the request settles
- Optional
onSuccess/onErrorhooks (with documented join semantics) - Default shared instance and constructible isolated managers
- TypeScript types, ESM + CJS builds
- Legacy
RequestManager.callstatic helper (deprecated)
Contributing
Contributions are welcome. Fork the repository, open a pull request, and keep changes focused. Run tests with:
npm ci
npm test
npm run buildLicense
RequestManager is released under the MIT License.
