Skip to content

Instantly share code, notes, and snippets.

@vslipchenko
Created March 4, 2025 16:04
Show Gist options
  • Save vslipchenko/9af8cec4e2eea780e4039a7360918a1a to your computer and use it in GitHub Desktop.
Save vslipchenko/9af8cec4e2eea780e4039a7360918a1a to your computer and use it in GitHub Desktop.
This utility provides a flexible request management system for asynchronous API calls. It is designed to optimize API request performance, avoid redundant requests, and manage stale data or errors in dynamic web applications.
// Symbols are used in order to not confuse these special values with the real request response values
// Unique primitive value used as the stale request response value
const staleRequestSymbol = Symbol('staleRequest');
// Unique primitive value used as a response value of non-distinct payload requests
const nonDistinctPayloadSymbol = Symbol('nonDistinctPayload');
// Map are used to guarantee the integrity of request specific context if a single request
// is re-created more than once in a process
const requestsCache = new Map();
const requestsContext = new Map();
// Enum
const cacheScopes = {
// Page scope means that the request cache will be available until the user leaves a page
Page: 'page',
Global: 'global'
};
const invalidRequest = (results) => results === nonDistinctPayloadSymbol || results === staleRequestSymbol;
const containsInvalidRequest = (results) => results.some(invalidRequest);
const cacheEntryExpired = (maxAge, cachedAt) => {
const expiresAt = cachedAt + maxAge;
const now = new Date().getTime();
return now > expiresAt;
};
const removeInvalidCacheEntries = () => {
requestsCache.entries().forEach(([key, value]) => {
const pageScopedCache = value.scope === cacheScopes.Page;
const removeCacheEntry = pageScopedCache || cacheEntryExpired(value.maxAge, value.cachedAt);
if (removeCacheEntry) return requestsCache.delete(key);
});
};
// Usage: const requestData = request(getData, {...options});
// Where 'getData' is a method which returns a pending request (be it $.ajax/Promise/fetch or whatever)
const request = (apiCall, {cache, cacheScope, cacheMaxAge, distinctPayload, disregardStaleRequestResults, disregardStaleRequestError} = {}) => {
cache = cache ?? false;
cacheScope = cacheScope ?? cacheScopes.Page;
cacheMaxAge = cacheMaxAge ?? Infinity;
distinctPayload = distinctPayload ?? false;
disregardStaleRequestResults = disregardStaleRequestResults ?? false;
disregardStaleRequestError = disregardStaleRequestError ?? false;
const useCache = cache;
const useContext = distinctPayload || disregardStaleRequestResults || disregardStaleRequestError;
const initializeCacheEntry = useCache && !requestsCache.has(apiCall);
const initializeContextEntry = useContext && !requestsContext.has(apiCall);
if (initializeCacheEntry) requestsCache.set(apiCall, {results: [], scope: cacheScope, maxAge: cacheMaxAge});
if (initializeContextEntry) requestsContext.set(apiCall, {});
return (...payload) => {
let proceedWithApiCall = true;
const requestContext = requestsContext.get(apiCall);
// Compare current and the previous payload
if (distinctPayload) proceedWithApiCall = !_.isEqual(payload, requestContext.recentPayload);
return new Promise(async (resolve, reject) => {
if (useCache) {
const {results, maxAge, cachedAt} = requestsCache.get(apiCall);
// The default/current cache strategy is based on the request idempotence principle meaning
// that for the given payload the same cached entry will be resolved at all times
const cachedEntry = results.find((item) => _.isEqual(payload, item.payload));
const fetchFromCache = cachedEntry && !cacheEntryExpired(maxAge, cachedAt);
// Resolve cached results if exist
if (fetchFromCache) return resolve(cachedEntry.results);
}
// Try to resolve cached results before resolving nonDistinctPayloadSymbol if payload is not distinct
if (!proceedWithApiCall) return resolve(nonDistinctPayloadSymbol);
const requestTimestamp = new Date().getTime();
// Memoize the current context
if (useContext) requestsContext.set(apiCall, {recentPayload: payload, recentRequestTimestamp: requestTimestamp});
try {
const results = await apiCall(...payload);
if (useCache) {
const requestCache = requestsCache.get(apiCall);
// Check if cache was not cleared before the request completed.
// E.g., if navigated away before the request completed.
// If so, it's considered a stale request
if (!requestCache) return resolve(staleRequestSymbol);
const updatedCachedResults = [...requestCache.results, {payload, results}];
const now = new Date().getTime();
// Update cache entry with new results
requestsCache.set(apiCall, {
...requestCache,
results: updatedCachedResults,
cachedAt: now
});
}
if (disregardStaleRequestResults) {
const requestContext = requestsContext.get(apiCall);
// Check if context was cleared before the request completed.
// E.g., if navigated away before the request completes.
// If so, it's considered a stale request
if (!requestContext) return resolve(staleRequestSymbol);
const staleRequestResults = requestTimestamp !== requestContext.recentRequestTimestamp;
if (staleRequestResults) return resolve(staleRequestSymbol);
}
resolve(results);
} catch (error) {
if (disregardStaleRequestError) {
const requestContext = requestsContext.get(apiCall);
// Check if context was cleared before the request completed.
// E.g., if navigated away before the request completed.
// If so, it's considered a stale error
if (!requestContext) return resolve(staleRequestSymbol);
const staleRequestError = requestTimestamp !== requestContext.recentRequestTimestamp;
if (staleRequestError) return resolve(staleRequestSymbol);
}
reject(error);
}
});
};
};
// Logic to be run when navigating away to perform Maps clean-up
const onNavigateAway = () => {
// Clean up maps when leaving/entering a page
requestsContext.clear();
removeInvalidCacheEntries();
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment