Async javascript is much more fun when you memoize.
Here’s something resembling an interview question. The question isn’t very well defined, but it serves point… so bear with me.
Let’s take the following interface for making soup (mmmmm):
async function getSoupRecipe(<soupType>)
async function hireSoupChef(<soupRecipe:requiredSkills>)
async function buySoupPan()async function makeSoup(<soupChef>, <soupRecipe>, <soupPan>)
The rules are:
- Provide me with some code for making my favorite soup.
- To make soup you need a recipe, a chef, and a soup pan.
- The above code is a loose interface for a few methods which should get you what you need to make the soup
- Mess around with the interface, if it makes for better code
OK, so let’s get started and churn out some of these methods.
async function getSoupRecipe(soupType) {
return await http.get(`/api/soup/${soupType}`);
}
async function buySoupPan() {
return await http.get(`/api/soupPan`);
}
async function hireSoupChef(requiredSkills) {
return await http.post(`/api/soupChef/hire`, {
requiredSkills: requiredSkills
});
}
async function makeSoup(soupChef, soupRecipe, soupPan) {
return await http.post(`api/makeSoup`, {
soupRecipe, soupPan, soupChef
});
}
Great, that was straightforward… but oh crap, how difficult is it to actually call these, if I want some soup..?! Let’s make it easier and add another method to orchestrate all of this:
async function makeSoupFromType(soupType) {
let soupRecipe = await getSoupRecipe(soupType);
let soupPan = await buySoupPan();
let soupChef = await hireSoupChef(soupRecipe.requiredSkills);
return await makeSoup(soupChef, soupRecipe, soupPan);
}
OK, great, so we’re done? Not quite.
This code works, but it’s just going to cycle through every step one by one. We’re really hungry for our soup, so let’s try to make it a bit faster. Surely there’s stuff we can do in parallel?
As it happens, yes! The soup pan doesn’t actually have any dependencies on anything else being ready first — so I can buy it in parallel while I’m fetching the recipe:
async function makeSoupFromType(soupType) {
let [soupRecipe, soupPan] = await Promise.all([
getSoupRecipe(soupType),
buySoupPan()
]); let soupChef = await hireSoupChef(soupRecipe.requiredSkills);
return await makeSoup(soupChef, soupRecipe, soupPan);
}
Great, this is a bit faster, but … now my code is pretty tightly coupled with what-happens-when. I’m making things way more imperative, by explicitly stating ‘these two methods must run in parallel’ and ‘this method must run afterwards’. Gee, I hope I don’t ever have to maintain this code in future.
Oh wait, yes I do, because I didn’t even get it right. Hiring my soup chef really only needs a recipe, but before I even start the hiring process, I’m waiting for the soup pan, and that could take days…
I’m still going to be waiting way longer than I need to get my soup. Ho hum. OK then, let’s take this to the logical extreme and try to fix the code again:
async function makeSoupFromType(soupType) {
let soupRecipePromise = getSoupRecipe(soupType);
async function hireSoupChefWithSoupRecipe(_soupRecipePromise) {
let soupRecipe = await _soupRecipePromise;
return await hireSoupChef(soupRecipe.requiredSkills);
}
let [ soupRecipe, soupPan, soupChef ] = await Promise.all([
soupRecipePromise,
buySoupPan(),
hireSoupChefWithSoupRecipe(soupRecipePromise)
]);
return await makeSoup(soupChef, soupRecipe, soupPan);
}
Well, cool, it’s as fast as it can possibly be… but that’s really about the only good thing that can be said about this code.
- Since I don’t want to get my soup recipe twice, I’m having to explicitly cache the promise for it. Before now, I didn’t even have to really think about promises, now I have one hanging around in my code where it’s not welcome.
- At this point, to exacerbate the problem I had before, my code really cares what order things happen in… I literally had to define a new inner function to ensure the only thing blocking hiring my soup chef was retrieving the recipe.
- This method takes a lot of effort to read and understand, and probably even more to maintain and change in future. What if someone absent-mindedly adds an `await` to that cached promise, thinking I’ve missed it? Everything gets slow again…
So how do we fix this? Well, consider the problem we have in the above method. We needed to get a soup recipe, but we didn’t want to fetch it twice, so we had to manually cache it ourselves.
There’s a better way. Let’s take the functional approach, and use memoization instead:
function memoize(method) {
let cache = {};
return async function() {
let args = JSON.stringify(arguments);
cache[args] = cache[args] || method.apply(this, arguments);
return cache[args];
};
}
This will turn any async function into a memoized one — that is, if it’s called twice with the same arguments, it will return the same cached value the second time.
Notice, by the way, how we’re not using `await` in our memoize function— the thing we’re actually caching is the promise returned by the method, not the final value. This means that we don’t even need to wait for the async method to return anything, before we cache it’s future value. Awesome!
Now let’s re-implement our original methods using this… and let’s change around that interface a little, for good measure. What if everything just takes `soupType` as a parameter?
let getSoupRecipe = memoize(async function(soupType) {
return await http.get(`/api/soup/${soupType}`);
});
let buySoupPan = memoize(async function() {
return await http.get(`/api/soupPan`);
});
let hireSoupChef = memoize(async function(soupType) {
let soupRecipe = await getSoupRecipe(soupType)
return await http.post(`/api/soupChef/hire`, {
requiredSkills: soupRecipe.requiredSkills
});
});
let makeSoup = memoize(async function(soupType) {
let [ soupRecipe, soupPan, soupChef ] = await Promise.all([
getSoupRecipe(soupType), buySoupPan(), hireSoupChef(soupType)
]);
return await http.post(`api/makeSoup`, {
soupRecipe, soupPan, soupChef
});
});
Cool! Now I can safely call any of these methods, at any time, and be guaranteed that I won’t double-invoke any of my http apis. Remember that we’re caching/memoizing promises? And promises can only resolve a single time? That means after the first call, I’ll always get a cached result, even if I call one of these methods again before it has returned for the first time.
All of these methods now only require `soupType` as a parameter, making them super easy to integrate and call elsewhere in my codebase. In fact, I don’t even need to define a `makeSoupFromType` method any more, to chain everything together: `makeSoup` already does the job for me.
Moreover, there’s now no more explicit code defining what order these functions need to run in. They fetch their own dependencies, and are automatically run in the fastest possible order.
I don’t even have to think through the problem any more… I can just look forward to my soup. Woot.