RxJS 9: RxJS, Built on the Web Platform

RxJSJavaScriptWeb Platform

RxJS 9 is now in beta.

It is a new generation of RxJS built on the web-platform Observable. The platform provides the foundation and common operations such as map and filter; RxJS augments it with the broader operator catalog people rely on, including scan, retry, repeat, higher-order mapping, multicasting, and more.

This is a significant change. It is also being built with migration in mind from the start. The beta already includes deterministic transforms, diagnostics, and an AI migration Skill designed to work inside real repositories.

Beta status: The APIs are still subject to change as I get feedback from the community. RxJS 9 is ready to explore, not yet ready for production migrations. The source is available on the RxJS master branch.

The most useful way you can help: Create a new branch in an RxJS 7 codebase and try the @rxjs/migrate package from npm. It includes agent Skills and CLI tools for migrating toward RxJS 9. I very much want to hear what works, what fails, what is confusing, and where the tools stop short. That feedback will be crucial to making migration practical for the greatest number of RxJS users.

The short version #

You do not need to migrate today. If you maintain a large RxJS 7 codebase, the useful work now is to keep behavioral tests healthy and make lifecycle assumptions explicit. When it is time to migrate, the goal is a reviewable engineering workflow—not a manual rewrite and not “ask an AI and hope.”

The code change in 30 seconds #

An RxJS 7 pipeline:

ts
import { repeat, retry, scan } from 'rxjs';
const resilientTotal = source.pipe(
retry({ count: 3 }),
repeat({ count: 2 }),
scan((total, value) => total + value, 0)
);

The RxJS 9 form:

ts
import { repeat } from 'rxjs/repeat';
import { retry } from 'rxjs/retry';
import { scan } from 'rxjs/scan';
const resilientTotal = source
[retry]({ count: 3 })
[repeat]({ count: 2 })
[scan]((total, value) => total + value, 0);

The platform Observable already provides common transformations such as map and filter. RxJS 9 adds the larger reactive operator vocabulary that the platform does not attempt to provide. Each import supplies an exact Symbol, so RxJS can add that capability without claiming another string-named method or colliding with the platform.

Applying an RxJS Symbol operator to a platform Observable still returns a platform Observable:

ts
import { scan } from 'rxjs/scan';
const total = source[scan]((sum, value) => sum + value, 0);
// `total` is a platform Observable augmented with RxJS capabilities.

The Symbol selects an RxJS operation; it does not wrap the source in a separate “RxJS Observable” or produce an “RxJS result.” On a platform Observable, operators such as [scan], [retry], and [repeat] derive another platform Observable.

RxJS also provides Symbol-keyed forms for overlapping names such as map and filter. Those forms keep RxJS chains consistent and make the same operator surface available to types such as ColdObservable, whose construction contract Symbol operators preserve. They are useful points of coexistence, but the primary reason to use RxJS 9 is the substantial operator layer it adds beyond the platform.

Composition is still available when a long chain reads better as named steps:

ts
import { pipe } from 'rxjs/pipe';
import { retry } from 'rxjs/retry';
import { scan } from 'rxjs/scan';
const runningTotal = (source: Observable<number>) =>
source[scan]((total, value) => total + value, 0);
const retryTransientFailures = (source: Observable<number>) =>
source[retry]({ count: 3 });
const resilientTotal = source[pipe](
retryTransientFailures,
runningTotal
);

RxJS 7 and RxJS 9 at a glance #

Concern RxJS 7 RxJS 9
Observable RxJS owns the class Uses the active platform Observable
Missing platform support RxJS always supplies its runtime Installs a fallback only when needed
Operators Pipeable functions Exact imported Symbol extensions
Producer lifecycle Usually one producer per subscription Concurrent observers share one active producer
Explicit cold work Usually implicit ColdObservable
Cancellation Subscription.unsubscribe() AbortController and AbortSignal
subscribe() result Subscription undefined
Producer teardown Often returned by the producer subscriber.addTeardown()
Scheduling RxJS schedulers and overloads Host timers, clocks, frames, and signals
Testing TestScheduler rxTest with explicit source lifecycles
Input conversion Broad ObservableInput support Platform Observable.from rules
Migration Major-version guidance Skill, transforms, diagnostics, and behavioral evidence

One Observable foundation #

RxJS 9 uses the Observable that belongs to the current JavaScript realm.

Importing an RxJS public entry point ensures that the current realm has an Observable before installing the requested capability:

ts
import { scan } from 'rxjs/scan';
const source = Observable.from([1, 2, 3]);
source[scan]((total, value) => total + value, 0).subscribe(console.log);

The architecture has three layers: acquire the platform primitive, add RxJS capabilities, and provide migration tooling. RxJS can then focus on the operator ecosystem instead of maintaining a competing foundational type forever.

The biggest behavioral change: producer lifecycle #

In RxJS 7, a cold Observable usually creates new work for every subscription. A platform Observable shares one active producer among concurrent observers:

