Cache function invocation results
Phuoc Nguyen
Posted on February 13, 2024
As the famous computer scientist Phil Karlton once said, "There are only two hard problems in Computer Science: cache invalidation and naming things". While naming things might seem like an easy task, it can be challenging to come up with a name that is both descriptive and concise. The name should give an indication of what the function or variable does without being too long or complicated.
Cache invalidation, on the other hand, is a complex issue that arises when dealing with cached data. Caching is used to speed up computations by storing frequently accessed data in memory. However, if the underlying data changes, the cache becomes invalid, and we need to update it with new data.
To solve this issue, various caching strategies are available such as time-based expiration or event-driven invalidation. Each strategy has its own pros and cons depending on the specific use case.
Event-based cache invalidation is a strategy that involves invalidating the cache when a specific event occurs. This could be an update to the underlying data source or a change in the application state. When the event occurs, the cached data is marked as invalid, and the next time the function is called, it retrieves new data from the source and updates the cache.
Time-based expiration invalidation involves setting an expiration time for the cached data. After a certain amount of time has passed, the cached data is considered stale, and we need to update it with fresh data from the source.
In this post, we'll demonstrate how to use JavaScript Proxy to create a caching mechanism using the time-based expiration invalidation. It's going to be exciting, so let's dive in!
Retrieving weather data
Let's take a look at some sample code to see why caching data is so important. In this example, we'll be using the fetch
function to get weather information for a specific city using the OpenWeatherMap API.
const API_KEY = 'your-api-key-here';
const getWeather = async (city) => {
const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${API_KEY}`;
return fetch(url)
.then(response => response.json())
.then(data => {
const temperature = data.main.temp;
return temperature;
})
.catch(error => console.error(error));
};
To use the API, you'll need to sign up for an API key. Once you have your key, simply replace 'your-api-key-here'
with your actual API key.
For instance, to retrieve the weather of San Francisco, we just need to pass the city's name to the getWeather
function.
const temperature = await getWeather('San Francisco');
To improve your application's performance and reduce costs, it's a smart move to cache the response from the weather API and minimize the number of requests made to the server. This is particularly useful if your application frequently requests weather data. Additionally, caching the data makes sense since it doesn't change frequently.
Now, let's move on to the next section and learn how to easily implement this functionality.
Caching weather data
To improve the performance of our weather data retrieval, we can implement a caching mechanism in the getWeather()
function. This mechanism involves creating a cache
object that will store the API response for each requested city.
Here's how we can update the getWeather()
function:
const API_KEY = 'your-api-key-here';
const cache = {};
const getWeather = async (city) => {
if (cache[city]) {
console.log('Loading weather data from cache...');
return Promise.resolve(cache[city]);
}
const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${API_KEY}`;
return fetch(url)
.then(response => response.json())
.then(data => {
const temperature = data.main.temp;
cache[city] = temperature;
return temperature;
})
.catch(error => console.error(error));
};
In this updated version of getWeather()
, we've implemented a simple caching mechanism to reduce server costs and improve performance. Now, when a user requests weather data for a city, we first check if it's already in our cache
object. If it is, we return the cached value instead of making a new network request.
// Make network request
const temperature1 = await getWeather('San Francisco');
// Load data from cache
const temperature2 = await getWeather('San Francisco');
While this caching mechanism is helpful, it does have some limitations. For example, it's tied to the getWeather()
function and can't be reused elsewhere in your application without modification. Plus, if the cached data becomes outdated, there's no automatic way to refresh it.
To avoid these limitations, it's better to implement the caching mechanism in a separate location and reuse it throughout your application. In the next section, we'll show you how to do just that.
Caching function results with JavaScript Proxy
Instead of using an object to implement caching, we can use a JavaScript Proxy. By creating a Proxy object that wraps around the getWeather()
function, we can store the result of each invocation in a cache object. Here's how we can update the code to use this approach:
const cache = {};
const getWeather = async (city) => {
// The original implementation without caching ...
};
const proxiedGetWeather = new Proxy(getWeather, {
apply: async (target, thisArg, args) => {
const city = args[0];
if (cache[city]) {
console.log('Loading weather data from cache...');
return cache[city];
}
cache[city] = await target.apply(thisArg, args);
return cache[city];
},
});
The proxiedGetWeather
function is a Proxy
object that wraps around the original getWeather()
function. This Proxy
object helps us customize the behavior of the original function.
Here's how it works: when we call the original getWeather()
function, the Proxy
object intercepts that call and executes its own apply
method.
In the apply
method, we first extract the city argument from the list of arguments passed to the intercepted function call. Then, we check if this city is already in our cache object. If it is, we immediately return the cached value instead of making a new network request.
If the city is not in our cache, we make a new network request by invoking the original function using target.apply(thisArg, args)
. We then store this result in our cache object under its corresponding city key and return it.
Using a Proxy
object like this gives us more flexibility and power than a simple caching mechanism with an object. We can intercept and modify any operation performed on our target object, which in this case is our weather API.
Give it a try and see how it can help optimize your code!
// Make network request
const temperature1 = await proxiedGetWeather('San Francisco');
// Load data from cache
const temperature2 = await proxiedGetWeather('San Francisco');
Enhancing cache functionality with expiration time
To improve the proxiedGetWeather
function and make it more robust, we can add an expiration time to our cached data. This will allow us to refresh or invalidate the cache after a certain period.
One way to do this is by adding a timestamp to our cached data that indicates when the data was last fetched. We can then check this timestamp before returning cached data and invalidate the cache if the timestamp is older than our desired expiration time.
Here's how we can update the proxiedGetWeather
function to include this functionality:
const cache = {};
const getWeather = async (city) => {
// The original implementation without caching ...
};
const proxiedGetWeather = (expirationTime = 60 * 60 * 1000) => {
return new Proxy(getWeather, {
apply: async (target, thisArg, args) => {
const city = args[0];
if (cache[city] && Date.now() - cache[city].timestamp < expirationTime) {
console.log('Loading weather data from cache...');
return cache[city].temperature;
}
const temperature = await target.apply(thisArg, args);
cache[city] = {
temperature,
timestamp: Date.now(),
};
return temperature;
},
});
};
The updated proxiedGetWeather()
function creates a Proxy
object that wraps around the original getWeather()
function. This allows us to customize the behavior of the original function by intercepting its calls.
With the proxiedGetWeather()
function, you can pass an optional parameter called expirationTime
, which specifies how long (in milliseconds) cached data should be considered valid before it becomes stale and needs to be refreshed.
When you call proxiedGetWeather()
, the apply
method of the Proxy
object is executed. First, the function extracts the city argument from the list of arguments passed to the intercepted function call.
Next, it checks if the city is already in the cache object and if its timestamp is less than the desired expiration time. If it is, the cached value is returned without making a new network request.
If the city is not in the cache or if its cached data has expired, a new network request is made by invoking the original function using target.apply(thisArg, args)
. The result of this request is then stored in the cache object under the corresponding city key, along with a timestamp indicating when the data was fetched.
To cache weather data with an expiration time of 30 minutes, simply pass the desired time to the proxiedGetWeather()
function.
// Create proxiedGetWeather function with expiration time of 30 minutes
const proxiedGetWeather30min = proxiedGetWeather(30 * 60 * 1000);
// Make network request
const temperature1 = await proxiedGetWeather30min('San Francisco');
// Load data from cache
const temperature2 = await proxiedGetWeather30min('San Francisco');
setTimeout(() => {
// Make new network request
const temperature3 = await proxiedGetWeather30min('San Francisco');
}, 30 * 60 * 1000);
By adding an expiration time to our cached data, we can prevent serving stale or outdated data to our users and ensure they always receive current weather information.
Caching results of generic functions
To improve the reusability of the proxiedGetWeather
function for other API calls, we can modify it to accept a generic function and its parameters as arguments. By doing this, we can cache the result of the function execution, which can significantly improve the performance of the code. Here's how we can update the code:
const cache = {};
const createCacheProxy = (func, expirationTime = 60 * 60 * 1000) => {
return new Proxy(func, {
apply: async (target, thisArg, args) => {
const key = JSON.stringify(args);
if (cache[key] && Date.now() - cache[key].timestamp < expirationTime) {
console.log('Loading data from cache...');
return cache[key].data;
}
const data = await target.apply(thisArg, args);
cache[key] = {
data,
timestamp: Date.now(),
};
return data;
},
});
};
In the latest version of the code, we've added a new function called createCacheProxy
. This function takes a generic function and its parameters as input and returns a Proxy
object that caches the function's results.
To make the caching mechanism more versatile, we're now using JSON.stringify(args)
to generate a unique key for each set of arguments passed to the function. This means we can cache any type of data, not just weather data.
We've also included an optional parameter called expirationTime
, which determines how long cached data should be considered valid before it's discarded.
This updated implementation allows us to easily create new cached functions by passing in any generic function and its parameters. This provides a more flexible and reusable way to cache API calls throughout our application.
Let's put our createCacheProxy
to work with a practical example. In the code below, we have a function called getUsers()
that fetches user data from the https://jsonplaceholder.typicode.com/users
API endpoint. This function is generic and returns a Promise
that resolves to an array of user objects.
// Example usage with another API call
const getUsers = async () => {
const url = 'https://jsonplaceholder.typicode.com/users';
return fetch(url)
.then(response => response.json())
.catch(error => console.error(error));
};
The proxiedGetUsers
function uses createCacheProxy()
to create a new cached version of the getUsers()
function. This means that when we call proxiedGetUsers()
, the function checks if there's already cached data for the arguments provided.
If there is, it returns the cached data instead of making a new network request. If there isn't, it makes a new network request using the original function (getUsers()
) and caches the result for future use. This way, we can reuse the results of the network request without having to make another one every time.
const proxiedGetUsers = createCacheProxy(getUsers);
// Make network request
const users1 = await proxiedGetUsers();
// Load data from cache
const users2 = await proxiedGetUsers();
Conclusion
Overall, using a caching mechanism is crucial for optimizing the performance of web applications that rely on network requests. It helps reduce redundant network requests and improves the overall performance of the application.
In conclusion, this post has explored different methods of implementing caching mechanisms using JavaScript. We started with a simple object-based cache and then moved on to using a Proxy
object to intercept function calls and store their results in a cache.
We also learned how to set an expiration time for our cached data to ensure that our users always receive up-to-date information. Furthermore, we demonstrated how to create a versatile caching function that can be used with any API call.
By leveraging these techniques, you can enhance the performance of your web applications and provide your users with faster and more responsive experiences.
If you found this series helpful, please consider giving the repository a star on GitHub or sharing the post on your favorite social networks π. Your support would mean a lot to me!
If you want more helpful content like this, feel free to follow me:
Posted on February 13, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.