From RxJS 6 to 7 and 9: Changed APIs, the Native Observable and When to Upgrade an Angular Project
An Angular 22 project should stay on RxJS 7.8 today: Angular's peer range is ^6.5.3 || ^7.4.0, and a build with the RxJS 9 beta breaks on Angular's own rxjs/operators imports. With real runs we look at RxJS 7's firstValueFrom, connectable and share, the behaviour change hidden in migrating publishReplay, why RxJS 8 never shipped, RxJS 9's Symbol operators and platform Observable, and the native Observable in Chromium.
Version note: The examples in this post were run with RxJS 7.8.2 and 9.0.0-beta.1, @rxjs/test and @rxjs/migrate 9.0.0-beta.1, RxJS 6.6.7 for comparison, TypeScript 6.0, Node.js 24, Vite 8.3 and Angular 22.2 (the Angular project uses TypeScript 6.0, the range Angular 22.2 supports); the browser examples ran in headless Chromium and WebKit driven by Playwright 1.65. npm versions and browser support were checked on 14 November 2026.
Predict First
In an old module of Bookstore, the online bookstore used as the shared example across this site's series, code from the RxJS 6 days turns a book's stock changes for the day into a Promise. Today the stock never changed, so the stream completes without a value. Let us await the same stream the RxJS 6 way and the RxJS 7 way:
import { EMPTY, firstValueFrom, lastValueFrom, of } from "rxjs";
// the stock changes of a book that had no change today: the stream completes without a value
const todaysChanges$ = EMPTY;
// RxJS 6 style: deprecated since RxJS 7
const legacy = await todaysChanges$.toPromise();
console.log("toPromise:", legacy);
// RxJS 7 style
try {
const last = await lastValueFrom(todaysChanges$);
console.log("lastValueFrom:", last);
} catch (err) {
console.log("lastValueFrom failed:", (err as Error).name, "-", (err as Error).message);
}
console.log("with defaultValue:", await lastValueFrom(todaysChanges$, { defaultValue: 0 }));
console.log("firstValueFrom(of(12, 11, 9)):", await firstValueFrom(of(12, 11, 9)));
console.log("lastValueFrom(of(12, 11, 9)):", await lastValueFrom(of(12, 11, 9)));
What does toPromise() return? What does lastValueFrom do with the same stream?
We used RxJS 7.8 throughout the series. In this last post we look at where RxJS came from and where it is going: the APIs that changed from RxJS 6 to 7 and moving off the deprecated publish family, RxJS 8, which was never released, RxJS 9, which went into beta in August 2026, and the native Observable arriving in browsers. At the end we answer the real question: when should an Angular 22 project upgrade?
What RxJS 7 Brought
Running it:
toPromise: undefined
lastValueFrom failed: EmptyError - no elements in sequence
with defaultValue: 0
firstValueFrom(of(12, 11, 9)): 12
lastValueFrom(of(12, 11, 9)): 9
toPromise() quietly resolved with undefined on the empty stream; the calling code cannot tell "stock 0" from "no value". That is why RxJS 7 deprecated toPromise, corrected its return type to Promise<T | undefined> and brought two functions in its place. firstValueFrom takes the first value and closes the subscription; lastValueFrom waits for completion and gives the last value. Both reject with EmptyError when no value arrives; if the empty case has a meaningful answer, you pass a defaultValue. The 7.8.2 typings say toPromise will be removed in v8.
We already used RxJS 7's (April 2021) other changes in this series:
| RxJS 6 | RxJS 7 | In this series |
|---|---|---|
toPromise() | firstValueFrom, lastValueFrom | This post |
retryWhen | retry({ count, delay, resetOnSuccess }) (since 7.3) | The error handling post |
timeout(ms) + timeoutWith | timeout({ first, each, with }) | The time-based operators post |
share() (no options) | share({ connector, resetOnError, resetOnComplete, resetOnRefCountZero }) | The multicasting post |
publish, multicast, refCount | connectable, connect, share | This post |
import { map } from 'rxjs/operators' | import { map } from 'rxjs' (since 7.2) | Every example |
RxJS 7's changelog also mentions a large size reduction in the operator implementations. We measured it with our own search pipeline: we built the same code (debounceTime, map, distinctUntilChanged, switchMap, catchError) with RxJS 6.6.7, 7.8.2 and 9.0.0-beta.1, minified with Vite. Like the Angular CLI, we picked the ES2015 builds of RxJS 6 and 7, not the ES5 ones:
v6: 17.1 kB minified, 5.0 kB gzip
v7: 15.7 kB minified, 5.1 kB gzip
v9: 17.3 kB minified, 5.1 kB gzip
For this small pipeline the difference is 0.1 kB gzipped. The size gain may be more visible in a large application that uses many operators, but it is not a reason to upgrade on its own. We also fell into a trap while measuring: with Vite's default settings the ES5 build of RxJS 7 was picked and the bundle came out at 21.5 kB instead of 15.7 kB (6.5 kB instead of 5.1 kB gzipped). That is why the Angular CLI adds the es2015 condition itself.
When moving from 6 to 7, the eslint-plugin-rxjs-x we set up in the custom operator post helps: no-topromise in the recommended config finds the remaining toPromise calls, and prefer-root-operators finds the rxjs/operators imports.
From publish and refCount to share and connectable
In the multicasting post we said RxJS 7 deprecated publish, publishReplay, multicast and refCount, and left the migration to this post. The most common pattern is sharing an HTTP request with publishReplay(1) and refCount(). The first replacement that comes to mind is shareReplay({ bufferSize: 1, refCount: true }):
// the category list: one request, answered after 200 ms
const categories$ = defer(() => {
log("-> GET /api/categories");
return timer(200).pipe(map(() => "classics, fantasy"));
});
function showSharing(shared: Observable<string>) {
shared.subscribe((list) => log(`nav: ${list}`));
shared.subscribe((list) => log(`select: ${list}`));
timer(500).subscribe(() => shared.subscribe((list) => log(`late: ${list}`)));
}
runInVirtualTime("RxJS 6: publishReplay(1) + refCount()", () => {
showSharing(categories$.pipe(publishReplay(1), refCount()));
});
runInVirtualTime("RxJS 7: shareReplay({ bufferSize: 1, refCount: true })", () => {
showSharing(categories$.pipe(shareReplay({ bufferSize: 1, refCount: true })));
});
--- RxJS 6: publishReplay(1) + refCount()
0 ms -> GET /api/categories
200 ms nav: classics, fantasy
200 ms select: classics, fantasy
500 ms late: classics, fantasy
--- RxJS 7: shareReplay({ bufferSize: 1, refCount: true })
0 ms -> GET /api/categories
200 ms nav: classics, fantasy
200 ms select: classics, fantasy
500 ms late: classics, fantasy
When everything goes well the outputs are the same: one request, two subscribers, and the late subscriber at 500 ms gets the cached value. If the first request gets a 503, things change:
runInVirtualTime("after an error, RxJS 6: publishReplay(1) + refCount()", () => {
showRecovery(flakyCategories$.pipe(publishReplay(1), refCount()));
});
runInVirtualTime("after an error, RxJS 7: shareReplay({ bufferSize: 1, refCount: true })", () => {
showRecovery(flakyCategories$.pipe(shareReplay({ bufferSize: 1, refCount: true })));
});
// the exact replacement from the RxJS deprecation guide: never reset, keep the error too
runInVirtualTime("after an error, RxJS 7: share() with a ReplaySubject, no resets", () => {
showRecovery(
flakyCategories$.pipe(
share({
connector: () => new ReplaySubject(1),
resetOnError: false,
resetOnComplete: false,
resetOnRefCountZero: false,
}),
),
);
});
--- after an error, RxJS 6: publishReplay(1) + refCount()
0 ms -> GET /api/categories
200 ms nav failed: HTTP 503
500 ms late failed: HTTP 503
--- after an error, RxJS 7: shareReplay({ bufferSize: 1, refCount: true })
0 ms -> GET /api/categories
200 ms nav failed: HTTP 503
500 ms -> GET /api/categories
700 ms late: classics, fantasy
--- after an error, RxJS 7: share() with a ReplaySubject, no resets
0 ms -> GET /api/categories
200 ms nav failed: HTTP 503
500 ms late failed: HTTP 503
publishReplay uses a single ReplaySubject, and the error stays in that Subject too: the late subscriber gets the same error without sending a new request. shareReplay resets on error and sends the request again for the late subscriber. In this example the new behaviour is better, but it is still a change in behaviour; the exact equivalent, which RxJS's deprecation guide also recommends, is share with all three reset options set to false. Code moved with a mechanical find-and-replace can behave differently when something fails; in the multicasting post we saw why caching a failed response is a problem. When you migrate, decide which one you want and test that behaviour.
The counterpart of the ConnectableObservable you get from publish() is connectable(). We ran both with the same polling stream, and the nine lines of output were identical:
runInVirtualTime("RxJS 6: publish() + connect()", () => {
const shared = stock$.pipe(publish()) as ConnectableObservable<number>;
shared.subscribe((i) => log(`screen: poll ${i + 1}`));
shared.subscribe((i) => log(`badge: poll ${i + 1}`));
shared.connect();
});
runInVirtualTime("RxJS 7: connectable() + connect()", () => {
const shared = connectable(stock$);
shared.subscribe((i) => log(`screen: poll ${i + 1}`));
shared.subscribe((i) => log(`badge: poll ${i + 1}`));
shared.connect();
});
--- RxJS 7: connectable() + connect()
1000 ms -> GET /api/books/9780441172719/stock (poll 1)
1000 ms screen: poll 1
1000 ms badge: poll 1
2000 ms -> GET /api/books/9780441172719/stock (poll 2)
2000 ms screen: poll 2
2000 ms badge: poll 2
3000 ms -> GET /api/books/9780441172719/stock (poll 3)
3000 ms screen: poll 3
3000 ms badge: poll 3
Why RxJS 8 Never Shipped, and What RxJS 9 Brings
The version history on npm tells the story: 8.0.0-alpha.0 was published in January 2022 and the last alpha, 8.0.0-alpha.14, in January 2024; a stable RxJS 8 never came out. Meanwhile 7.x stayed maintained, and latest is still 7.8.2 from February 2025. The README of RxJS 9 gives the reason: work on RxJS 8 was paused until the Web Platform Observable settled. RxJS 9 is not a continuation of that paused code but a new generation built on the platform Observable, and it is numbered 9 so that it is not mistaken for the old RxJS 8 work. The first beta, 9.0.0-beta.0, was published on 4 August 2026, 9.0.0-beta.1 on 9 October 2026, and it sits under npm's next tag.
The change is bigger than a version bump. RxJS 9 does not bring its own Observable class: if the environment has a native Observable it uses it, otherwise it installs the separately published @rxjs/observable-polyfill. Operators are not functions passed to .pipe() but methods added to Observable under Symbol keys, each imported from its own subpath. The examples in this section live in the separate rxjs9-trial project of the snapshot; after npm ci they run with node src/01-symbols.ts, the tests with npm test and the bundle measurement with npm run bundles:
// Node 24 has no native Observable: importing rxjs installs @rxjs/observable-polyfill
import "./before-rxjs.ts";
import { ColdObservable } from "rxjs";
import { map } from "rxjs/map";
import { scan } from "rxjs/scan";
console.log("Observable after rxjs:", typeof Observable);
type CartItem = { title: string; price: number; quantity: number };
// the cart: 2 x Dune, 1 x Emma, 1 x The Hobbit
const cart = new ColdObservable<CartItem>((subscriber) => {
subscriber.next({ title: "Dune", price: 450, quantity: 2 });
subscriber.next({ title: "Emma", price: 320, quantity: 1 });
subscriber.next({ title: "The Hobbit", price: 390, quantity: 1 });
subscriber.complete();
});
// operators are Symbols: source[map](…) instead of source.pipe(map(…))
const result = cart[map]((item) => item.price * item.quantity)
[scan]((total, line) => total + line, 0)
.subscribe({
next: (total) => console.log(`subtotal: ${total} TRY`),
complete: () => console.log("done"),
});
console.log("subscribe() returns:", result);
console.log("cart.pipe:", (cart as unknown as { pipe?: unknown }).pipe);
Observable before rxjs: undefined
Observable after rxjs: function
subtotal: 900 TRY
subtotal: 1220 TRY
subtotal: 1610 TRY
done
subscribe() returns: undefined
cart.pipe: undefined
Node.js 24 has no native Observable; importing rxjs installed the global Observable through the polyfill. map and scan are each a unique symbol; they are called with the cart[map](…) bracket syntax, and the root import does not install the operator catalog, only the Symbols you import exist. subscribe() no longer returns a Subscription, it returns undefined; there is no .pipe. According to the package's migration guide the OperatorFunction type family, rxjs/operators, the schedulers (asyncScheduler and the rest) and TestScheduler are gone too. The package is published as ESM only and needs Node 22.13 or later; require('rxjs') works through Node's require(esm) support. The equivalent of this series' custom operators changes as well: in RxJS 9 you wrap an operator in an ordinary typed function (like settledTerm below).
The Platform Observable and ColdObservable
In the first post of this series we said "every subscribe runs the producer again"; that is also why HttpClient sent two requests for two async pipes. The platform Observable's contract is different: subscribers that are active at the same time share one producer, the producer closes when the last subscriber leaves, and a later one starts it again. For those who want the old behaviour, RxJS 9 provides ColdObservable. We ran the same fake request with both; cancellation now goes through AbortController:
import { ColdObservable } from "rxjs";
// a fake GET /api/categories: logs when the request starts and when its teardown runs
function categoriesProducer(subscriber: Subscriber<string>) {
console.log("-> GET /api/categories");
const handle = setTimeout(() => {
subscriber.next("classics, fantasy");
subscriber.complete();
}, 200);
subscriber.addTeardown(() => {
clearTimeout(handle);
console.log(" teardown");
});
}
async function twoSubscribers(title: string, source: Observable<string>) {
console.log(`--- ${title}`);
const nav = new AbortController();
const select = new AbortController();
source.subscribe((list) => console.log(`nav: ${list}`), { signal: nav.signal });
source.subscribe((list) => console.log(`select: ${list}`), { signal: select.signal });
await new Promise((resolve) => setTimeout(resolve, 300));
}
await twoSubscribers("platform Observable", new Observable(categoriesProducer));
await twoSubscribers("ColdObservable", new ColdObservable(categoriesProducer));
console.log("--- abort before the answer");
const source = new Observable(categoriesProducer);
const nav = new AbortController();
const select = new AbortController();
source.subscribe((list) => console.log(`nav: ${list}`), { signal: nav.signal });
source.subscribe((list) => console.log(`select: ${list}`), { signal: select.signal });
nav.abort();
console.log("nav aborted");
select.abort();
console.log("select aborted");
--- platform Observable
-> GET /api/categories
nav: classics, fantasy
select: classics, fantasy
teardown
--- ColdObservable
-> GET /api/categories
-> GET /api/categories
nav: classics, fantasy
teardown
select: classics, fantasy
teardown
--- abort before the answer
-> GET /api/categories
nav aborted
teardown
select aborted
With the platform Observable two subscribers saw one request; with ColdObservable two requests went out. In the last part, while the request was running, nav left first and nothing happened; when select left too, the teardown ran. You no longer return the teardown from the function, you register it with subscriber.addTeardown(). This difference means a decision for every stream migrated from RxJS 7: the platform does part of what share did in the multicasting post, but not exactly the same thing. The migration guide also asks you to choose the target lifecycle (platform, ColdObservable or a Subject) for each pipeline first.
The Native Observable in Browsers
Observable reached the browser as a proposal developed in the WICG. Its main entry point is EventTarget.when(): any DOM event becomes an Observable, and a set of operators such as map, filter, takeUntil, switchMap and first comes as the platform's own methods. According to version 8.1.9 of MDN's browser-compat-data package (12 November 2026), Observable and EventTarget.when have been in Chrome and Edge since 135 and are not in Firefox or Safari; both are marked experimental. We wrote the search box with the platform's when and map together with RxJS 9's Symbol operators:
// RxJS 9 on top of the browser's Observable: EventTarget.when() + RxJS Symbol operators
import { getObservablePolyfillInfo } from "@rxjs/observable-polyfill";
import "rxjs";
import { debounce } from "rxjs/debounce";
import { distinctUntilChanged } from "rxjs/distinct-until-changed";
import { map } from "rxjs/map";
console.log(`Observable: ${getObservablePolyfillInfo() ? "RxJS fallback" : "native"}`);
const box = document.querySelector<HTMLInputElement>("#search")!;
box
.when("input")
.map(() => box.value) // the platform's own map
[debounce](300) // RxJS operators via Symbols
[map]((term) => term.trim())
[distinctUntilChanged]()
.subscribe((term) => console.log(`search: ${term}`));
In both engines Playwright typed "dune", added a space at the end half a second later, then typed "emma" into the box:
--- Chromium 158.0.8127.18: Observable function, EventTarget.when function
10 ms Observable: native
638 ms search: dune
1765 ms search: emma
--- WebKit 27.2: Observable undefined, EventTarget.when undefined
7 ms Observable: RxJS fallback
640 ms search: dune
1767 ms search: emma
In Chromium RxJS 9 used the native Observable; Playwright's WebKit build had neither, and the polyfill took over. The behaviour is the same in both engines: after trim the space gave the same term and was skipped. In the same chain .map(…) is the platform's method and [map](…) is RxJS's; importing RxJS does not override the platform method. We could not run Firefox in this environment; for Firefox the information comes from browser-compat-data only.
Testing and Migration in RxJS 9: @rxjs/test and @rxjs/migrate
The marble tests of the previous post relied on TestScheduler, and RxJS 9 has no TestScheduler; its place is taken by rxTest from a separate package, @rxjs/test. We wrote settledTerm in RxJS 9 as an ordinary function and tested it with the same marbles:
import "rxjs";
import { debounce } from "rxjs/debounce";
import { distinctUntilChanged } from "rxjs/distinct-until-changed";
import { map } from "rxjs/map";
// RxJS 9 has no OperatorFunction: a custom operator is an ordinary function
export function settledTerm(terms: Observable<string>, dueTimeMs = 300): Observable<string> {
return terms[debounce](dueTimeMs)[map]((term) => term.trim())[distinctUntilChanged]();
}
import { rxTest } from "@rxjs/test";
import { expect, it } from "vitest";
import { settledTerm } from "./settled-term.ts";
it("emits a term 300 ms after the last key", async () => {
await rxTest(({ hot, expectObservable }) => {
const input = hot("a 99ms b 99ms c 99ms d", { a: "d", b: "du", c: "dun", d: "dune" });
// rxTest refuses a stream that is still open at the end: unsubscribe at 1000 ms
expectObservable(settledTerm(input, 300), "^ 999ms !").toBe("600ms d", { d: "dune" });
});
});
it("trims the term and skips a repeat", async () => {
await rxTest(({ hot, expectObservable }) => {
const input = hot("a 399ms b 399ms c", { a: "dune", b: "dune ", c: " emma" });
expectObservable(settledTerm(input, 300), "^ 1999ms !").toBe("300ms a 799ms c", {
a: "dune",
c: "emma",
});
});
});
The marbles are the same as in the RxJS 7 version and the tests pass. The only difference showed up on the first try: without the unsubscription marble ("^ 999ms !") both tests failed with this error:
FAIL src/settled-term.test.ts > emits a term 300 ms after the last key
Error: rxTest finished with 1 open observation(s). Complete the source or provide an unsubscription marble.
TestScheduler silently accepted a stream that never completes; rxTest asks you to state explicitly when a subscription that is still open at the end of the test is dropped. Since a text box never completes, this makes the test describe the real world more honestly.
@rxjs/migrate comes with a deterministic transformation engine for the RxJS 9 migration and a "Skill" that can be installed for Claude Code, Codex and Cursor. According to its README the mechanically supported set is small for now: plain pipe(…) chains with filter, map, takeUntil, debounceTime, concatMap and a few more operators. We started with a dry run; the tool writes nothing to disk and returns the result as JSON. Its output for a simple search function:
// RxJS 7 input for the @rxjs/migrate dry run in the post (not compiled in this project)
import { debounceTime, filter, map, type Observable } from "rxjs";
// the search box: wait for a pause, trim, ignore empty terms
export function searchTerms(input: Observable<string>): Observable<string> {
return input.pipe(
debounceTime(300),
map((term) => term.trim()),
filter((term) => term.length > 0),
);
}
// Migrated from https://github.com/example/bookstore @ abc123
// Source: src/search-terms.ts
import { debounce } from "rxjs/debounce";
import { map } from "rxjs/map";
import { filter } from "rxjs/filter";
import { type Observable } from "rxjs";
// the search box: wait for a pause, trim, ignore empty terms
export function searchTerms(input: Observable<string>): Observable<string> {
return input[debounce](300)[map]((term) => term.trim())[filter]((term) => term.length > 0);
}
debounceTime(300) became [debounce](300); RxJS 9 has no separate debounceTime, debounce takes a number or a function. But the output did not compile as it was in the RxJS 9 project: tsc said Module '"rxjs"' has no exported member 'Observable', because in RxJS 9 the Observable type is global and not exported from the root. Deleting that import line made it compile. Run on the previous post's 02-settled-term.test.ts, the same tool refused the transformation:
"status": "refused"
"code": "lifecycle-review"
"message": "Multiple observations may depend on producer multiplicity."
"nextAction": "Verify cold versus shared platform behavior before accepting the migration."
"span": src/12/02-settled-term.test.ts line 23
The tool says that where several observations are attached to the same hot source, the difference between cold and shared platform behaviour may change the result, and it leaves the decision to a human. That is the right design: the hard part of moving to RxJS 9 is not the syntax but the lifecycle decision for every stream. One note: in our trial the npx rxjs-migrate command exited with 0 without printing anything; running the CLI file directly (node node_modules/@rxjs/migrate/dist/esm/cli.js …) worked. The kind of issue to expect from a beta tool.
The Decision: When Should an Angular Project Upgrade?
Angular's position is written in its package.json. For @angular/core 22.2.6 (latest) and 22.3.0-rc.0 (next) the peer dependency is the same: rxjs: "^6.5.3 || ^7.4.0". We tried anyway: we installed the RxJS 9 beta into a copy of bookstore-web and built it:
$ npm i rxjs@9.0.0-beta.1
npm warn ERESOLVE overriding peer dependency
npm warn Could not resolve dependency:
npm warn peer rxjs@"^6.5.3 || ^7.4.0" from @angular/common@22.2.6
...
$ npx ng build
✘ [ERROR] Could not resolve "rxjs/operators"
node_modules/@angular/common/fesm2022/http.mjs:11:20:
11 │ import { tap } from 'rxjs/operators';
╵ ~~~~~~~~~~~~~~~~
The path "./operators" is not exported by package "rxjs":
...
✘ [ERROR] TS2305: Module '"rxjs"' has no exported member 'of'.
src/app/core/operators.ts:8:2:
8 │ of,
...
$ grep -c ERROR build.log
73
npm did not stop the installation; it let the peer mismatch through with a warning. The build failed with 73 errors, and the first ones come not from our code but from Angular itself: @angular/common, core, forms and router import from rxjs/operators, which RxJS 9 does not have. So today the answer is clear:
- A production Angular 22 project stays on RxJS 7.8. 7.x is maintained and is still
latest. - A project on RxJS 6 should move to 7.8; the table in this post, the replacements for the
publishfamily and the rules ofeslint-plugin-rxjs-xare the list for that move. - The preparation you can do today happens inside RxJS 7: clean up deprecated APIs (
toPromise, thepublishfamily,rxjs/operators), keep custom operators in a few files, and pin the behaviour (sharing, cancellation, errors) down with tests. That is also the first step of RxJS 9's migration guide. - Try RxJS 9 outside Angular, in a separate trial project. The
rxjs9-trialin this post is exactly that. - Upgrade time starts on the day Angular adds RxJS 9 to its peer range. The peer range of
eslint-plugin-rxjs-x1.0.6 is alsorxjs ^7.2.0for now; check the lint step again on that day too.
In Production
Everything written about a beta is short-lived. The information in this post was checked on 14 November 2026 against npm (npm view rxjs dist-tags, npm view @angular/core peerDependencies) and the package's own documentation; the API is likely to change before RxJS 9 reaches a stable release. Run those two commands again before you decide.
If you write code that relies on the native Observable, remember that it is not in Firefox or Safari today. RxJS 9's polyfill fills that gap itself, but code that calls element.when('click') directly without importing RxJS throws a TypeError in those browsers. Until the Baseline status changes, use feature detection or load a polyfill.
Do the migrations inside RxJS 7 in small steps too: first get the list from the lint rules, then replace each publish/multicast use together with a test that pins its behaviour down. The publishReplay example in this post shows how a migration without tests can quietly change behaviour when something fails.
Common Pitfalls
- Replacing
toPromise()withlastValueFromwithout thinking about the empty stream. Code that used to getundefinednow rejects withEmptyError; pass adefaultValueif needed. - Turning
publishReplay(1), refCount()intoshareReplay({ bufferSize: 1, refCount: true })without thinking. The behaviour after an error changes. - Measuring bundle size with the ES5 build. If the bundler does not pick RxJS 7's
es2015build, the result is misleading. - Installing the RxJS 9 beta into an Angular project and ignoring the peer warning. The build breaks on Angular's own
rxjs/operatorsimports. - Assuming every Observable in RxJS 9 is cold. The platform Observable shares the producer among concurrent subscribers; use
ColdObservableif each subscriber needs its own producer. - Accepting a migration tool's output without compiling it and running the tests. The output of
@rxjs/migratedid not compile in our trial. - Assuming the native
EventTarget.when()exists in every browser. As of November 2026 it exists only in Chromium-based browsers.
A Small Experiment
★☆☆ Run npx eslint . in bookstore-web with the rxjs-x/no-topromise and rxjs-x/prefer-root-operators rules of the recommended config on. Then add import { map } from 'rxjs/operators' and a toPromise() call to a file; which of the rules offers an automatic fix (--fix)?
★★☆ Repeat the publishReplay scenario in 02-multicast-migration.ts for publishLast() + refCount(), and show that its equivalent from RxJS's deprecation guide (share with an AsyncSubject) gives the same output, including the error case.
★★★ Write the retryWithBackoff from the error handling post with RxJS 9 in rxjs9-trial: look at the signature of rxjs/retry, and make the three retry tests from the previous post (500/1000/2000 ms waits, no retry on 404, success on the third attempt) pass with @rxjs/test and the same marbles. What did you have to change in the marbles?
The RxJS series ends here. The rule we built throughout the series still holds after RxJS 9: time and event flow in RxJS, the state on screen in signals. We build on this foundation for application-wide state (Signal Store, rxMethod, NgRx Effects) in the Angular'da State Yönetimi series, for real-time sources such as WebSocket and SSE in the Real-time Uygulamalar series, and for interfaces that stream LLM answers token by token in the Angular ile AI Arayüzleri series.