Back to Blog

Reflex v0.8.0 - Refactoring for Performance

Major improvements in Reflex v0.8.0 developer experience and performance.

Reflex Team

·

Image for blog post: Reflex v0.8.0 - Refactoring for Performance

Reflex v0.8.0 represents a major milestone in our journey to make Python web development faster and more reliable. This release includes two fundamental architectural changes that improve both developer experience and application performance.

We had achieved 2-3x faster production build times with our migration from NextJS to Vite + Rolldown, 18% faster component initialization with our custom Pydantic replacement, significantly reduced memory usage during development and builds, and faster component compilation across the board.

Reflex compiles its frontend to a NextJS project. For production builds, we use NextJS Server-Side-Rendering to pre-render the majority of the application so it can be served statically without NodeJS dependency. We also leverage its development server for iterative development. NextJS is much more than what we use it for, and that is our issue with it.

Turbopack is wonderful, when it works. NextJS has generally overpromised and underdelivered with turbopack and its stability. For many of our usecases, it was very much unusable. When we reported these bugs upstream (by the way, please do report bugs upstream), it was one of the slowest to respond.

We had to disable turbopack because of the above bug, and opt in for a much older version of NextJS that does not have this issue. Overall, turbopack massively improved things, but when you export NextJS for downstream users, it is not reliable just yet.

NextJS is popular for a good reason. Is there really anything better?

Switching away from React is a non-starter, so that takes away things like Vue/Nuxt and Svelte/SvelteKit. We do need some Single-Page-Application-like routing, so we cannot just use vanilla React. This leaves us with two options:

  1. React-Router (merged with Remix). They recently had their v7 release which cleaned up the weird transitional state they are in.
  2. Tanstack Router. They are a bit new (around three years old) but they had their v1 release for some time now.

We ruled out Tanstack for now. It might be potentially cool for the future but we needed to move off of NextJS now, not later.

React-Router is generally, promising. They are more restricted as React-Router mostly just does, routing. It delegates most of the work to Vite.

Vite is wonderful. Even when we have issues with it, they are much less random and mysterious. They are much more active on fixing bugs. They are so good that basically everyone but NextJS uses them.

Vite is already fast, but it can be even faster if you use Rolldown. Oh it is in beta? If Rolldown is beta you should take NextJS back to alpha.

This one is big. That is basically our biggest dependency really, after Python. We merged a three-month work in #4984. We had to do lots of cleanups after to make sure features remained compatible as reasonable as possible.

We also opted to use Rolldown. Simply, our experience using it was much less buggier than NextJS. We have yet to hit a Rolldown bug in our apps.

We have yet to enable Rolldown's experimental native plugins (we did hit some issues on those). Although we should be doing so hopefully soon. Even without them, Vite with Rolldown is consistently two to three times faster than NextJS and uses much less memory.

Reflex had its initial commit in November of 2022. During which, Pydantic was during its V1 era. It was not until June of 2023 that Pydantic V2 was released. Although upgrade should be easy, right?

It is obvious that Pydantic V2 would not maintain backwards compatibility with V1. The issue is that we were not using Pydantic internally, but we were reexporting Pydantic classes for downstream users of Reflex to use. Mainly, we had two major components that were tightly attached to Pydantic.

First there is reflex.State. It defines the schema, i.e. fields and events, for a client session (a user visiting the website).

The public API looks like:

The fact that rx.State was implemented through Pydantic was mostly a hidden detail. Although the specifics of how Pydantic processed fields was essentially public API. Things like mutable defaults being handled without a default constructor, fields starting with _ being ignored and not at all processed by Pydantic, and other weirdly specific details. As such, upgrading has always been a difficult task, espically if you really wanted to maintain API compatibility.

Second, there is reflex.Component. It defines the schema of a Python class that would compile into a Javascript invocation of a React component. Similar to reflex.State, it also package Pydantic's public API to Reflex downstream, and maintaining backwards compatibility was desired.

Pydantic has always been a weird fit for our usecase at Reflex. For one, Pydantic's blurb on PyPI says:

Data validation using Python type hints

...while we do not actually use validation for most of our classes. We hand rolled our own validation that works with our reflex.Var system. Pydantic just offered so much that we were not using, and we needed to pay for that extra every time we define a class or create an instance of it.

#4904 is one example of things just, being bad. Where Pydantic was checking the mutability of every default field at every instance creation. The culprit is this line in pydantic/v1/fields.py:

Things are still largely fine. Although we set self.default to an instance of reflex.Var, which is a frozen dataclass, and Pydantic takes the long route of deepcopying the dataclass instead of just returning it. This makes smart_deepcopy take around 50x longer than it needs to. Changing things to default_factory with a constructor lambda fixes this issue.

While we could continue to track down such behavior in Pydantic, it does show a bit of limitation in our case. We did not write Pydantic, so we do not know what is just, bad Pydantic code. This issue magnifies if you export Pydantic classes for your downstream users to subclass from.

We could upgrade to Pydantic V2 and either face the API breakage for us and for our downstream, or we could patch all V1 behavior we can think of into Pydantic V2. As time continued without making a decision, the former looked too difficult to commit to, and the latter seemed a more significant work than desired.

We opted to choose neither, and rewrite Pydantic from scratch! Well, we only used a small subset of features from Pydantic, so it was not a terrible task. You can take a look at the two pull requests: #5289 and #5396 to get some insight on how that was implemented. Those rewrites allowed us to clean up Pydantic patches and control our logic. It also boosted our performance on our existing benchmarks. Roughly 18% faster on initializing components and similar improvements on compiling stateful components.

For other classes that we do not expect downstream to subclass, we opted for traditional dataclasses as API breakage there is expected.

These foundational improvements set the stage for even more exciting features coming in future releases. We're continuing to focus on making Reflex the fastest and most reliable way to build web applications with Python.

Check out our roadmap to see what's coming next, and as always, we welcome contributions from the community!

More Posts

Newsletter

Get the latest updates and news about Reflex

$ pip install reflex

$ reflex init

$ reflex run

Need help? Learn how to use Reflex.

Built with Reflex

Built with Reflex