ts
let activations = 0;
const ticks = new Observable<number>((subscriber) => {
activations++;
let value = 0;
const handle = setInterval(() => subscriber.next(value++), 1_000);
subscriber.addTeardown(() => clearInterval(handle));
});
const first = new AbortController();
const second = new AbortController();
ticks.subscribe(console.log, { signal: first.signal });
ticks.subscribe(console.log, { signal: second.signal });
console.log(activations); // 1
first.abort(); // Producer keeps running for `second`.
second.abort(); // Final observer leaves; teardown runs.

This shared lifecycle can prevent duplicated requests, timers, retry loops, and state machines. It can also change code that intentionally relied on each subscription starting fresh work. That is the main semantic question migration tooling must identify.

When every subscription should start new work #

Use ColdObservable when producer-per-subscription behavior is intentional:

ts
import { ColdObservable } from 'rxjs/cold-observable';
let activations = 0;
const requests = new ColdObservable<Response>((subscriber) => {
activations++;
// Start one request for this direct subscription.
});
requests.subscribe(handleFirst);
requests.subscribe(handleSecond);
console.log(activations); // 2

RxJS Symbol operators preserve that cold construction contract. Native string-named methods return to the platform lifecycle. The result type makes the boundary visible.

Stateful operators follow the same rule. Concurrent observers of a platform pipeline share operator state for scan, retry, debounce, buffer, higher-order mapping, and similar work. Once every observer leaves, the next producer run starts with fresh state.

Cancellation and teardown use platform APIs #

RxJS 9 uses AbortSignal for cancellation. subscribe() returns undefined, so cancellation ownership must be explicit:

ts
const controller = new AbortController();
source.subscribe(
{
next: handleValue,
error: handleError,
},
{ signal: controller.signal }
);
controller.abort('view disposed');

The same signal can own a subscription, a fetch, event listeners, and other abortable platform work. One lifecycle action can cancel all of them.

Inside a producer, register cleanup with the Subscriber:

ts
const ticks = new Observable<number>((subscriber) => {
const handle = setInterval(() => subscriber.next(Date.now()), 1_000);
subscriber.addTeardown(() => clearInterval(handle));
});

Operators pass subscriber.signal upstream. Code that depends on teardown order, finalizer aggregation, or unhandled-error timing needs deliberate review during migration.

Subjects stay; general schedulers do not #

Subjects remain first-class RxJS APIs. An instantiated Subject is hot because its producer exists before observers subscribe. Subject.asObservable() also remains available as a class-local way to expose a read-only view without adding asObservable to the platform prototype.

RxJS 9 uses host scheduling capabilities directly: setTimeout, setInterval, requestAnimationFrame, platform clocks, and AbortSignal. Time-based operators accept durations instead of a general RxJS scheduler.

ts
import { debounce } from 'rxjs/debounce';
import { observeOn } from 'rxjs/observe-on';
const result = source
[debounce](250)
[observeOn](0);

This does not make time-based code harder to test. @rxjs/test virtualizes the host environment, so application timers and RxJS operators use the same deterministic clock.

Testing makes lifecycle explicit #

rxTest replaces the public TestScheduler API with a function-first test surface:

ts
import { rxTest } from '@rxjs/test';
import { scan } from 'rxjs/scan';
test('accumulates values', () =>
rxTest(({ cold, expectObservable }) => {
const source = cold('-a-b-|', { a: 1, b: 2 });
const result = source[scan]((total, value) => total + value, 0);
expectObservable(result).toBe('-a-b-|', {
a: 1,
b: 3,
});
}));

The source model is explicit:

Tests no longer inject schedulers into production APIs just to control time. More importantly, a migrated test states which lifecycle it is protecting.

Conversion boundaries are explicit #

Observable.from accepts the platform inputs in order: existing Observables, async iterables, sync iterables, and Promises.

ts
const values = Observable.from([1, 2, 3]);
const profile = Observable.from(
fetch('/api/profile').then((response) => response.json())
);

Objects that only expose a lowercase subscribe() method or a legacy RxJS interop Symbol may need an adapter. Migration tools flag those cases instead of silently changing their cancellation or error behavior.

Converting an Observable to an async iterator also requires an explicit buffering choice:

API Behavior Good fit
iterateEachValue Lossless FIFO; may grow without bound Every value matters
iterateBufferedValues Lossless batches Batch processing is cheaper
iterateLatestValue Keeps only the latest unread value Rendering and current state
iterateNextValue Accepts a value only while waiting Stale values should be discarded
ts
import { iterateLatestValue } from 'rxjs/iterate-latest-value';
for await (const latest of source[iterateLatestValue]()) {
await render(latest);
}

Breaking out of the loop aborts that iterator's source observer.

Migration is part of the product #

RxJS 9 does not include a permanent runtime that pretends to be RxJS 7. Such a layer would leave applications split between two lifecycle models and make it unclear which behavior is safe to depend on.

Instead, each RxJS 7 surface maps to a platform method, an RxJS Symbol, an intentional RxJS 9 API, a migration adapter, or a documented unsupported capability.

The migration system is designed to make that classification for you where it can—and stop safely where it cannot.

