Blog

Observable vs Promise | What's the difference?

Key Takeaways

  • A Promise represents one eventual outcome: fulfillment with a value or rejection with a reason. It can also remain pending.
  • A Promise executor runs synchronously; its .then() handlers run asynchronously as microtasks.
  • RxJS Observables can deliver zero, one, or many values, synchronously or asynchronously. Cold sources start fresh work per subscription; hot or shared sources can behave differently.
  • Unsubscription stops delivery to that subscriber and runs registered teardown. Stopping underlying work requires the source to support cleanup.
  • Use Promises for straightforward single-result workflows and RxJS operator pipelines for composable streams.

These distinctions follow the Promise timing rules and RxJS's subscription model.

The main difference in Observable vs Promise is the result model: a Promise represents one eventual outcome, while an RxJS Observable represents a sequence of notifications that can contain multiple values. Promises are usually simpler for one request; Observables earn their place when you need to compose repeated events and manage their lifetime.

Here, Observable means RxJS's library implementation. RxJS is one implementation of the Observable model, not a synonym for every API named Observable.

Observable vs Promise at a Glance

ConcernPromiseRxJS Observable
ResultsOne fulfillment value or rejection reasonZero, one, or many values per subscription
Starting workThe constructor calls its executor immediatelyDepends on the source; cold sources create work on subscription
Delivery timingSettlement handlers run as microtasksNotifications can be synchronous or asynchronous
Stopping workNo built-in cancellation method; the operation may accept an abort signalUnsubscribe; underlying cancellation depends on teardown and sharing
CompositionPromise chains and async/awaitRxJS operator pipelines

The important caveat is that execution timing, result count, and cancellation are separate concerns—not interchangeable definitions of asynchronous programming.

Running the Examples

The examples target RxJS 7.8.2 and its pipeable-operator API. Operators are imported from the rxjs package root, following the RxJS importing guide. Each JavaScript example is independent; don't paste all of them into the same scope.

With Node.js and npm installed, save one example as example.mjs, then run the following commands. Node treats .mjs files as ES modules; the Fetch example is separately labeled for a browser.

npm install rxjs@7.8.2
node example.mjs

Eager vs Lazy

A Promise Executor Runs During Construction

The function passed to new Promise() is its executor. The constructor calls it immediately, without waiting for .then(). This is the precise meaning of “eager” in this example—not a rule that every Promise controls when an external operation starts.

const myPromise = new Promise((resolve) => {
  console.log('Promise firing!');
  resolve('Promise resolved.');
});

console.log('Before calling then...');
myPromise.then((value) => console.log(value));
Promise firing!
Before calling then...
Promise resolved.

A Cold Observable Defers Its Work Until Subscription

The callback passed to this Observable constructor doesn't run until you subscribe. Calling complete() ends this finite sequence after its value is delivered.

import { Observable } from 'rxjs';

const myObservable$ = new Observable((observer) => {
  console.log('Observable firing!');
  observer.next('Observable called');
  observer.complete();
});

console.log('Before calling subscribe on Observable');
myObservable$.subscribe({ next: (value) => console.log(value) });
Before calling subscribe on Observable
Observable firing!
Observable called

Cold, Hot, and Shared Sources

A cold Observable creates a new producer—the code or resource generating values—for each subscription. Subscribe twice to the example above and its callback runs twice. A hot Observable uses a producer created outside subscription, such as an existing event source.

Sharing lets multiple subscribers observe one producer. Consequently, subscribing doesn't universally mean “start independent work,” and removing one subscriber doesn't necessarily stop work needed by others. The RxJS glossary makes these distinctions explicit.

You can also defer Promise creation by putting it inside a function. The difference is between an already-created Promise and a recipe for creating work, not an inability to control your own function calls.

Sync vs Async

Promise Handlers Are Asynchronous, Even After Immediate Fulfillment

Promise executor work can be synchronous, but handlers registered with .then() run as microtasks after the current synchronous execution finishes. An already-fulfilled Promise doesn't change that rule.

const myPromise = new Promise((resolve) => {
  resolve('Promise resolved.');
});

console.log('Before calling then...');
myPromise.then((value) => console.log(value));
console.log('After calling then...');
Before calling then...
After calling then...
Promise resolved.

Observable Notifications Can Be Synchronous

This source delivers its value before subscribe() returns. Observable subscription callbacks aren't automatically scheduled as microtasks.

import { Observable } from 'rxjs';

const myObservable$ = new Observable((observer) => {
  observer.next('Observable called');
  observer.complete();
});

