[Translation] Promises vs Observables
When developing Angular 2+ projects, I’ve always used RxJS for asynchronous handling. Angular is highly coupled with RxJS and closely follows its development. By Angular 6, the dependent RxJS version had already been upgraded to version 6.
Admittedly, RxJS isn’t strictly necessary for development - Promises work too. However, you’ll find that RxJS offers more flexibility, richness, and elegance. That said, RxJS has a steep learning curve. In the book “RxJS in Depth,” it’s described as a cliff.
To understand and learn RxJS, I believe comparing it with Promises is an important approach. I recently read a great article that might help everyone get a glimpse of the differences.
Original link: Promises vs Observables
Observables continue to evolve. Angular 2 uses them as the default asynchronous processing solution. You can use their middleware in your React-Redux applications. But why and when should you use them? This article will explain the biggest differences between the two. Understanding these can help you decide which to choose. Even if you ultimately don’t choose Observables, I hope you’ll learn more about Promises.
Note that this article describes how Observables work under RxJS. Other reactive programming libraries may differ.
The article assumes you already have basic knowledge of Promises
. On the other hand, if Observables
are new to you, this article can serve as an introduction. But don’t stop here - Observables have much more to offer. In particular, we barely mention operators, which are heavily used in RxJS and reactive programming.
Single Value vs Multiple Values
Promises are widely used for handling HTTP requests. In this model, you make a request and wait for a response. You can be sure that the same request won’t have multiple responses.
const numberPromise = new Promise((resolve) => {
resolve(5);
});
numberPromise.then(value => console.log(value));
// will simply print 5
But trying to resolve the Promise with another value will fail. Promises always handle the value from the first resolve function call and ignore subsequent calls.
const numberPromise = new Promise((resolve) => {
resolve(5);
resolve(10);
});
numberPromise.then(value => console.log(value));
// still prints only 5
In contrast, Observables allow you to handle (or we call it “emit”) multiple values. Look at this code:
const numberObservable = new Observable((observer) => {
observer.next(5);
observer.next(10);
});
numberObservable.subscribe(value => console.log(value));
// prints 5 and 10
Notice the familiar syntax? We changed Promise to Observable, resolve to observer.next, and then to subscribe - how similar!
This behavior is actually Observables’ biggest selling point. When you think about browser asynchronous sources, you quickly think of single request-single response models that work fine with single requests or setTimeout timers. But there are also these scenarios:
- setInterval
- WebSockets
- DOM events (mouse clicks, etc.)
- Any other type of events (this issue also exists in Node.js)
Although we’ve made some progress with Promises later on, we still end up using terrible callbacks. Doesn’t anyone realize we’ve only solved part of the problem? Thanks to the RxJS authors for their work.
Let’s see how to wrap setInterval with an Observable:
const secondsObservable = new Observable((observer) => {
let i = 0;
setInterval(() => {
observer.next(i++);
}, 1000);
});
secondsObservable.subscribe(value => console.log(value));
// logs:
// 0
// 1
// 2
// and so on, every second
To avoid triggering anonymous undefined values, we initialize the counter i
, then every second we execute observer.next
to pass the i value.
This is an example of an Observable that actually never stops emitting values. So instead of getting a single value from a promise, you get an input that could be any number of values.
Eager vs Lazy
Let’s assume Promises support emitting multiple values, and let’s rewrite the setInterval
example using Promise.
const secondsPromise = new Promise((resolve) => {
let i = 0;
setInterval(() => {
resolve(i++);
}, 1000);
});
We have a problem here: even if no one is listening to these values (we haven’t even logged them), setInterval
is still called immediately when the Promise is created. We’re wasting resources, triggering these values because no one is listening to them. This happens because Promises are eager. You can easily test this:
const promise = new Promise(() => {
console.log('I was called!');
});
The above code will immediately print "I was called!"
to the terminal. In contrast, the Observable-based code:
const observable = new Observable(() => {
console.log('I was called!');
});
This doesn’t happen. This is because Promises are eager, while Observables are lazy. The function passed to the Observable constructor is only invoked when someone actually subscribes to an Observable.
observable.subscribe();
// just now "I was called!" gets printed
This might seem like a small change, but let’s go back to the Observable-wrapped setInterval
:
const secondsObservable = new Observable((observer) => {
let i = 0;
setInterval(() => {
observer.next(i++);
}, 1000);
});
Because of laziness, setInterval
is not called at this time, and even the i
variable isn’t initialized. The function passed to the Observable is just waiting until someone actually subscribes to this Observable.
To summarize this point: initializing a Promise means the process has already started (the HTTP request has been sent), we’re just waiting for the result. This is because the function issued the request the moment the Promise was created. On the other hand, initializing an Observable represents a potential request, but it only triggers when we actually subscribe, potentially saving browser resources by avoiding work that nobody cares about.
Non-cancellable vs Cancellable
Let’s assume Promises are lazy. Imagine in our Promise example, we only call setInterval
when someone is listening to the result. But what if someone stops listening? You might know that setInterval returns a token that can be used to cancel the timer. We should be able to do this when consumers no longer want to listen to events, because resources shouldn’t be wasted when no one is listening.
Actually, some Promise libraries support this. Bluebird Promises support cancellation methods that you can use on a Promise to stop what’s happening inside. Let’s see how to do this to cancel these operations.
const secondsPromise = new Promise((resolve, reject, onCancel) => {
let i = 0;
const token = setInterval(() => {
resolve(i++);
}, 1000);
onCancel(() => clearInterval(token));
});
The key is we pass the onCancel
function, a special callback that can be called when the user decides to cancel the Promise. Cancellation basically works like this:
const logSecondsPromise =
secondsPromise.then(value => console.log(value));
// we print values every second
// (in our imaginary version of Promises),
// but at some point user calls:
logSecondsPromise.cancel();
// from this moment numbers are no longer logged
Note that we cancel the promise from then, with the side effect that values are printed to the console.
Let’s see how to cancel with Observables:
const secondsObservable = new Observable((observer) => {
let i = 0;
const token = setInterval(() => {
observer.next(i++);
}, 1000);
return () => clearInterval(token);
});
Compared to cancellable promises, not much has changed. Instead of passing a function to onCancel
, we just return it.
Cancelling (or, we call it unsubscribing) Observables looks similar:
const subscription =
secondsObservable.subscribe(value => console.log(value));
subscription.unsubscribe();
Notice that subscribe doesn’t return an Observable! This means you can’t chain multiple subscribes like you can with then in promises. Subscribe just returns a subscription to a given Observable. This subscription has only one method: unsubscribe. You unsubscribe when you decide you no longer want to listen to this Observable.
If you’re worried about the lack of chained observables making Observables non-reusable, remember there are plenty of operators, and operators do support chaining.
On the other hand, if you’re concerned about verbose subscription handling, there are operators that let you handle this elegantly. In fact, every operator lets you handle this smartly, ensuring you don’t subscribe to things you don’t need.
Enough talk, let’s continue. Although some Promise libraries support cancellation, ES6 Promises do not. There was a proposal to add cancellation functionality to Promise, but it was rejected. There are still other ways to cancel Promises, but if you compare the languages themselves, you’ll find Observable wins because it was designed with cancellation from the beginning.
Multicast vs Unicast or Multicast
However, there’s another issue with lazy Promises: even if the function is passed to the constructor, it’s only called when someone calls then. What if someone calls then a few minutes later? Should we call this function again, or share the previous result?
Because Promises are not lazy, they naturally implement the second approach - the function passed to the Promise constructor is only called when the Promise is created. This behavior works well for HTTP requests. Consider this simple example that returns after a 1-second delay:
const waitOneSecondPromise = new Promise((resolve) => {
setTimeout(() => resolve(), 1000);
});
Of course, real Promises start computing immediately, but if they were lazy, they should only compute when the user actually needs them.
waitOneSecondPromise.then(doSomething);
doSomething will be called after a 1-second wait. Everything is fine, unless another user also wants to use the same Promise:
waitOneSecondPromise.then(doSomething);
// 500ms passes
waitOneSecondPromise.then(doSomethingElse);
The user naturally expects doSomethingElse to also be called after 1 second, but it will be called after only half a second. Why? Because someone used it before, the function passed to the Promise constructor was called, so setTimeout started and the 1-second countdown began. When we call the second function, half the time has already passed. Because we share the timer, they will be called at the same time, not after 1 second each.
Let’s modify the Promise to log something:
const waitOneSecondPromise = new Promise((resolve) => {
console.log('I was called!');
setTimeout(() => resolve(), 1000);
});
In the previous example, even if then is called a second time, you’ll only see one “I was called!”, which proves there’s only one setTimeout timer instance.
This is more obvious in a special case. If a Promise function were to separately call then
for each user, setTimeout
would be called separately, ensuring their callbacks execute as expected. In fact, this is exactly what Observables do. Let’s rewrite this:
const waitOneSecondObservable = new Observable((observer) => {
console.log('I was called');
setTimeout(() => observer.next(), 1000);
});
Each time we subscribe
, we start our own clock.
waitOneSecondObservable.subscribe(doSomething);
// 500 ms
waitOneSecondObservable.subscribe(doSomethingElse);
Both doSomething
and doSomethingElse
functions will execute 1 second after being subscribed. If you look at the console, you’ll see “I was called” printed twice. This shows that the function passed to the Observable constructor was actually called twice, and setTimeout timers were created twice.
However, it’s worth mentioning that this isn’t always the behavior you want. HTTP requests are something you want to execute only once, but you want to share the result with many subscribers. Observables don’t do this by default, but they can support it, and it’s very simple - you just need to use the share
operator.
Assuming the previous example, we want to call both doSomething
and doSomethingElse
simultaneously, regardless of when we pass them to subscribe, roughly like this:
const sharedWaitOneSecondObservable =
waitOneSecondObservable.share();
sharedWaitOneSecondObservable.subscribe(doSomething);
// 500 ms passes
sharedWaitOneSecondObservable.subscribe(doSomethingElse);
If an Observable shares results among subscribers, we say it’s “multicast” because it delivers a single value to multiple entities. By default, Observables are unicast, meaning each result is delivered to a single, unique subscriber.
So we understand that Observables win again in flexibility: Promises (because they’re eager, not lazy) are always “multicast”, while Observables are unicast by default but can easily be converted to multicast when necessary.
Always Asynchronous vs Possibly Asynchronous
Let’s go back to this very simple example:
const promise = new Promise((resolve) => {
resolve(5);
});
Note that in a function, we resolve synchronously. Because we already have the value, we immediately execute the Promise, ensuring that when the user calls then, the callback function can immediately process this synchronously? Well, no. In fact, it’s always asynchronous. This can be clearly seen here:
promise.then(value => console.log(value + '!'));
console.log('And now we are here.');
First “And now we are here.” is printed, then “5!”, even though the Promise has already resolved that number.
Observables, in contrast, actually emit values synchronously:
const observable = new Observable((observer) => {
observer.next(5);
});
observable.subscribe(value => console.log(value + '!'));
console.log('And now we are here.');
“5!” will appear first, then “And now we are here.”. Of course, we can delay emitting the value, for example by wrapping “observer.next(5)” in setTimeout. So we see that Observables are more flexible. You might think this behavior is dangerous because “subscribe” doesn’t work as expected, but I mentioned that RxJS has many ways to implement event listening asynchronously (if interested, check out the observeOn
operator).
Conclusion
That’s it! If you have other good examples to illustrate the differences between Promises and Observables, please let me know in the comments. What about similarities?
I hope after reading this article, you have the ability to choose which solution to use in your projects.
Hint: Both might work!
Final Thoughts
This translation provides a comprehensive comparison between Promises and Observables, highlighting their key differences in handling asynchronous operations. Understanding these distinctions is crucial for making informed decisions about which approach to use in different scenarios, particularly in modern JavaScript development with frameworks like Angular that heavily leverage RxJS.