Callbacks in JavaScript are a very important concept and we need to understand a bit the difference between them and promises.
In JavaScript functions are first class citizens which means we can also pass them around as arguments. A callback function is just a function passed as an argument to another function and executed inside that function.
So we can do something like:
constcallBack=()=>{console.log("I have been called back at the end of some async task");};constfailedCallBack=()=>{thrownewError("Ooops, that didn't go well..");};constmainFunc=(time, cb)=>{setTimeout(()=>cb(), time);};mainFunc(1000, callBack);mainFunc(2000, failedCallBack);
*Note that the callback may also fail (just as you saw above). The problem with callbacks is that if you nest too many of them, you get to what's called a 'callback hell' (namely too many nested callbacks which have to be handled separately and the code gets messy):
constcb1=(cb)=>{setTimeout(()=>{console.log("cb1 has been called back at the end of some async task");cb();},1000);};constcb2=(cb)=>{setTimeout(()=>{console.log("cb2 has been called back at the end of some async task");cb();},2000);};constcb3=(cb)=>{setTimeout(()=>{console.log("cb3 has been called back at the end of some async task");cb();},3000);};constfailedCallBack=()=>{thrownewError("Ooops, that didn't go well..");};constmainFunc=(time)=>{setTimeout(function(){cb1(function(){cb2(function(){cb3(failedCallBack);});});}, time);};mainFunc(1000);
I hope you can start to see the issue with the callback hell. Even if we re-write the mainFunc() using arrowFunctions, so like this:
We still have the issue of handling the nesting of function calls. So Promises to the rescue!. Promises were introduced with ES6 specifically to address this issue. With promises, our example becomes something like:
constpromise1=()=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log("promise1 has been resolved at the end of some async task");resolve();},1000);});};constpromise2=()=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log("promise2 has been resolved at the end of some async task");resolve();},2000);});};constpromise3=()=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log("promise3 has been resolved at the end of some async task");resolve();},1000);});};constfailedCallBack=()=>{thrownewError("Ooops, that didn't go well..");};constmainFunction=()=>{promise1().then(()=>promise2()).then(()=>promise3()).then(()=>failedCallBack()).catch((err)=>{console.error(`Error: ${err.message}`);});};mainFunction();
We now got rid of the callbacks nesting but we can still do better. We can use async/await:
constpromise1=()=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log("promise1 has been resolved at the end of some async task");resolve();},1000);});};constpromise2=()=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log("promise2 has been resolved at the end of some async task");resolve();},2000);});};constpromise3=()=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log("promise3 has been resolved at the end of some async task");resolve();},1000);});};constfailedCallBack=()=>{setTimeout(()=>{thrownewError("Ooops, that didn't go well..");},500);};constmainFunction=async()=>{try{awaitpromise1();awaitpromise2();awaitpromise3();awaitfailedCallBack();}catch(err){console.log(`Error: ${err.message}`);}};mainFunction();
* Note how this is much more readable and easier to understand and maintain. Next, in order to better understand how all these 3 ways of handling asynchronous code work, I suggest we do a little exercise. Let's say we have a callback style function like this:
* Note that our callback can throw an error or it can successfully log the message. This is very smilar to how actual asynchronous tasks work (they sometimes fail and some other times they succeed). I want to call the main function as a Promise (so to be able to use the .then() syntax and also to be able to await it). The .then() syntax allows chaining promises, which can still get pretty cumbersome at times so I highly recommend using async/await instead that.
If we are to look at some difference between chaining promises and using async/await, it will be the fact that promise chains do not wait for the promise to finish before executing subsequent code (while async functions do it). So for instance the code below:
constpromiseItem1=()=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log("promise1 has been resolved at the end of some async task");resolve();},1000);});};constpromiseHandler=()=>{const val =promiseItem1().then((val)=>console.log(val));console.log("function started...");//note how this logs first};promiseHandler();
*Note how the 'function started' log pops up first so the promise is triggered but the handler does not wait for it to finish before executing the subsequent line, whereas with async/await we get something like this:
constpromiseItem1=()=>{returnnewPromise((resolve)=>{setTimeout(()=>{console.log("promise1 has been resolved at the end of some async task");resolve();},1000);});};constasyncHandler=async()=>{const val =awaitpromiseItem1();console.log("val here", val);//note how this logs firstconsole.log("function started...");//this logs last};asyncHandler();
*Note how now the promise is resolved before the final console log (so the async function 'stops' and waits for the async operation to finish - in this case for the promise to resolve or reject). From this little 'experiment' we can only infer that we may want to simply chain promises instead of using async/await if the operations do not depend on one another and we do not care for their completion when triggering multiple of them. But for most part async/await is the best practice and the recommended way to deal with async code (unless some other constraint forces us to use promises or even worse callbacks).
As for our exercise, let's try to write a function that wraps a 'callback style' function in a promise so we can get this better control over how code executes. Let's use the code below:
constcallBack=(arg)=>{setTimeout(()=>{const defaultArg ="default";if(arg){console.log(arg);return arg;}return defaultArg;},1000);};constfailedCallBack=()=>{returnnewPromise((_, reject)=>{setTimeout(()=>{reject(newError("Ooops, that didn't go well.."));},2000);});};constmain=(time, cb)=>{console.log("Starting main..");setTimeout(()=>{cb();}, time);};main(500,()=>callBack("someArg"));main(700, failedCallBack);
*Note that our callback can also fail. And we want to write a generic promise-like wrapper for it:
constpromiseWrapper=(asyncFunction)=>{returnfunction(...args){returnnewPromise(async(resolve, reject)=>{try{const result =awaitasyncFunction(...args);resolve(result);}catch(err){reject(err);}});};};
* Note how we wrap everything in a function. This is called a closure and in this particular use case it allows us to store the scope and variables of the functions in order to re-use them later on (in our case after the promisification). I figured beginners find it hard to give an example of a closure use-case but this one is pretty standard and commonly used.
Now we can wrap the initial callback-style functions in this wrapper and use them like so:
constcallBack=(arg)=>{setTimeout(()=>{const defaultArg ="default";if(arg){console.log(arg);return arg;}return defaultArg;},1000);};constfailedCallBack=()=>{returnnewPromise((_, reject)=>{setTimeout(()=>{reject(newError("Ooops, that didn't go well.."));},2000);});};constpromiseWrapper=(asyncFunction)=>{returnfunction(...args){returnnewPromise(async(resolve, reject)=>{try{const result =awaitasyncFunction(...args);resolve(result);}catch(err){reject(err);}});};};const promise1 =promiseWrapper(callBack);const promise2 =promiseWrapper(failedCallBack);promise1("someArg").then((res)=>console.log(res));promise2().then((res)=>console.log(res)).catch((err)=>console.log("some err occurred: ", err.message));
We can also put everything in an async function for a cleaner handling like so:
After everything so far you might still be wondering: why bother with this cumbersome re-writing for async operations? The answer is simple: we do this so that we can use legacy (callback style) libraries more uniformly and more predictably by wrapping them in promises (instead of using old-school and cumbersome callbacks) and in async functions. In the NodeJS utils module there already is a 'promisify' function (one slightly more complex than ours, whose implementation you can see here).