It has three parts:

  1. An AI migration Skill inventories the repository, identifies lifecycle decisions, coordinates transformations, and verifies the project.
  2. A deterministic engine and CLI apply only mappings in a versioned, test-backed capability registry.
  3. Structured diagnostics identify unsupported APIs and code that needs a human decision.

The AI is not asked to guess whether two APIs with similar names behave the same. It works from versioned project knowledge and behavioral evidence. The deterministic engine remains the authority for mechanical edits.

Try @rxjs/migrate in a new branch #

If you maintain an RxJS 7 codebase, please try the migration tooling now—even if you have no plans to migrate that codebase yet. Use a new branch so the experiment is isolated, reviewable, and easy to discard:

sh
git switch -c try-rxjs-9-migration
npm install --save-dev @rxjs/migrate

The npm package provides both agent Skills and deterministic CLI tools. Trying it against real applications and libraries is the best way to expose the cases the migration system still needs to handle.

Install the migration Skill #

The canonical Skill can be installed into a repository for Codex:

sh
npx rxjs-migrate-skill install --harness codex --project-root .

The installer records the package version and content digest. It also supports checking and updating the installed Skill, and refuses to overwrite local modifications unless explicitly forced.

Start with a dry run #

The migration CLI writes nothing by default:

sh
npx rxjs-migrate \
--source-root . \
--source-repo https://github.com/example/project \
--source-sha abc123 \
--mode cold \
--framework preserve \
test/user-stream.spec.ts

It returns transformed source plus diagnostics. Writing requires both --write and an output directory. A refused batch writes no files.

The output is ordinary project-owned source: readable, editable, testable, and ready for review. There is no permanent runtime generator.

If the tooling cannot migrate something, or its diagnostics do not make the next step obvious, please report it. Especially useful feedback includes the kind of project you tried, the APIs or patterns involved, what the tool changed, where it stopped, and what you expected it to do. Success reports matter too: they tell us which migrations are already dependable. Please share that feedback in the RxJS issue tracker.

What the workflow does #

  1. Make the RxJS 7 build and tests green.
  2. Inventory imports, pipelines, producers, subscriptions, schedulers, Subjects, custom inputs, and marble tests.
  3. Choose the intended lifecycle for each affected pipeline.
  4. Apply proven mechanical transformations.
  5. Stop for semantic decisions and unsupported surfaces.
  6. Run formatting, type checks, focused tests, the full suite, and package checks.

The current well-tested path is deliberately bounded. It handles direct, unshadowed pipe(...) expressions for a documented operator subset and migrates supported TestScheduler marble specs to rxTest. It does not claim to migrate every RxJS program automatically.

That boundary is a safety feature. Unsupported or ambiguous code stays visible instead of being hidden behind a compatibility shim.

Mechanical changes versus review points #

Usually mechanical Requires semantic review
Operator and subpath imports Multiple subscriptions used to repeat work
Supported pipe-to-Symbol calls share, shareReplay, publish, or ref counting
Basic AbortController ownership Retry or refresh behavior based on resubscription
Producer teardown registration Scheduler-dependent ordering
Supported marble-test syntax Subject replay and late subscribers
Known operator argument mappings Custom subscribables or Observable subclasses
Test-framework boilerplate Teardown order, error timing, or cross-realm use

For review points, the tools should explain the behavior, migrate the safe surrounding code, and leave the engineer with a focused decision. They should not bury uncertainty in generated code.

RxJS 7 tests are migration evidence #

The RxJS 7 test suite represents years of accumulated behavior. RxJS 9 uses that evidence without treating every old implementation detail as a platform requirement.

Each migrated claim is classified as portable, a test-harness rewrite, compatibility-only, an intentional divergence, or unsupported. Thousands of marble cases have already been converted into executable migration evidence.

This evidence is why a transform can be trusted when it changes an API such as bufferCount(3, 1) into a new buffer configuration. The mapping is accepted because its behavior is classified and tested—not because the names look similar.

What is settled, and what can still change #

The major architectural direction is set:

This is still a beta. Community feedback may change API details before the stable release, including overlapping operator contracts, framework adapters, browser and server support, bundler behavior, and the final migration-tooling surface.

What teams should do now #

Do not start a production migration yet. Instead:

The better a repository explains why its streams behave as they do, the more work the migration Skill can perform safely—and the smaller the remaining human review becomes.

Why this is exciting #

RxJS helped prove that Observables belong in JavaScript. RxJS 9 gets to build on that success: one platform primitive, the full power of RxJS layered on top, and migration tooling built for the codebases that already depend on it.

This is a major change, but it is not a retreat from RxJS. It is RxJS moving closer to the platform it helped influence.

Migration is not an afterthought. The goal is to meet developers inside their repositories, automate the work we can prove safe, explain the decisions we cannot automate, and verify the result.

Historical footnote: RxJS was never designed specifically for Angular. Angular was an influential early adopter, but RxJS has always been a general-purpose reactive programming library. The Angular team later supported bringing Observable to the web platform, and I filed WHATWG DOM issue #544 in 2017 to advance that effort.