console.log('Before calling subscribe');
myObservable$.subscribe({ next: (value) => console.log(value) });
console.log('After calling subscribe');
Before calling subscribe
Observable called
After calling subscribe

A Timer-Backed Observable Delivers Later

Here, subscription schedules a timer synchronously, but the timer delivers the value later. The returned function is teardown: cleanup that clears the timer when the subscription is disposed.

import { Observable } from 'rxjs';

const delayed$ = new Observable((observer) => {
  const timer = setTimeout(() => {
    observer.next('Delayed value');
    observer.complete();
  }, 1000);

  return () => clearTimeout(timer);
});

console.log('Before subscribing');
delayed$.subscribe({
  next: (value) => console.log(value),
  complete: () => console.log('Complete'),
});
console.log('After subscribing');
Before subscribing
After subscribing
Delayed value
Complete

The timing distinction looks like this; each row describes its corresponding example, not one combined program.

Current synchronous execution
  Promise:          executor runs; then() registers a handler
  Sync Observable:  subscribe() delivers next and complete
  Timer Observable: subscribe() schedules a timer

Microtask checkpoint
  Promise:          then() handler runs

Later timer task
  Timer Observable: next and complete are delivered

RxJS Operators: Compose Values, Timing, and Lifetime

RxJS supplies operators for composing Observables. A pipeable operator takes a source Observable and returns another Observable; pipe() connects those transformations without modifying the original source.

Start with the original arithmetic example: map() transforms the emitted 2 into 4.

import { Observable, map } from 'rxjs';

const values$ = new Observable((observer) => {
  observer.next(2);
  observer.complete();
});

values$.pipe(map((value) => value * 2)).subscribe({
  next: (value) => console.log(value),
});
4

The benefit becomes clearer with an ongoing source. This periodic refresh counter keeps every second tick, then stops after three retained values:

import { filter, interval, map, take } from 'rxjs';

interval(500).pipe(
  map((tick) => tick + 1),
  filter((count) => count % 2 === 0),
  take(3),
).subscribe({
  next: (count) => console.log(count),
  error: (error) => console.error(error),
  complete: () => console.log('Complete'),
});
2
4
6
Complete

interval(500) produces ascending numbers starting at zero, with its first emission after the first interval. filter() keeps matching values, and take(3) completes after three values reach it. This combines timing, transformation, filtering, and lifetime management in one pipeline.

Cancellability: Unsubscription vs Aborting an Operation

Observable Unsubscription Needs Working Teardown

subscribe() returns a Subscription. Calling unsubscribe() stops notifications to that subscriber and invokes registered teardown; the teardown is what releases resources such as timers.

import { Observable } from 'rxjs';

const ticks$ = new Observable((observer) => {
  let count = 0;
  const timer = setInterval(() => observer.next(++count), 1000);

  return () => {
    clearInterval(timer);
    console.log('Interval cleared');
  };
});

const subscription = ticks$.subscribe({
  next: (value) => console.log('Tick:', value),
  complete: () => console.log('Complete'),
});

setTimeout(() => subscription.unsubscribe(), 2500);

You'll see ticks followed by Interval cleared. Manual unsubscription does not invoke the observer's complete callback. Unsubscribing after a synchronous source has already delivered its values cannot undo those deliveries.

A Promise-Based API Can Support Abort Signals

A Promise has no built-in cancellation method. However, fetch() accepts an AbortSignal, so you can abort the operation separately from the Promise representing its result. The Fetch guide documents both cancellation and response-status handling.

Run this independent example in a browser console on an HTTP(S) page. It requests the current page, then immediately aborts:

async function readPage(signal) {
  const response = await fetch(window.location.href, { signal });
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.text();
}

const controller = new AbortController();
const task = readPage(controller.signal);

task.then((text) => console.log(text.length)).catch((error) => {
  if (error.name === 'AbortError') {
    console.log('Request aborted');
  } else {
    console.error('Request failed', error);
  }
});

controller.abort();

HTTP error responses don't automatically reject the Fetch Promise, which is why the example checks response.ok. Aborting the request produces an AbortError; it doesn't add a cancellation method to task.

Emitting Multiple Values

An Observable can deliver zero, one, or many next notifications, then complete, error, or remain open. Completion and error are terminal: neither permits further notifications to that subscription.

import { Observable } from 'rxjs';

const numbers$ = new Observable((observer) => {
  observer.next(1);
  observer.next(2);
  observer.next(3);
  observer.complete();
});

