Modernizing Legacy Apps with RxJS: Streams as the Bridge Between Old and New
In most legacy migrations, the difficult part is the async code, not the templates: callbacks, observers, global events and timers that every screen depends on. This post explains how we use RxJS as a bridge, so the old
In most legacy migrations, the difficult part is the async code, not the templates: callbacks, observers, global events and timers that every screen depends on. This post explains how we use RxJS as a bridge, so the old UI and the new React UI share the same streams while screens are moved one by one.
In most legacy frontend migrations, the templates are not the hard part. Templates can be ported quite easily. The hard part is the async code. A jQuery handler sends a request, sets a global flag and fires a custom event. A Backbone model has three views listening to its change event. An Ember observer saves data on every keystroke. A setInterval keeps polling the server, and nobody in the team knows why it was added.
This is where the actual behavior of a legacy app lives, and it is spread across every page. We cannot move it one route at a time, because both the old screens and the new screens depend on it.
In such projects, we use RxJS as a bridge between the old app and the new one. We do not use it as the final architecture, and we do not use it just because Angular uses it. The approach is simple. Wrap each legacy event source in an Observable and keep the shared state behind a stream. Now the old UI and the new React UI can subscribe to the same data while screens are moved one by one. As a bonus, a lot of hand-written debounce, retry and cancel logic becomes a few lines of operators. This is the branch by abstraction pattern, with streams as the interface.
The Short Version
| Legacy pattern | What goes wrong | RxJS replacement |
|---|---|---|
Callbacks and $.Deferred chains |
Deeply nested, cannot be cancelled, errors get lost |
from(promise), defer, switchMap
|
Global or custom events ($(document).trigger, Backbone on) |
Any code can fire them, and nobody removes the listeners |
fromEvent, fromEventPattern, typed Subjects |
Observers and watchers (Ember observer, $scope.$watch) |
Order is not clear, and they run too often |
BehaviorSubject + distinctUntilChanged
|
| Hand-rolled debounce and timers | Old responses overwrite new ones, timers keep running |
debounceTime, switchMap, takeUntil
|
Polling with setInterval
|
Requests overlap, and polling never stops |
timer + switchMap / exhaustMap
|
| Retry loops | Too many retries, no delay between them | retry({ count, delay }) |
| WebSockets with manual reconnects | Duplicate connections, lost messages |
webSocket + retry + share
|
Every row follows the same idea: treat "something that happens over time" as a value that we can pass around, combine and unsubscribe from. Once both apps use Observables, the framework on each side becomes much less important.
Why RxJS Works Well for Modernization
There are many state management libraries. For a migration, RxJS is useful for three specific reasons:
- It does not depend on any framework. An Observable works the same way whether jQuery, Backbone, Ember, AngularJS or React subscribes to it. This is exactly what we need during a migration, when the old and new code run on the same page.
- One model for every kind of async. DOM events, custom events, promises, timers, WebSockets and Node-style callbacks all become the same type. There is no need to write a different pattern for each source.
- Cancellation and cleanup are built in. Every subscription can be closed. Legacy apps often leave handlers and timers running, and many of these leaks are bugs that users have simply got used to.
There is also one clear reason to be careful: RxJS takes time to learn, and a codebase where everything is a stream is difficult to read. We will discuss where to stop later in this post. In short, use it at the boundaries and for real event streams, not for every value in the app.
Step 1: Wrap the Legacy Event Sources
First, find every place where the old app produces events or async results, and create an Observable wrapper for each one. Do not change the legacy code at this stage. The wrapper only listens.
DOM and jQuery events. fromEvent accepts DOM elements and any object with jQuery-style on/off methods, so a jQuery object can be passed directly:
import { fromEvent, map } from 'rxjs';
// The legacy app fires: $(document).trigger('cart:updated', [cart])
export const legacyCartUpdated$ = fromEvent($(document), 'cart:updated', (_e, cart) => cart);
export const searchInput$ = fromEvent<InputEvent>(document.querySelector('#search')!, 'input').pipe(
map((e) => (e.target as HTMLInputElement).value),
);
Backbone, Ember and anything with its own add/remove API. fromEventPattern takes an add handler and a remove handler. This way, the Observable removes its listener from the legacy object properly when it is unsubscribed:
import { fromEventPattern, startWith, map } from 'rxjs';
// Backbone model or collection
export const fromBackbone = <T>(model: Backbone.Model, event = 'change') =>
fromEventPattern<Backbone.Model>(
(handler) => model.on(event, handler),
(handler) => model.off(event, handler),
).pipe(startWith(model), map((m) => m.toJSON() as T));
// Ember object property (Ember.addObserver / removeObserver)
export const fromEmberProperty = <T>(obj: object, key: string) =>
fromEventPattern<T>(
(handler) => addObserver(obj, key, handler),
(handler) => removeObserver(obj, key, handler),
).pipe(map(() => get(obj, key) as T), startWith(get(obj, key) as T));
Two points are important here. First, convert to plain objects at the boundary (toJSON(), get). If the stream emits Backbone models or Ember records, the new code will also start calling .get('title'), and the legacy model will spread into React. This is the anti-corruption layer from our strategies post, applied to streams. Second, startWith gives the current value to late subscribers. So a React component that mounts later will not show an empty screen while it waits for the next change.
Promises, callbacks and sockets also have direct wrappers:
import { defer, bindNodeCallback } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
// defer: the request only starts when someone subscribes, and runs again per subscriber
export const loadOrders$ = (userId: string) => defer(() => legacyApi.getOrders(userId));
// Node-style callback API from an old SDK
export const readConfig$ = bindNodeCallback(legacySdk.readConfig);
export const prices$ = webSocket<PriceTick>('wss://example.com/prices');
For requests, prefer defer(() => promise) over from(promise). A promise starts running as soon as it is created. defer waits until someone subscribes, and this is what makes cancellation and retries possible later.
Step 2: Put Shared State Behind a Stream
Wrapping the sources makes the old app observable. The next step is to give each piece of shared state (the cart, the current user, the selected account, feature flags) a single owner. This owner is a small service that holds the value and exposes it as an Observable. Both apps read from this service, and all updates go through its methods.
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
export interface Cart { items: CartItem[]; total: number }
const state = new BehaviorSubject<Cart>({ items: [], total: 0 });
export const cartService = {
cart$: state.asObservable(),
count$: state.pipe(map((c) => c.items.length), distinctUntilChanged()),
get value() { return state.getValue(); },
add(item: CartItem) {
const items = [...state.value.items, item];
state.next({ items, total: sum(items) });
},
};
// Keep the legacy event working for screens that still listen to it
cartService.cart$.subscribe((cart) => $(document).trigger('cart:updated', [cart]));
The legacy side subscribes and updates the DOM the same way it does today:
// legacy/header.js (jQuery)
const sub = cartService.count$.subscribe((n) => $('#cart-badge').text(n));
$('#add-to-cart').on('click', () => cartService.add(readItemFromForm()));
// Backbone view: tie the subscription to the view's lifetime
initialize() { this.sub = cartService.cart$.subscribe((c) => this.render(c)); },
remove() { this.sub.unsubscribe(); Backbone.View.prototype.remove.call(this); },
React reads the same stream through a small hook. useSyncExternalStore is the official React API for subscribing to an external store. It also avoids the "tearing" issue that a useEffect + useState version can have during concurrent rendering:
import { useSyncExternalStore } from 'react';
import type { Observable } from 'rxjs';
// Subscribe to a stream that always has a current value (like a service backed by a BehaviorSubject)
export function useStream<T>(stream$: Observable<T>, getValue: () => T): T {
return useSyncExternalStore(
(onChange) => {
const sub = stream$.subscribe(onChange);
return () => sub.unsubscribe();
},
getValue,
);
}
// In a component
function CheckoutSummary() {
const cart = useStream(cartService.cart$, () => cartService.value);
return <p>{cart.items.length} items, {formatMoney(cart.total)}</p>;
}
That is the complete seam. A screen can move from the old app to React, and the cart badge in the legacy header will still update, because neither side owns the cart. Once the last legacy subscriber is removed, the service internals can be replaced (with Zustand, TanStack Query or plain React context) without changing any component, because components only depend on cart$ and add.
Issues we faced
-
Exposing the Subject directly. If the service exports
stateinstead ofstate.asObservable(), any file can call.next(), and we are back to global events. Expose streams for reading and methods for writing. -
Changing emitted objects in place.
state.value.items.push(item)changes the object that both apps hold, but nothing is emitted. Always emit a new object, becauseuseSyncExternalStoredepends on reference changes. -
Updates inside updates. A legacy handler that calls
add()while handling acart$emission can create a loop. UsingdistinctUntilChangedon derived streams, and keeping the legacy re-trigger one-way, prevents most of these cases. -
Loops with the legacy event. If the legacy app both fires and listens to
cart:updated, either wrap it withfromEventor re-trigger it from the service. Do not do both.
Step 3: Replace Hand-Written Async Code with Operators
Once events become streams, a lot of fragile legacy code can be rewritten in a few lines, and it usually becomes more correct too. A search box is the common example. Most legacy apps have some version of this jQuery code:
let timer, lastQuery;
$('#search').on('input', function () {
clearTimeout(timer);
const q = $(this).val();
timer = setTimeout(() => {
if (q === lastQuery) return;
lastQuery = q;
$.getJSON('/api/search', { q }).done(renderResults); // a slow early response can overwrite a newer one
}, 300);
});
It looks fine, but it has a race condition. If the request for "rea" is slow and the request for "react" is fast, the old results arrive last and replace the correct ones. The RxJS version avoids this by design, because switchMap unsubscribes from the previous request as soon as a new query comes in:
import { debounceTime, distinctUntilChanged, filter, switchMap, catchError, of } from 'rxjs';
import { ajax } from 'rxjs/ajax';
export const results$ = searchInput$.pipe(
debounceTime(300),
distinctUntilChanged(),
filter((q) => q.length >= 2),
switchMap((q) =>
ajax.getJSON<SearchResult[]>(`/api/search?q=${encodeURIComponent(q)}`).pipe(
catchError(() => of([])), // one failed search shouldn't kill the stream
),
),
);
ajax from rxjs/ajax cancels the actual HTTP request when it is unsubscribed. With fetch, pass an AbortSignal inside new Observable (or use fromFetch from rxjs/fetch) to get the same behavior.
The same small set of operators covers most of the other hand-written patterns we see in legacy apps:
| Need | Operator | Why it is better than the legacy version |
|---|---|---|
| Only the latest request matters (search, filters, route params) | switchMap |
Cancels old requests, so results never arrive out of order |
| Ignore clicks while a save is in flight | exhaustMap |
Replaces isSaving flags and disabled-button workarounds |
| Every action must run, in order (queued writes) | concatMap |
Writes do not mix, and no update is lost |
| Poll until the user leaves the page | timer(0, 5000).pipe(switchMap(load), takeUntil(leave$)) |
Polls never overlap, and polling stops reliably |
| Retry flaky calls with backoff | retry({ count: 3, delay: (_, i) => timer(2 ** i * 500) }) |
Limited attempts with a growing delay |
| Combine several sources |
combineLatest, withLatestFrom
|
Replaces flags like if (userLoaded && settingsLoaded)
|
| Time out a slow call | timeout({ first: 8000 }) |
Configured in one place, and errors are reported properly |
Polling needs one more example, because legacy polling code often causes duplicate requests and battery drain:
import { timer, defer, switchMap, takeUntil, retry, share, fromEvent, filter } from 'rxjs';
const hidden$ = fromEvent(document, 'visibilitychange').pipe(filter(() => document.hidden));
export const jobStatus$ = (jobId: string) =>
timer(0, 5000).pipe(
switchMap(() => defer(() => api.getJob(jobId))),
retry({ count: 3, delay: 1000 }),
takeUntil(hidden$),
share(), // several widgets can watch the same job with one poller
);
Common Mistakes
Leaked subscriptions
Every subscribe() must be closed at some point. In legacy code, connect it to the lifecycle that already exists: a Backbone view's remove, an Ember component's willDestroy, an AngularJS $scope.$on('$destroy'), or a jQuery plugin's destroy. In React, the hook's cleanup takes care of it. For streams that should end on an event, keep takeUntil(destroy$) as the last operator in the pipe, so that nothing after it can subscribe again.
Hot and cold Observables
A cold Observable (like defer(() => fetch(...))) runs again for every subscriber. If three components subscribe to the same stream, three requests are made. When a result should be shared, add share() or shareReplay({ bufferSize: 1, refCount: true }). Use refCount: true unless the source really needs to run forever. Without it, shareReplay keeps the inner subscription (and its polling or socket) running even after the last subscriber has left.
Turning everything into a stream
A common mistake in RxJS projects is a codebase where a simple boolean flag becomes a BehaviorSubject<boolean> passed through four operators. Use streams only where time matters: events, sockets, polling, typeahead and state shared between the two apps. For normal server data in React (load a list, show it, refetch on change), TanStack Query is simpler and also handles caching. For local component state, use useState. A simple rule: if you cannot draw a marble diagram for it, it probably should not be an Observable.
Errors stop streams
When an error reaches a long-running stream, the stream ends and every subscriber stops getting updates, without any visible message. Catch errors inside the switchMap/mergeMap (as shown in the search example) so that the outer stream keeps running, and log every error you catch.
Bundle size and RxJS versions
RxJS 7 supports tree-shaking, and from version 7.2 the operators are exported directly from rxjs. So a typical bridge layer adds only a small amount to the bundle. If the legacy app still uses RxJS 5 or 6 (common in older Angular apps), the official migration guide explains the upgrade to 7. rxjs-compat was meant only for the 5 to 6 upgrade and should not be used in a new layer.
Testing the Bridge
Streams are easy to test without a browser. TestScheduler runs time-based operators on virtual time, so a 300 ms debounce does not actually wait 300 ms. Marble strings describe the input and the expected output:
import { TestScheduler } from 'rxjs/testing';
// The search stream, with its inputs passed in instead of read from globals
export const searchPipeline = (input$: Observable<string>, search: (q: string) => Observable<SearchResult[]>) =>
input$.pipe(debounceTime(300), distinctUntilChanged(), filter((q) => q.length >= 2), switchMap(search));
const scheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
test('search only queries the last term after a pause', () => {
scheduler.run(({ cold, expectObservable }) => {
const input$ = cold('a 100ms b 400ms c|', { a: 'rea', b: 'react', c: 'react' });
const api = (q: string) => cold('--r|', { r: [`result for ${q}`] });
// 'rea' is dropped by the debounce; 'react' settles at 401ms, its result arrives 2 frames later
expectObservable(searchPipeline(input$, api)).toBe('403ms x 99ms |', { x: ['result for react'] });
});
});
Writing the pipeline as a function of its inputs is what makes it testable, and it is a good practice for the seam in any case. For checking that both apps behave the same, use the approach from our migration guides: one end-to-end test suite that runs the same screen in the old app and in the new app, and checks for the same visible result. The stream layer is what keeps those results the same, so it needs good test coverage.
Temporary Bridge or Long-Term Layer?
At the end of the migration, decide what happens to the RxJS layer. Both options below are fine, as long as the team makes a clear decision:
- Temporary bridge. The bridge was added so that old and new code could share state. Once the legacy app is removed, plain React state, TanStack Query or a small store can handle most of it. Remove the Subjects, and keep only the few streams that represent real events.
-
Long-term layer. Apps with a lot of real-time behavior (trading screens, live dashboards using sockets, collaborative editors, IoT consoles) often keep RxJS as the event layer under React. In that case, document the rules: where streams are kept, how errors are handled, and which shared streams use
shareReplay.
Whichever option you choose, delete the fromEventPattern wrappers around legacy objects in the same sprint as the last legacy screen. These wrappers are always temporary.
Further in This Series
This post covers one technique within a larger migration plan. The posts below explain the overall plan and each starting framework in more detail:
- Frontend migration strategies: strangler fig, branch by abstraction and vertical slicing, and how they combine.
- One-way vs two-way data binding: why observers and watchers are difficult to migrate, and what replaces them.
- Framework guides: Angular, Ember, Backbone, jQuery and vanilla JS to React.
Our Migration Plugins
Finding every event source, observer and shared service in a legacy app takes the most time, and it decides where the streams should go. We have packaged our approach as free, open-source plugins for Claude Code, one for each starting framework:
| Plugin | Migrates from |
|---|---|
ember-to-react |
Ember Classic and Octane |
angular-to-react |
Angular 2+ and AngularJS 1.x, including hybrid ngUpgrade apps |
backbone-to-react |
Backbone.js, Marionette, jQuery templates |
jquery-to-react |
jQuery apps, plugins and jQuery UI |
vanillajs-to-react |
Plain HTML, CSS and JavaScript |
Each plugin comes with a migration skill, a /<plugin>:plan command that writes a migration plan (strategy, units, route map, test plan and risks) without touching your code, a read-only inventory agent that maps the old app, a parity-reviewer agent that compares each migrated piece with its original, and a bundled Context7 server for up-to-date library docs. To install one in Claude Code:
/plugin marketplace add cobuild-tech/cbx-plugins
/plugin install backbone-to-react@cbx-plugins
They also work in Cursor, in VS Code with GitHub Copilot, and in the GitHub Copilot CLI. Progress is kept in a .migration/ folder in your repo, so a migration can carry on across sessions and teammates. The source and setup steps are at cobuild-tech/cbx-plugins, and issues are welcome.
About MigrateX
MigrateX is our platform and service for moving software, data and infrastructure from one technology to another. It combines automation with experienced engineers. The automation does the repetitive work quickly, and the engineers make the tricky decisions, like which shared state needs a stream-based seam and which can be moved directly.
A MigrateX project usually runs like this:
- Assessment. We map every route, shared service, event source and hidden dependency in your app, so you see where the work is before anyone agrees on a timeline.
- Plan. With your team, we choose the strategy, the slice order, and what can simply be deleted.
- Migration, step by step. Automation handles the repetitive parts. Our engineers handle the router, the seams and the code that connects old and new.
- Proof that it works. The same tests run against both versions. A piece only counts as moved when it passes on both.
- Handover. We remove the temporary code, the seams and the flags, and leave your team with a codebase they understand and own.
Your app keeps running and shipping features the whole time, so there's no big launch day. Our migratex-examples repo has small apps in each legacy framework next to their migrated versions.
Working on the async code of a legacy app? Share a few details with us through Contact Us, and we will start with a free discussion on where the seams should go.
References
RxJS
- RxJS. Overview and API reference. https://rxjs.dev/guide/overview
- RxJS. fromEventPattern. https://rxjs.dev/api/index/function/fromEventPattern
- RxJS. Testing RxJS Code with Marble Diagrams. https://rxjs.dev/guide/testing/marble-testing
- RxJS. Breaking Changes in Version 7. https://rxjs.dev/deprecations/breaking-changes
- Ben Lesh. Hot vs Cold Observables. https://benlesh.medium.com/hot-vs-cold-observables-f8094ed53339
React and migration patterns
- React. useSyncExternalStore. https://react.dev/reference/react/useSyncExternalStore
- Martin Fowler. Branch By Abstraction. https://martinfowler.com/bliki/BranchByAbstraction.html
- Microsoft. Anti-corruption Layer pattern. https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer
- TanStack Query. Overview. https://tanstack.com/query/latest/docs/framework/react/overview
Source code
- CobuildX AI. cbx-plugins: Claude Code plugins for Backbone, Angular, Ember, jQuery and Vanilla JS to React migrations. https://github.com/cobuild-tech/cbx-plugins
- CobuildX AI. migratex-examples: small jQuery, Backbone, Angular, Ember and Vanilla JS apps and their migrated versions. https://github.com/cobuild-tech/migratex-examples
Originally published on the CobuildX blog.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.



