This article isn’t a complete guide to Promises or RxJS Observables. Instead, it focuses on their differences and guides you on when to use each. I’ve linked some resources inside - just a heads-up.
RxJS Observables Vs Promises In JavaScript: What You Need To Know
Angular developers know what the Observer pattern and RxJS Observables are practically from birth - it’s like they "absorbed it with their mother’s milk". Things are a bit different for React developers or those using other JS frameworks that don’t strictly enforce these concepts. For them, a Promise is the go-to tool for handling async operations.
So, we’re dealing with two different worlds here - Angular, built around the reactive approach, and, well, everything else outside of it (yes, I’m oversimplifying; I know there are others out there too). So, which one deserves your attention? When is each approach worth using? What are the key differences between them, and why are you still reading this intro? Let’s dive right into the meat 🍗!
Today, we’ll compare both approaches - specifically, RxJS Observables and Promises - since the Observer pattern itself isn’t directly comparable to Promises (as I’ll explain in the next section).
Promise is not a design pattern like Observer; it’s an abstraction layer created to handle asynchronous operations in a dev-friendly way - though whether it actually achieves that is another story 👈(゚ヮ゚👈).
Eager Vs Lazy Code Evaluation
As a kid, my mom always asked me to take out the trash. I’d respond to every request with, "In a minute". Code works similarly. We can handle tasks immediately - like my mom would, the eager approach - or we can delay them, like me: the lazy approach.
Here’s an example of both in TypeScript:
import { z } from 'zod'
type MaybeUser = {
username: unknown
email: unknown
}
// @@@ Eager @@@
// Validates and parses user data immediately upon each call
const eagerUserCheck = (maybeUser: MaybeUser) => {
const user = z
.object({
username: z.string().min(4).max(50),
email: z.string().min(1).email(),
})
.parse(maybeUser)
return user
}
// User type inferred from eagerUserCheck's return value
type User = ReturnType<typeof eagerUserCheck>
// Immediate validation for each call; schema is created and validated each time
eagerUserCheck({ username: `XD`, email: `piotr1994@gmail.com` })
eagerUserCheck({ username: `jaro`, email: `jaro@gmail.com` })
// @@@ Lazy @@@
// Creates the schema once, then validates user data on each call
const lazyUserCheck = () => {
const schema = z.object({
username: z.string().min(4).max(50),
email: z.string().min(1).email(),
})
return (maybeUser: MaybeUser) => {
const user = schema.parse(maybeUser)
return user
}
}
// User type inferred from lazyUserCheck's return value
type UserLazy = ReturnType<ReturnType<typeof lazyUserCheck>>
// Reuses schema for each validation, optimizing resource use
const checkUser = lazyUserCheck()
checkUser({ username: `XD`, email: `piotr1994@gmail.com` })
checkUser({ username: `jaro`, email: `jaro1994@gmail.com` })
If you're interested in comparing typed validation libraries, here’s a handy article for you: Searching For The Holy Grail In Validation World.
In programming, it’s essential to avoid unnecessary work. In the example shown, instead of recreating the schema each time, we used currying and a closure, allowing the first function call to return another one with schema
in its scope. By assigning it to a variable, we could skip the schema creation step.
Now that you've seen the difference in the code, let's highlight these distinctions in a cool table ☜(゚ヮ゚☜):
Feature | Eager Code Evaluation | Lazy Code Evaluation |
---|---|---|
Execution Timing | Executes immediately | Delays execution until needed |
Memory Usage | Higher, loads all data upfront | Lower, loads data only when required |
Performance | Faster if data is needed immediately | Better by skipping unnecessary work |
Use Case | For data that will definitely be used | For data that may not be needed |
Predictability | Predictable, straightforward order | Conditional, less predictable |
Resource Efficiency | May waste resources | Uses resources only when necessary |
In this case, the lazy approach is better. Why waste time creating and validating an object on every call when you can create it once and reuse it?
It’s also worth mentioning that many built-in browser APIs support both approaches. Take the HTML <img />
tag as an example. It includes an interesting loading="eager|lazy"
attribute, which controls the image loading behavior - either loading immediately or only when the user scrolls to the section where the image appears.
This approach can be applied almost anywhere. It doesn’t always make sense, and sometimes it’s completely unnecessary, as it adds a bit of "complexity," but the places where it’s worth it will be obvious to the naked eye.
The Zod user might ask: "Bro, why not create the schema outside?" Sure, you can do that, but it’s not lazy - it’s eager. The schema will be created as soon as the module is imported, making it eager.
You shouldn’t use the lazy approach by force. It should be purpose-driven - for example, to avoid executing long-running tasks immediately upon importing a module or calling a function. Instead, you want to manage these tasks in steps.
The queue data structure is another example of lazy evaluation in code.
What Is A Promise?
We know what eager and lazy approaches are. So, what is this Promise? It’s a broad topic, so I’ll stick to the essentials, assuming you already know the basics if you’re here.
If you want to know everything, check out the article All About JavaScript Promise.
Imagine you have an asynchronous operation. In older JavaScript, you’d handle it with callbacks - a function passed as an argument to another function.
This often led to messy, hard-to-manage code:
Callbacks Hell
The Promise
API is nothing more than an additional layer of abstraction containing methods, properties, and information about any asynchronous operation. So, instead of using callbacks as before, we now have a cohesive approach.
function fetchDataPromise(url) {
return new Promise((resolve) => {
setTimeout(() => {
const data = `Data from ${url}`
resolve(data)
}, 1000)
})
}
async function fetchData() {
const data = await fetchDataPromise("https://example.com")
}
fetchData()
However, despite the nice syntax, something is missing… Promises are great for handling one-time asynchronous operations, but they have their limitations. Don’t worry, I’ll summarize everything at the end of the article, so let’s keep going and not take up space with extra writing.
What Is The Observer Pattern And Observable?
A moment ago, you saw how Promise
provides a "nice" way to handle asynchronous operations, but it lacks features like canceling an operation or listening for specific events from anywhere in the code.
There’s a design pattern - the Observer - that enables notifying certain parts of an application about events or changes without constant checking. This is, once again, a lazy approach.
The Observer pattern itself centers on a one-to-many relationship. There is one source of information, the Subject (or Observable), which manages a list of Observers - the pieces of code interested in receiving updates on changes.
So, it can be understood like this:
- A YouTube channel is the Subject or Observable.
- An Observer is like someone who wants to know when a new video drops - they hit the subscribe button. Sometimes, the Observer is also called a Subscriber.
I write more about this in a dedicated article Understanding Observer Pattern In TypeScript.
So, with the basic theory covered, we can see how it looks in code:
// Helper types for Observer pattern implementation
type Subscription = () => void
type Unsubscribe = () => void
// The "data" parameter is our "Subject" from definition
type Next<Data> = (data: Data) => void
// Interface defining the API of the "Observer" pattern implementation
interface Observable<Data> {
// Subscribe to changes with a callback function
subscribe(next: Next<Data>): Unsubscribe
// Unsubscribe all listeners
unsubscribeAll(): void
// Get the current data snapshot
snapshot(): Data
}
// Factory function to create an Observer instance
const Observer = <Data>(initialData: Data): Observable<Data> => {
// Data to pass through
let currentData = initialData
// Stores a mapping of unique IDs to callback functions
const subscriptions = new Map<string, Next<Data>>()
return {
// Subscribe to changes and return an unsubscribe function
subscribe: (next) => {
const id = new Date().toISOString()
subscriptions.set(id, next)
// Unsubscribe function
const unsubscribe: Unsubscribe = () => {
subscriptions.delete(id)
}
return unsubscribe
},
// Unsubscribe all listeners
unsubscribeAll: () => subscriptions.clear(),
// Update data and notify all subscribed functions
next: (data) => {
// Set new data
currentData = data
// Notify all subscribed functions with the new data
subscriptions.forEach((sub) => {
sub(currentData)
})
},
// Get the current data snapshot
snapshot: () => currentData,
}
}
And here’s how it’s used:
// Create an Observer instance with initial data
const user = Observer({ id: 1, username: "piotr1994" })
// Subscribe to changes and log data
const unsubscribe = user.subscribe((data) => {
// Log the data whenever the "next" function is called
console.log(data)
})
// Trigger a data update
user.next({ id: 2, username: "piotr1994" })
// Log the latest data snapshot
console.log(user.snapshot())
// Unsubscribe, and the callback inside "subscribe" will no longer be triggered
unsubscribe()
// Remove all listeners that have been created
user.unsubscribeAll()
In reality, the Observer pattern itself doesn’t give us "better control" over asynchronous operations. It’s simply a way to notify other parts of the code that something has happened and to pass additional information lazily.
When handling an asynchronous operation, the callback passed to the subscribe
function dictates what should happen.
subscribe(async (data) => {
It’ll be a Promise anyway
console.log(data)
})
The pattern, in the implementation I showed, also doesn’t provide any "cancellation" of operations or queuing. It simply offers the ability to propagate - everything else still needs to be implemented manually.
The RxJS Library To The Rescue
Now, you might be wondering - what’s the point of this article? Give me a moment to explain.
A Promise
is a built-in abstraction in JavaScript, designed to simplify asynchronous operations.
The Observer pattern is a design pattern that enables communication between modules within an application by allowing them to listen for changes.
An Observable (also known as a Subject) is a component of the Observer pattern. It is an entity whose state is being observed by other parts of the code, which are notified when any changes occur.
Exactly, and with these definitions, how can anyone compare a Promise
to an Observable? It doesn't make sense...
To understand why, we need some context. There’s a library called RxJS that implements the Observer pattern, adding extensive functionality and enabling the transformation of a Promise
into an Observable
, which can then be subscribed to.
That’s why developers working in Angular who never code in plain JS might not even know what a Promise
is - they’ve simply never needed it...
// Converting a Promise to an Observable
const observable = from(promise);
// Subscribing to the Observable to get the result.
// It's like "try - catch - finally"
observable.subscribe({
next: (data) => console.log('Received data:', data),
error: (error) => console.error('Error:', error),
complete: () => console.log('Completed')
});
What’s more, RxJS not only lets you forget about Promise
, it also allows you to cancel operations, queue them, run them in parallel, perform side effects, map, repeat - all in a declarative way.
import { of, from, concat, merge, interval, Observable } from 'rxjs'
import { delay, map, mergeMap, takeUntil, tap, repeat, catchError } from 'rxjs/operators'
// Example observable that mimics an async operation (e.g., HTTP request)
const fetchData = (id: number): Observable<string> =>
of(`Data for ID: ${id}`).pipe(
delay(1000), // Mimics delay for async operation
tap(() => console.log(`Fetching data for ID ${id}`))
)
// Observable to cancel operations
const cancel$ = interval(5000) // Cancel all operations after 5 seconds
// Queuing operations with `concat`
const queued$ = concat(
fetchData(1),
fetchData(2),
fetchData(3)
).pipe(takeUntil(cancel$)) // Will cancel if 5 seconds pass
// Running operations in parallel with `merge`
const parallel$ = merge(
fetchData(1),
fetchData(2),
fetchData(3)
).pipe(takeUntil(cancel$))
// Performing side effects with `tap`
const withSideEffects$ = fetchData(4).pipe(
tap(data => console.log(`Side effect: Received ${data}`)),
takeUntil(cancel$)
)
// Mapping results to a different format
const mapped$ = fetchData(5).pipe(
map(data => data.toUpperCase()),
takeUntil(cancel$)
)
// Repeating an operation every 3 seconds
const repeat$ = interval(3000).pipe(
mergeMap(id => fetchData(id)),
repeat(3), // Repeat 3 times
takeUntil(cancel$)
)
// Combining all examples
const combined$ = merge(
queued$,
parallel$,
withSideEffects$,
mapped$,
repeat$
).pipe(
catchError(error => of(`Error occurred: ${error}`))
)
// Subscribing to the combined observable
combined$.subscribe({
next: result => console.log(result),
error: error => console.error(`Error: ${error}`),
complete: () => console.log('All operations completed or canceled')
})
To see the difference, here’s the same code operating with plain promises:
// Helper function to simulate async data fetching
function fetchData(id) {
return new Promise((resolve, reject) => {
console.log(`Fetching data for ID ${id}`)
setTimeout(() => resolve(`Data for ID: ${id}`), 1000)
})
}
// Cancel token to stop promises (we'll use a boolean flag)
let isCanceled = false
// Function to cancel ongoing operations
function cancelOperations() {
isCanceled = true
console.log('Operations canceled')
}
// Queuing operations with promises
function queueOperations() {
fetchData(1)
.then(data => {
if (!isCanceled) console.log(data)
return fetchData(2)
})
.then(data => {
if (!isCanceled) console.log(data)
return fetchData(3)
})
.then(data => {
if (!isCanceled) console.log(data)
})
.catch(error => console.error(`Error: ${error}`))
}
// Running operations in parallel
function runInParallel() {
Promise.all([fetchData(1), fetchData(2), fetchData(3)])
.then(results => {
if (!isCanceled) results.forEach(data => console.log(data))
})
.catch(error => console.error(`Error: ${error}`))
}
// Side effects using a .then chain
function withSideEffects() {
fetchData(4)
.then(data => {
if (!isCanceled) {
console.log(`Side effect: Received ${data}`)
}
return data
})
.catch(error => console.error(`Error: ${error}`))
}
// Mapping results by transforming data within .then
function mapData() {
fetchData(5)
.then(data => {
if (!isCanceled) console.log(data.toUpperCase())
})
.catch(error => console.error(`Error: ${error}`))
}
// Repeating an operation with setInterval and promise chaining
function repeatOperation(times, interval) {
let count = 0
const intervalId = setInterval(() => {
if (count < times && !isCanceled) {
fetchData(count + 1).then(data => {
if (!isCanceled) console.log(data)
})
count++
} else {
clearInterval(intervalId)
console.log('Repeating operation completed or canceled')
}
}, interval)
}
// Initiate all operations
console.log("Starting all operations...")
queueOperations()
runInParallel()
withSideEffects()
mapData()
repeatOperation(3, 3000) // Repeat 3 times every 3 seconds
// Cancel all operations after 5 seconds
setTimeout(cancelOperations, 5000)
As you can see, it’s much more complicated and prone to bugs. RxJS handles the "meat" - operators implementation, allowing you to focus only on defining the logic and what each function should do.
To make it even more interesting, I’ve shown you only a fraction of this library's capabilities. I recommend exploring the list of operators and other examples to see how it can be effectively used for managing asynchronous codebases as well as implementing event driven application architecture - but that’s a topic for another day.
Comparison
I’ll compare the RxJS Observable
implementation with native JS Promise
, since comparing it to my Observer pattern implementation doesn’t make sense. It lacks a lot of functionality, and as an Observer pattern, it focuses only on broadcasting information to subscribers.
Feature | RxJS Observable | Native JavaScript Promise |
---|---|---|
Eager Vs Lazy | Lazy - Only runs when subscribed | Eager - Runs immediately upon creation |
Cancellation | Can be canceled by unsubscribing | Cannot be canceled once started |
Multiple Values | Emits multiple values over time | Resolves/rejects with a single value |
Control Flow | Full control - pause, resume, retry, or complete | Limited - can't control once resolved |
Composition | Rich operators (map, filter, merge, etc.) | Limited - chaining .then() and .catch() |
Push Vs Pull | Push-based - actively emits values | Push-based - emits a single result or error |
Error Handling | Integrated error handling (catchError , retry ) | Basic .catch() |
Performance | Efficient for streams and repeated values | Efficient for single async tasks |
Concurrency | Manages concurrency with operators (merge, concat) | Limited - uses Promise.all for setup |
Execution Context | Runs only with subscribers | Runs immediately when created |
Memory Management | Frees resources on unsubscribe | May need special handling to prevent leaks |
Lifetime Control | Controlled by subscription - can complete or persist | Resolves once, can’t restart |
Data Transformation | Broad transformations through operators | Manual transformation in .then() |
Asynchronous Iteration | Suitable for event streams, emits values over time | Resolves once, suitable for single events |
Testing | Easier to mock and control timing | Limited - executes once immediately |
Use Cases | Real-time data, events, repeated async tasks | Single async events, data fetching |
If you’re interested in another pattern based on the Observer - the Mediator - check out this Mediator Pattern In TypeScript article.
FAQ
Is A Promise
Truly Eager?
When you create a Promise
, its code begins executing immediately, even before you call .then()
or await
it. This is because a promise represents a computation that has already started and will eventually produce a result. The await
keyword or .then()
just specifies what to do with the result once it's ready; it doesn’t control when the Promise
starts executing.
const promise = new Promise((resolve) => {
console.log("Executing...");
resolve("Done");
});
console.log("Before .then");
Executing...
Before .then
In the logs, we see that the function passed to the Promise
constructor is executed immediately, demonstrating that the Promise
has eager behavior, as it runs without needing await
or .then()
.
When To Use RxJS Vs Promise?
Additional dependencies aren’t always necessary. If you’re using Angular, go with RxJS; don’t complicate things with Promises… Use what the framework and its creators recommend.
All other use cases depend on the problem at hand. If you have complex UI requirements - chat, type-ahead, or similar features - it’s worth considering RxJS, as it will likely fit naturally.
Additionally, it’s worth mentioning that RxJS is tree-shakable.
Summary
Someone unfamiliar with RxJS but familiar with the Observer pattern and Promise
would be mind-blown hearing the phrase - "Hey, what do you think about Promise
vs Observable
?" But now you know where this question comes from, especially among Angular developers.
If you're using Angular, use RxJS to handle async stuff - it works much better and is part of the framework anyway. Convert anything that is Promise
based to Observable
to fit into the RxJS world.
However, if you're writing JS code, for example in React, and your asynchronous code is just simple API requests, then Promise
is sufficient. You can always use AbortController
and a simple flag to try canceling the request and "letting go" of the response.
Let’s choose tools based on the problems at hand. Angular itself is highly reactive. Unfortunately, React, as well as JavaScript, are not.
RxJS is a great way to simplify asynchronous code in any JS-based app, but everything in moderation.
In closing, remember - the Observer pattern doesn't make sense to compare with Promise
. But its extended implementation on steroids in RxJS does.