numbers$.subscribe({ next: (value) => console.log(value) });
1
2
3

A Promise settles at most once. In this example, only the first resolve() call affects the outcome; subsequent calls are ignored.

const myPromise = new Promise((resolve) => {
  resolve(1);
  resolve(2);
  resolve(3);
});

myPromise.then((value) => console.log(value));
1

A Promise can fulfill with an array containing many items, but that array is still one fulfillment value—not a sequence of separate deliveries.

Error Handling and Cleanup

Handle Promise rejection with .catch() or try/catch around await. For an Observable, provide an error handler. An error terminates that subscription without a completion notification, and registered teardown still runs.

import { Observable } from 'rxjs';

Promise.reject(new Error('Promise failed')).catch((error) => {
  console.error(error.message);
});

const failing$ = new Observable((observer) => {
  observer.next('Before failure');
  observer.error(new Error('Stream failed'));
  return () => console.log('Observable cleanup');
});

failing$.subscribe({
  next: (value) => console.log(value),
  error: (error) => console.error(error.message),
  complete: () => console.log('Not called after an error'),
});

For application code, put cleanup in teardown rather than only in a complete handler: completion, failure, and manual unsubscription are different ways a subscription can end.

Converting a Promise to an Observable

RxJS from(promise) adapts fulfillment into a value followed by completion, or rejection into an error notification. It doesn't restart the operation or make the underlying Promise cancellable. Unsubscription can suppress delivery to that subscriber while the original work continues.

Use defer() when you want the Promise-producing function called at subscription time:

import { defer, from } from 'rxjs';

function makePromise() {
  return new Promise((resolve) => {
    console.log('Work started');
    resolve(42);
  });
}

const eager$ = from(makePromise()); // Work starts here.
const lazy$ = defer(makePromise);  // The factory hasn't run yet.

const observer = {
  next: (value) => console.log(value),
  error: (error) => console.error(error),
};

eager$.subscribe(observer);
lazy$.subscribe(observer); // Creates another Promise here.

Each subscription to lazy$ calls makePromise() again. By contrast, subscribers to eager$ observe the same already-created Promise. Deferral changes when work starts, not whether that work supports cancellation.

Which Should You Use?

  • One request or one eventual result: start with a Promise and async/await. See our JavaScript async/await example for that workflow.
  • Ongoing events or repeated values: consider an Observable when filtering, transforming, and controlling a stream's lifetime belong in one pipeline. Our guide to when to use an Observable explores that choice.
  • An existing RxJS application: keeping a single-result operation in an Observable pipeline can still be sensible. Result count is a useful starting point, not the only design constraint.

For WebSocket messages and repeated UI events, Observables fit the repeated-notification model. For a straightforward Fetch result, a Promise is usually enough. Neither abstraction replaces the other; choose the interface that makes your data flow and cleanup easiest to understand.

Common Mistakes

  • Calling all Promise code asynchronous. Separate executor timing from handler timing.
  • Assuming subscriptions share work. Two subscriptions to a cold source create two producers.
  • Unsubscribing without implementing teardown. Closing delivery doesn't automatically clear your timer or abort your request.
  • Using completion as a cancellation callback. Manual unsubscription doesn't send a completion notification.

These mistakes all come from overlooking the boundary between a subscription, its producer, and the underlying operation.

FAQ

When should I use Promises over Observables?

Use a Promise when you need one eventual result, particularly with Fetch and async/await. Observables are useful for ongoing event streams and workflows that benefit from RxJS operator composition.

Are Promise executors synchronous and .then() callbacks asynchronous?

Yes. The executor passed to new Promise() runs synchronously during construction, while .then() callbacks run asynchronously as microtasks, even when the Promise is already fulfilled.

Can you cancel a Promise or an RxJS Observable?

A Promise has no built-in cancellation method, but its underlying operation may support AbortController. An Observable subscription can be unsubscribed; whether that stops underlying work depends on the source's teardown and sharing behavior.

Can I convert a Promise to an Observable?

Yes: use RxJS from(promise) to adapt its result into Observable notifications. Unsubscribing does not cancel the original Promise's work; use defer() if you also need to delay Promise creation until subscription.

Are Observables replacing Promises in modern web development?

No. Promises and Observables solve different interface problems: one eventual outcome versus a potentially ongoing sequence of notifications. Use whichever makes the workflow simpler rather than converting everything to one abstraction.

Further Reading

Written by Sam Erickson

Software engineer. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me