RxJS 9: RxJS, Built on the Web Platform
RxJSJavaScriptWeb PlatformRxJS 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
masterbranch.
The most useful way you can help: Create a new branch in an RxJS 7 codebase and try the
@rxjs/migratepackage 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 #
- RxJS 9 uses the active web-platform
Observable. - If the platform does not provide
Observable, RxJS installs a fallback. - RxJS augments the platform Observable with operators such as
scan,retry, andrepeat, installed through imported Symbols. - Concurrent observers share one active producer by default.
ColdObservableprovides explicit producer-per-subscription behavior.- Cancellation uses
AbortSignal;subscribe()returnsundefined. - Subjects remain intentional RxJS APIs.
- Scheduling uses host APIs, with
@rxjs/testfor deterministic tests. - RxJS 9 does not ship a permanent RxJS 7 emulation runtime.
- Migration tools handle proven mechanical changes and stop for semantic decisions.
- An AI migration Skill inventories the repository, applies safe changes, and verifies the result.
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:
tsimport { 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:
tsimport { 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:
tsimport { 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:
tsimport { 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.
- If
globalThis.Observableexists, RxJS preserves it. - If it is missing, the RxJS fallback installs it.
- Windows, iframes, workers, and server isolates initialize independently.
- RxJS does not silently patch a constructor from another realm.
Importing an RxJS public entry point ensures that the current realm has an Observable before installing the requested capability:
tsimport { 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:
- The first observer starts the producer.
- Concurrent observers join it.
- Late observers receive future values, not old ones.
- One observer can leave without stopping work for the others.
- The final observer leaving runs teardown.
- A later observer starts a fresh producer run.
tslet 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); // 1first.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:
tsimport { 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:
tsconst 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:
tsconst 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.
tsimport { 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:
tsimport { 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:
cold()creates independent work per subscription.hot()represents a producer that exists before observation.observable()exercises the platform's shared, ref-counted lifecycle.
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.
tsconst 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 |
tsimport { 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:
- An AI migration Skill inventories the repository, identifies lifecycle decisions, coordinates transformations, and verifies the project.
- A deterministic engine and CLI apply only mappings in a versioned, test-backed capability registry.
- 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:
shgit switch -c try-rxjs-9-migrationnpm 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:
shnpx 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:
shnpx 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 #
- Make the RxJS 7 build and tests green.
- Inventory imports, pipelines, producers, subscriptions, schedulers, Subjects, custom inputs, and marble tests.
- Choose the intended lifecycle for each affected pipeline.
- Apply proven mechanical transformations.
- Stop for semantic decisions and unsupported surfaces.
- 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:
- The platform Observable is the foundation.
- RxJS capabilities use exact Symbols.
- Cancellation uses
AbortSignal. - Concurrent platform observers share one active producer.
ColdObservableprovides explicit producer-per-subscription behavior.@rxjs/testprovides deterministic, lifecycle-aware testing.- Migration output is normal project-owned source.
- RxJS 9 does not include a permanent RxJS 7 emulation runtime.
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:
- Create a new branch in an RxJS 7 codebase, install
@rxjs/migratefrom npm, and tell us how it goes. - Try the beta in a non-production project and report confusing or painful APIs.
- Keep RxJS 7 behavioral tests green.
- Add tests where repeated subscriptions intentionally repeat work.
- Document custom Observable producers and teardown assumptions.
- Inventory scheduler usage, custom subscribables, and Observable subclasses.
- Avoid relying on undocumented RxJS internals.
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.
- Previous: forEach is a code smell