Rx.NET v7.0 Released
Rx.NET 7.0 can cut self-contained Windows app deployments by ~95MB by moving WPF/WinForms support out of System.Reactive into optional packages.
About this talk
Rx.NET
In this video, Ian Griffiths announces Rx.NET 7.0, highlighting a potential 95MB reduction in application deployment size by moving UI framework support out of the main System.Reactive package into separate NuGet packages (System.Reactive.Windows.Forms, System.Reactive.Wpf, System.Reactive.Uwp, and System.Reactive.WindowsRuntime).
He explains the three breaking changes:
- dropping .NET 6 and 7 support (now targeting .NET 8+ and tested on 8–10),
- fixing a nullable annotation bug in OfType (no runtime change),
- the UI-package split that prevents Windows TFMs from implicitly pulling in WPF/WinForms, which previously bloated self-contained deployments (example growing from 102MB to 197MB with Rx 6.1).
Rx 7.0 keeps binary compatibility by retaining UI code at runtime but hiding it from reference assemblies, with analyzers guiding developers to add the right package; he also recounts the historical "great unification" decision and invites feedback via GitHub for community suggestions of v8.0 features.
Full documentation is available at Introduction to Rx.NET.
- 00:00 Rx.NET 7.0 Overview
- 00:30 Breaking Change One TFMs
- 01:51 TFM Confusion Explained
- 03:18 Breaking Change Two Nullability
- 04:37 Big Change UI Packages Split
- 07:21 Demo The 95MB Problem
- 12:51 Rx 7 Deployment Results
- 15:14 Source vs Binary Breaking
- 18:10 ReactiveUI Still Works
- 22:34 Why This Happened
- 26:22 Wrap Up Next Steps
Read the transcript
This month we released version 7.0 of the Reactive Extensions for .NET. The big change is that this can reduce the size of application deployments by as much as 95 megabytes.
Now, it's been over three years since we released Rx 6.0, and since then we've done some bug fix releases, and we also released some new features in version 6.1 about 10 months ago.
But this time it's a new major version number. So why is that? Well, we follow semantic versioning conventions, so we only increment the major version when there's actually a breaking change, and there are three breaking changes. The first is that we've removed the ability to run on .NET 6 and .NET 7.
Now, Microsoft stopped supporting those back in 2024, so this shouldn't really affect anyone. We currently support Rx 6.1 on .NET 8, .NET 9 and .NET 10, and we support those same versions in Rx 7. So practically speaking, this isn't a big change, but it is a technical change, and you can see it in the NuGet package manager.
Right now I've got Rx 6.1 selected, and if we look down at the dependencies section, which breaks things down by target framework, you can see that Rx 6.1 offers a net6.0, and also a specific version of net6.0 on Windows. So when you see the short-form names like net6.0, these are what we call .NET target framework monikers, or TFMs for short.
So now let me switch to Rx 7 and we'll see how the picture changes. So now we no longer see those 6.0 targets, and we see .NET 8.0 in its place. And since this always confuses people, just to be absolutely clear, we support Rx 7 on .NET 10. TFMs are widely misunderstood, and sometimes people think that because we don't provide a .NET 10 TFM there, this means we don't support .NET 10, but that's not how these things work.
This specifies a minimum of .NET 8, but we also test on .NET 9 and .NET 10. So we changed the TFMs from net6.0 to net8.0 because we don't support Rx 7 on .NET 6 or .NET 7. But we test on .NET 8, 9 and 10 because those are the versions we support. And when .NET 11 ships in November, we'll expand the tests to cover that too.
So there are only two good reasons and one rather weak reason to change a library's TFM. The first good reason is that you want to remove support for an older runtime, which is what we've done here. The second good reason is that sometimes newer runtimes can offer new ways of doing things that a library can take advantage of.
Now, there's actually nothing in .NET 10 that Rx needs to exploit, which is why we don't do that. And then the other, weak reason is because a lot of developers don't really understand TFMs, and it can be easier just to provide a completely unnecessary later version to work around people's confusion. But this makes the NuGet package bigger for no reason, so we don't do it.
Okay, so I said that removing support for .NET 6 and 7 was the first of the three breaking changes. The second breaking change is that we fixed a bug in our nullability annotations. This was a pretty small bug. The OfType operator was incorrectly annotated, which occasionally required developers to use the null-forgiving operator or similar workarounds in scenarios that shouldn't have needed it.
There was no change in the runtime behavior. The change is that the nullability annotations now describe what the API does more accurately. You can see here that we've added a single question mark to the input element type. Now, since the return element type remains non-nullable, this clarifies that even if the input stream contains null values, OfType will filter those out.
This has always been true. It's just that this information is now visible to the compiler, enabling it to do a better job of nullability analysis. And even though there was no change in runtime behavior, this change to the public API annotations is technically a breaking change. That's why we held this back until version 7, instead of putting this into an earlier bug fix release.
So that leaves one more breaking change, and this is the big one. In fact, it's the main point of the release. This third and final big breaking change is the one that enables the potential 95 megabyte reduction in application deployment sizes. And that is that we've moved UI framework support into separate NuGet packages.
Back when endjin took over maintenance of Rx in 2023, we looked at the various outstanding problems, and one in particular loomed large. The Avalonia UI project had just decided to stop using Rx.NET because it could cause a major headache for Windows desktop applications. The basic problem was this.
If you built a .NET application with a Windows-specific target framework, and you then added a reference to System.Reactive, which is the main Rx.NET package, this would quietly cause your application to take dependencies on WPF and Windows Forms. Now, I'll explain exactly why that happened later, but the most important point is that this causes problems for certain kinds of applications.
That's why in Rx 7 we've separated out all UI-framework-specific features into their own packages. If you want Rx's Windows Forms support, you now need a reference to System.Reactive.Windows.Forms. If you want WPF support, you'll need to use System.Reactive.Wpf. And there's also a System.Reactive.Uwp for applications that are still using UWP.
Now, the final entry in this list is slightly different, so that's System.Reactive.WindowsRuntime. So the Windows Runtime API defines certain types that are often used in desktop applications, but which are not tied to any particular UI framework. There's a non-framework-specific dispatcher, for example, and also the IAsyncAction and IAsyncActionWithProgress interfaces.
Rx put support for these into System.Reactive.WindowsRuntime. Now, back in Rx 6, all of this used to be baked directly into the main System.Reactive package. Not all targets had all the features, of course. If your application did not have a Windows-specific target framework, none of this UI functionality would be available, for example.
But as of Rx 7, the availability of, say, WPF support is not determined by your application's target framework. It's now determined by whether you add a reference to System.Reactive.Wpf. And why exactly have we done this? Well, let me show you the impact. Here's a simple console app that illustrates the problem.
This code looks for various sensors, things like accelerometers, gyrometers, magnetometers and so on. Most tablet devices have at least some of these, as do a few laptops. Most desktops don't. So I'm running a desktop right now, so it says it can't find any of them, but if I run on my laptop, it might find a few.
Now, this is the whole program. Its user interface is a few calls to Console.WriteLine. It doesn't need WPF or Windows Forms. Now, to use these sensor APIs, though, I have had to say that I am using Windows. So I've got a Windows-specific target framework here, and if I hadn't done that, those sensor APIs would not exist.
These are Windows-only APIs, and they're only visible because I specified a Windows-specific TFM. But the .NET SDK does not presume that just because I specified a Windows TFM that I will want the desktop frameworks. And if I look for something like Windows Forms or WPF, then the namespaces are not present, because you only get them if you ask for them.
So far, so predictable. Okay? Now suppose that I want to use Rx. If I go here, and if I use version 6.1 to begin with, the previous version, and I install that, then the surprising side effect of this is that now I go looking in the WPF namespaces, and suddenly all of my WPF controls are available, and all the Windows Forms ones would be as well.
So this has happened because before Rx 7, the Windows-specific build of System.Reactive included things like WPF scheduler support. So the System.Reactive.Concurrency namespace included a DispatcherScheduler, which is a WPF thing, and a ControlScheduler, which is a Windows Forms thing. So these types take a dependency on WPF, and that means that my project now has a dependency on WPF and Windows Forms, and that turns out to be a major problem.
If you want to use self-contained deployment — that's where you tell the .NET SDK that you want it to build your application in such a way that it will run on any computer, even if it's not had the .NET runtime pre-installed. So self-contained deployment essentially includes a copy of the .NET runtime as part of the build output.
So I actually built this earlier. I told the SDK to publish this as a self-contained app, and my application's in there. But you can see we've got all of the .NET runtime DLLs in here. And not just the .NET framework class library, but also various other supporting DLLs that the runtime needs.
So if you just copy this whole folder onto any Windows machine with an x64 CPU, it will run without you needing to install anything else. Now, it's pretty big. In fact, it's 102 megabytes in size right now. That's huge, actually. That's larger than most self-contained apps normally are, and it's because I'm using those Windows-specific APIs, which means that it's had to include a copy of the Windows Runtime interop DLL in there, which is pretty massive. That's made this like 25 and a half megabytes bigger than it normally needs to be.
So typically you'd expect this to be more like 75 megs or so, which is still large, but better. And in practice, with this sort of application, you would probably use either trimming or Native AOT, and that can get it down to about 20 megabytes, which is still pretty hefty, but might just about be reasonable for a tool like this.
Now, I created this self-contained deployment before I added the System.Reactive reference. So let me delete this, and then I'm gonna run the command to build that self-contained publication again. And if I look at this now, and ask how big that is — that is quite a lot bigger. It's now 197 megabytes, so that's, well, 94, 95 megabytes larger than it was before.
And all of that because I added the System.Reactive package. Well, I mean, that's not that big. Let's find it. There it is. It's only 1.4 megabytes in size. So why has it managed to make the whole thing 95 meg larger? Well, it's because this folder now has to contain a complete copy of not just the .NET runtime, but also the WPF and Windows Forms frameworks.
So these DLLs here, for example, beginning with PresentationFramework, they're all part of WPF, and these frameworks have to be really big. Now, I did just mention trimming and Native AOT, and those do mitigate this a little, but you still see disproportionately large increases. And unfortunately, WPF and Windows Forms weren't designed with trimmability in mind. So although you can trim a lot out, they still have a big impact.
Okay, so let's upgrade now to Rx version 7. So now if I go back to my Program.cs, so now I don't have the System.Windows.Controls namespace, 'cause WPF is no longer available. And now let's see what happens if I rebuild that publication. So I'm gonna delete the existing output, go to my command line, run that task again, and now let's see how big the result is.
And now it's 103 megabytes, so only slightly larger than it was before. And in fact, it's larger by the size of the Rx DLL, which is pretty much exactly what you would've expected. And if your application is able to use trimming, it's even better, because you only pay for the bits of Rx that you use.
Similarly, if you target Native AOT, the cost of entry for Rx 7 is pretty small, and again, you only pay for what you use. This particular example references Rx, but doesn't actually use it yet. So trimming is able to reduce the impact to zero. Obviously, if you were really using Rx, the price would go up.
The final line of this table is for framework-dependent deployment, or what you might think of as normal. This is where we don't ship a complete copy of the runtime with the app, and instead we just assume that a copy of .NET is already installed on the target system, and we just deploy our application and any NuGet packages it needs.
In this case, there's basically no difference between Rx 6.1 and Rx 7, because in this more conventional deployment model, Rx 6.1 didn't really cause any problems. But the point is that by moving the UI framework support out into separate NuGet packages, we no longer force Windows projects to take a dependency on WPF and Windows Forms, and for certain deployment models that can make your deployable outputs tens of megabytes smaller.
So that was the big new feature of Rx 7. Now, if you're curious, you might want more detail, and I'm all about detail, so here it goes. Now, I said this new package is a breaking change. To be precise, it's only really a source-level breaking change. I'm gonna show a couple of examples, one of which gets compiler errors after upgrading to Rx 7, and another which, despite relying on Rx's WPF support, doesn't actually get errors after you upgrade to Rx 7, even if you don't add the new packages.
I've got a WPF application that's using Rx 6.1's support for WPF dispatchers. I'm using Rx's Interval operator to get an event once per second, but this provides those events on some random threads. So I'm using this ObserveOnDispatcher method to tell Rx that I need to process these events on the dispatcher for the thread that this constructor's running on.
If I run this, then you can see that the UI is being updated each time a tick event comes. If I didn't have that, we'd get a crash at runtime, 'cause we'd be trying to update the UI from the wrong thread. Right? So now I'm gonna upgrade to Rx version 7. So let's do that. And now if I go back to my C# code, you can see I've immediately got a squiggly on that ObserveOnDispatcher. So if I mouse over, it's saying that there's no such method.
It can't find this ObserveOnDispatcher that I'm talking about. But notice that it's also told us that I need to add a certain package to fix this. The message says this method's moved. Please add a reference to the System.Reactive.Wpf NuGet package. So that's what I've gotta do: System.Reactive.Wpf.
So let's come back over here. We're gonna browse for System.Reactive.Wpf, and we install that as instructed, and that should fix the problem. There we go. We're up and running again. So we include an analyzer in Rx version 7 to detect this situation, and it tells you exactly what you need to do.
So this is a breaking change, but it's a source-level breaking change. I upgraded to version 7 and I got build errors, and I had to change something to get it working again. However, this is not a binary breaking change. The old UI framework support is actually still there in the main System.Reactive NuGet package's runtime assemblies. We just hide it by not including it in the reference assemblies.
So if you are using old libraries that expect code like that ObserveOnDispatcher method to be there, and those libraries have not been rebuilt against Rx 7, they will still work because the code is still there. For example, you might be using ReactiveUI, a popular Rx-based UI framework. As I record this, the latest version is built against Rx 6.1, and it relies on the WPF functionality that's built into the main System.Reactive package.
So this ReactiveUI application is the usual demo that goes and talks to the NuGet endpoint and shows you information about packages. Now, if I run this in the debugger, you can see we hit this breakpoint here. And if you look at the call stack, you can see that's inside System.Reactive.dll. So what's happened here is that when we asked ReactiveUI to start up, and in particular we said in our startup code that we wanted to get hold of the main thread scheduler.
Well, that's caused ReactiveUI to go, oh, okay, well, I'd better get hold of the relevant Rx scheduler. So there's a line of code here, and this is inside the ReactiveUI libraries, that retrieves DispatcherScheduler.Current. Well, that is in the Rx library. That's part of the System.Reactive.Concurrency.DispatcherScheduler class.
And so we end up inside the WPF-specific code of System.Reactive. So this is WPF-specific, 'cause these are all WPF types that I'm looking at here. So that's what happens with this as built as it is right now. If I look at the dependencies here, you can see we've got a dependency on ReactiveUI.Wpf, and that has brought in a dependency on System.Reactive 6.1.
But what would happen if I come to my NuGet package manager and I say, actually, I would like to use the most recent version of Rx? So I'm gonna force it to use version 7.0. So that says, okay, this is gonna be an upgrade. You are already using 6.1, you're now gonna upgrade to 7.0. So let's see what happens when that occurs.
Well, over here in Solution Explorer, there's my reference to System.Reactive, but you can see also the indirect one coming from our ReactiveUI has also been told, well, you've gotta use version 7 as well, because that's the one that's there. Now, as you know, in Rx 7 we have removed the WPF and Windows Forms and other UI support from the public-facing API of the library.
So what's gonna happen when I run this is it's gonna blow up, because it's expecting that DispatcherScheduler to be there, and now it's gone, 'cause I've not added the System.Reactive.Wpf component here. Well, let's see what happens if I hit F5. We still hit the DispatcherScheduler, even though we're now running against Rx version 7, so this WPF-specific type is actually still there.
And the reason this works is, as I said earlier, we actually leave all of this code in there at runtime. We keep it in there for binary compatibility. So for exactly this sort of scenario, where you've got some library that was built against a previous version of Rx, and it's gonna expect this stuff still to be in there.
This is only a source-level breaking change. So if the ReactiveUI team were to upgrade to Rx 7 in their project, then they would see a build error when they try to use the DispatcherScheduler, 'cause we've hidden it from the reference assemblies. And they would then need to add a reference to System.Reactive.Wpf to proceed.
So as I said, this is a compile-time breaking change, but it is not a runtime breaking change. Now, if the code is still really there, and you can see that it is, you might be wondering, well, what was the point? But as I already showed you, this does really have a huge impact on deployment sizes in applications that aren't using WPF.
And the key is that by hiding the UI framework features at compile time, System.Reactive no longer forces the consuming application to take a build-time dependency on WPF and Windows Forms. And that is what fixes the problem of unwanted copies of the UI frameworks in self-contained deployments.
But why didn't Rx always work like this? Why create this problem in the first place? Well, the reason Rx ends up causing unwanted dependencies on WPF and Windows Forms is because of a decision made 10 years ago, back in 2016. The team that was maintaining Rx decided to do something that was informally known as the great unification. This happened with Rx version 4.0, and the idea was that for years Rx had been split across a bewilderingly large set of NuGet packages, and it was really quite hard for developers new to Rx to work out what packages they needed.
The great unification vision was that there would be just one package, System.Reactive, and if you use that, you have all of Rx.NET. Simple. And this was fine for a year or two. But then .NET Core version 3 put a spanner in the works by adding WPF and Windows Forms support, 'cause up until that point, if you wanted to use .NET's desktop UI frameworks, you had to target .NET Framework.
The relatively newfangled .NET Core was all about cross-platform projects. So there simply was no way to use .NET Core 2 with WPF or Windows Forms, and that made things simple for Rx. If your application targeted the .NET Framework, Rx would automatically supply WPF or Windows Forms support. And this was an appropriate thing to do, because those frameworks are baked into .NET Framework.
And if you targeted .NET Core, you'd get a version of Rx that didn't support WPF and Windows Forms, because .NET Core didn't have those frameworks. But this simple picture fell apart when .NET Core 3 made the Windows desktop frameworks an optional feature of .NET Core on Windows. Now, the Rx.NET team decided at the time that the best thing to do was to continue with the existing great unification philosophy, in which a reference to the single Rx.NET package gave you access to everything Rx could do on your chosen platform.
So starting with Rx version 4.0 and up as far as version 6.1, this meant that if you targeted .NET Framework, a reference to just System.Reactive would provide WPF and Windows Forms features. If you used .NET Core with a non-OS-specific TFM or a non-Windows TFM, then System.Reactive would not have any WPF or Windows Forms features.
But if you used a Windows-specific TFM, then System.Reactive would include WPF and Windows Forms support. But what if you want to build a Windows-specific target, but you are not planning to use either Windows Forms or WPF? Perhaps back in 2019, when .NET Core 3 came out, that might not have seemed very likely. Why would you use a Windows-specific TFM if you're not going to show a UI? And if you're showing a UI, surely you'll be using WPF or Windows Forms.
And in fact, neither of those assumptions really holds up. There are plenty of reasons you might wanna access Windows-specific APIs without showing any desktop UI. The tool I showed earlier that discovers sensor availability doesn't need anything more than a text UI.
And although WPF and Windows Forms were the main .NET desktop UI frameworks for many years, even back as far as 2019, they weren't the only game in town. And today, there are many ways to show a Windows UI that don't involve WPF or Windows Forms. But why is this a problem? So you end up with some Rx features you're not gonna use — what's the big deal?
Well, the big deal is that if you're using self-contained deployment, you find your application deployment is 95 megabytes larger than it needs to be. And that is why we had to change Rx so that it no longer forces WPF and Windows Forms on you just because you happen to be building for Windows.
So Rx 7 is available today. And please give Rx.NET a try. The introtorx.com website provides a free book about Rx.NET if you'd like to learn more about Rx.
And finally, now that we've shipped Rx 7, we're deciding what to do in the next version. If you have ideas, please come to our GitHub repo and join in the discussions there. My name's Ian Griffiths. Thanks for listening.