DEV Community

Cover image for Making React Apps Memory Efficient | Million.js Beyond Speed
Ricardo Nunez
Ricardo Nunez

Posted on • Edited on

Making React Apps Memory Efficient | Million.js Beyond Speed

If you've heard of Million JS (from Aiden Bai, its creator, or Tobi Adedeji's React puzzles on Twitter), you've probably been intrigued by the headline: "Make React 70% Faster".

Most developers have the mindset of faster being better for a few reasons, namely SEO and user experience. If I could write plain React and make it as fast or faster than a lot of frameworks like Svelte and Vue (in some cases), then it's a win, right?

However, there are actually a few other reasons why Million optimizes React applications that has less to do with just "speed" and more to do with compatibility, whether with old devices, slow laptops, resource-constrained phones, etc.

Ultimately, what all of this comes down to is memory.

The old meme of a Chrome window with 10 tabs open chugging your old laptop to a halt has a lot more basis in reality than people realize.

If we look at the situations where an app is running really slowly despite being on a good network, it typically has a lot less to do with bandwidth and a lot more to do with memory, and that's what we're taking a look at in this article.

React Without Million

The way that typical React applications work without Million and without a server-side framework like Next.js is that for every component in your JSX, a transpiler (Babel) calls a function called React.createElement() which outputs not HTML elements but React elements.

These React elements actually create Javascript objects, so your JSX:



<div>Hello world</div>


Enter fullscreen mode Exit fullscreen mode

turns into a React.createElement() Javascript call that look like this:



React.createElement('div', {}, 'Hello world')


Enter fullscreen mode Exit fullscreen mode

Which gets you a Javascript object that looks like this:



{
    $$typeof: Symbol(react.element),
    key: null,
    props: { children: "Hello world" },
    ref: null,
    type: "div"
}


Enter fullscreen mode Exit fullscreen mode

Now, depending on how complex your component tree is, we can have nested objects (DOM Nodes) that go deeper and deeper where the root element's props key has hundreds or thousands of children per page.

This object is the virtual DOM, which ReactDOM creates actual DOM nodes out of.

So let's say we have only three nested divs:



<div>
    <div>
        <div>
            Hello world
        </div>
    </div>
</div>


Enter fullscreen mode Exit fullscreen mode

This would become something that looks more like this under the hood:



{
    $$typeof: Symbol(react.element),
    key: null,
    props: {
        children: {
            {
                $$typeof: Symbol(react.element),
                key: null,
                props: {
                    children: {
                        {
                            $$typeof: Symbol(react.element),
                            key: null,
                            props: { children: "Hello world" },
                            ref: null,
                            type: "div"
                        }
                    },
                ref: null,
                type: "div"
            }
        }
    },
    ref: null,
    type: "div"
}


Enter fullscreen mode Exit fullscreen mode

Pretty large, right? Keep in mind, this is only for a nested object with three elements, too!

From here, when the nested object(s) change (i.e. when state causes your component to render a different output), React will compare the old virtual DOM and new virtual DOM, update the actual DOM so that it matches the new virtual DOM, and discard of any stale objects from the old component tree.

Note, this is why most React tutorials will recommend moving your useState() or useEffect() as far down the tree as possible, because the smaller the component is that has to re-render, the more efficient it is to accomplish this comparing process (diffing).

Diagram of why moving state to the component leaves of the tree is more efficient

Now, diffing is incredibly expensive compared to traditional server-rendering where the browser just receives a string of HTML, parses it, and puts it in the DOM.

While server-rendering doesn't require Javascript, not only does React require it, but it creates this huge nested object in the process, and at runtime, React has to continuously check for changes which is very CPU intensive.

Memory Usage

Where the high memory usage comes in is in two ways: storing the large object and continuously diffing the large object. Plus extra if you're also storing state in memory as well and using external libraries which also store state in memory (which most people probably are, myself included).

Storing the large object itself is a problem in memory constrained environments, because mobile and/or older devices might not have much RAM to begin with, even less so for browser tabs which are sandboxed with their own small, finite amount of memory.

Ever had your browser tab refresh because it was "consuming too much energy"? That was likely a combination of both high memory usage plus continuous CPU operations that your device couldn't handle along with running these other operations like the OS, background app refreshes, keeping other browser tabs open, etc.

Also, diffing the large component tree means replacing old objects with new objects whenever the UI updates along with tossing the old objects away to the garbage collector, repeating the process constantly throughout the lifetime of the application. This is especially true for more dynamic, interactive applications (a.k.a. React's main selling point).

Object diffing process example in React
As you can see, the diffing process for even a simple component where you just change one word in a div means an object for garbage collection to get rid of. But what happens if you have thousands of these nodes in your object tree and many of them rely on dynamic state?

Immutable object stores used for state management (like with Redux) tax memory even more by continuously adding more and more nodes to their Javascript object.

Because this object store is immutable, it'll just continue to grow and grow, which further limits the memory available for the rest of your app to use for things like updating the DOM. All of this can create a sluggish, buggy-feeling experience for the end-user.

V8 and Garbage Collection

Modern browsers are optimized for this, though, right? V8's garbage collection is incredibly optimized and runs very quickly, so is this really a problem?

There are two problems with this take.

  1. Even if garbage collection runs quickly, garbage collection is a blocking operation, meaning it introduces delays in subsequent Javascript rendering.
  • The larger the objects are that have to be garbage collected, the longer these delays take. Eventually, there comes a point where there's so much object creation going on that garbage collection needs to run over and over again to free up memory for these new objects, which is often the case when your React app is open for a decent amount of time.

  • If you've ever worked on optimizing a React app and left it open for a couple of hours, and you click a button only for it to take 10+ seconds to respond, you know this process.

  1. Even if V8 is highly optimized, React apps often aren't, with event listeners often not being unmounted, components being too large, static portions of components not being memoized, etc.
  • All of these factors (even if they are often bugs and/or developer mishaps) increase memory usage, and some (like non-unmounted event listeners) even cause memory leaks. Yes, memory leaks. In a managed memory environment.

Dynatrace benchmark (Node.js memory consumption over time with memory leak)
Dynatrace has a great visualization of a Node JS app's memory usage over time when there's a memory leak. Even with garbage collection (the downward movements of the yellow line) getting more and more aggressive towards the end, memory usage (and allocations) just keeps going up.

Even Dan Abramov mentioned in a podcast that Meta engineers have written some very bad React code, so it isn't difficult to write "bad" React, especially given how easy it is to create memory in React with things like closures (functions written inside of useEffect() and useState()), or the necessity for Array.prototype.map() to loop over an array in JSX, which creates a clone of the original array in memory.

So it's not that performant React is impossible. It's just that it's often not intuitive how to write the best performing component, and the feedback loop of performance testing often has to wait for real-world users with a variety of browsers and devices.

Note: high performance Javascript is possible (I highly recommend this talk from Colt McAnlis), but it's also difficult to achieve, because it requires things like object pooling and static arraylist allocations to get there.

Both of these techniques are hard to leverage in React which is componentized by nature and typically doesn't promote the usage of a large list of recycled global objects (hence Redux's large, immutable, single object store for example).

However, these optimization techniques are still often used under the hood for things like virtualized lists which recycle DOM nodes in large lists whose rows go out of view. You can see more of these types of React-specific optimization techniques (specifically for low-end devices like TVs) in this talk by Seungho Park from LG.

React With Million

Keep in mind that even though memory constraints are real, developers are often conscious of the amount of tabs or apps open while running their dev server, so we often won't notice them apart from a few buggy experiences that might prompt a refresh or a server restart in development. However, your users will probably notice more often than you, especially on older phones, tablets, laptops, since they aren't clearing their open apps/tabs for your app.

So what does Million do differently that solves this problem?

Well, Million is a compiler, and while I won't go into everything here (you can read more about the block DOM and Million's block() function at these links), Million can statically analyze your React code and automatically compile React components into tightly optimized Higher Order Components which are then rendered by Million.

Million uses techniques closer to fine-grained reactivity (shoutout Solid JS) where observers are placed right on the necessary DOM nodes to track state changes among other optimizations, rather than using a virtual DOM.

This allows Million's performance and memory overhead to be closer to optimized vanilla Javascript than even performance focused virtual DOMs like Preact or Inferno, but without having an abstraction layer on top of React. That is to say using Million doesn't mean moving your React app to use "React-compatible" libraries. It's just plain React that Million itself can automatically optimize via our CLI.

Keep in mind, Million isn't suitable for all use cases. We'll go into where Million does/doesn't fit in later on.

Memory Usage

In terms of memory usage, Million uses about 55% of the memory that React does on standby after the page loads, which is a substantial difference. It uses less than half the memory that React does for every single operation otherwise tested by Krausest's JS Framework Benchmark, even on Chrome 113 (we're currently on 117).

memory benchmark for vanilla JS versus Million versus React

The memory hit you'd take by using Million compared to using vanilla Javascript would be at most about 28% higher (15MB vs. 11.9MB) when adding 10,000 rows to a page (the heaviest operation in the benchmark), whereas React would use about 303% to complete the same task vs. vanilla Javascript (36.1 MB vs. 11.9 MB).

Couple that with the total operations your app is completing over its lifetime, and both performance and memory usage will vary dramatically when using purely a virtual DOM vs. a hybrid block DOM approach, especially once you consider state management, libraries/dependencies, etc. Of course, in Million's favor.

Wait, But What About _?

As with everything, there are tradeoffs when using Million and the block DOM approach. After all, there was a reason that React was invented and there are definitely still reasons to use it.

Dynamic Components

Let's say you have a highly dynamic component in which data is frequently changed.

For example, maybe you have an application which is consuming stock market data, and you have a component that displays the most recent 1,000 stock trades. The component itself is a list that varies the list item component that's rendered per stock trade depending on whether it was a buy or sell.

For simplicity, we'll assume it's already prepopulated with 1000 stock trades.



import { useState, useEffect } from "react";
import { BuyComponent, SellComponent } from "@/components/recent-trades"

export function RecentTrades() {
    const [trades, setTrades] = useState([]);
    useEffect(() => {
        // set a timer to make this event run every second
        const tradeTimer = setInterval(() => {
            let tradeRes = fetch("example.com/stocks/trades");
            // errors? never heard of them
            tradeRes = JSON.parse(tradeRes);
            setTrades(previousList => {
                // remove the amount of elements returned from
                // our fetch call to stay at 1,000 elements
                previousList.slice(0, tradeRes.length);
                // add the most recent elements
                for (i, i < tradeRes.length, i++) {
                    previousList.push(tradeRes[i]);
                };
                return previousList;
            });
        }, 1000);

        return () => clearInterval(tradeTimer);
    }, [])

    return (
        <ul>
            {trades.map((trade, index) => (
                <li key={index}>
                    {trade.includes("+") ? 
                        <BuyComponent>BUY: {trade}</BuyComponent> 
                        : <SellComponent>SELL: {trade}</SellComponent>
                    }
                </li>
            ))}
        </ul>
    )
}


Enter fullscreen mode Exit fullscreen mode

Ignoring that there are probably more efficient ways to do this, this is a great example of where Million would not do well. The data is changing every second, the component being rendered depends on the data itself, and overall, there is nothing really static about this component.

If you look at the returned HTML, you might think "Having an optimized <For /> component would work great here!" However, in terms of Million's compiler (barring Million's <For /> component) there is no way to statically analyze the returned list of elements, and in fact, cases like these are why React was first brought about at Facebook (the news section of their UI, a highly dynamic list).

This is a great use case for React's runtime, because manipulating the DOM directly is expensive, and doing so every second for a large list of elements is expensive as well.

However, it's quicker when using something like React, because it will only diff and rerender this granular part of the page vs. something traditionally server rendered which might replace the entire page. Because of this, Million is better suited to handle other static parts of the page to keep React's footprint smaller.

Does that mean only components this extreme should be ignored by Million and use React's runtime? Not necessarily. If your components even lean into this kind of use case where the component relies on highly dynamic aspects like constantly changing state, ternary-driven return values, or anything that can't comfortably fit in the "static and/or close-to-static" box, then Million might not work well.

Again, there's a reason React was built, and there's a reason we're choosing to improve it, not create a new framework!

What Will Million Work Well On?

We'd definitely like to see Million pushed to the limits of where it can be used, but as for right now, there are certainly sweet spots where Million shines.

Obviously, static components are great for Million, and those are easy to imagine, so I won't go too deep into them. These could be things like blogs, landing pages, applications with CRUD-type operations where the data isn't too dynamic, etc.

However, other great use cases for Million are applications with nested data, i.e. objects with lists of data inside. This is because nested data is typically a bit slow to render due to tree traversal (i.e. going through the entire tree of data to find the datapoint your application needs).

Million is optimized for this use case with our <For /> component which is made specifically for looping over arrays as efficiently as possible and (like we mentioned before) recycling DOM nodes as you scroll rather than creating and discarding them.

This is one of the examples where even with dynamic, stateful data, performance can be optimized essentially for free by just using <For /> rather than Array.prototype.map() and creating DOM nodes for each item in the mapped array.

For example:



import { For } from 'million/react';

export default function App() {
    const [items, setItems] = useState([1, 2, 3]);

    return (
        <>
            <button onClick={() => setItems([...items, items.length + 1])}>
                Add item
            </button>
            <ul>
                <For each={items}>{(item) => <li>{item}</li>}</For>
            </ul>
        </>
    );
}


Enter fullscreen mode Exit fullscreen mode

Again, this performance can be gained almost for free with the only requirement being knowing how/when to use <For />.

For example, server rendering tends to cause errors with hydration because we're not mapping array elements 1:1 with DOM nodes, and our server rendering algorithm differs to that of client rendering, but it's a great example of a dynamic, stateful component that can be optimized with Million with a bit of work!

And although this example uses a custom component provided by Million, this is just an example of a specific use cases where Million can work well. However, as we went over before, non-list components that can be stateful and are relatively static work incredibly well with Million's compiler, such as CRUD-style components like forms, CMS-driven components like text blocks, landing pages, etc. (a.k.a. most applications that we work on as frontend developers, or at least I do).

Is It Worth Using Million?

We certainly think so. Lots of people, when optimizing for performance look at the easiest metrics to track: page speed. It's what you can measure right away on pagespeed.web.dev, and while that is certainly important, initial page load time usually won't be a big draw on user experience, especially when writing a Single Page Application which is optimized for between-page transitions, not full page loads.

However, avoiding and reducing memory usage where possible is also an incredibly compelling use-case for using Million JS.

If each action that your user performs takes no time to complete and gives them instant feedback, then user experience feels more native, and that's typically where performance issues creep up if you're not careful, because input delay is typically highly influenced by memory usage.

So is it necessary to use a virtual DOM to acheive this? We certainly don't think so. Especially if it means more Javascript to run, more objects to create, and more memory overhead to worry about on lower-end devices.

This doesn't mean Million is a good fit for all use cases, nor will it solve all performance problems. In fact, we recommend to use it granularly, as in some cases (i.e. more dynamic data like we discussed), a virtual DOM will actually be more performant.

But having a tool in your toolbelt that requires almost no setup time or config will certainly get us closer to having React be a much more reliable, performant library to reach for when building an app that will live in the wild, outside of other devs' 8 core, 32GB machines.

Soon, we'll be doing benchmarks on common React templates to see how Million impacts memory and performance, so stay tuned!

Top comments (17)

Collapse
 
warwizard profile image
War Wizard

Serious question... Why use React?

I know this is about Millions.js but... that only exists because React exists, it's solving a problem that shouldn't be solved but removed entirely.

The best way to optimize a React app is to stop using React (or anything that's forked from it or follows the same bad design principles).

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

I also ask the same question. It is as if developers are just bound to torture themselves with a library that is just beyond repair.

Million.js sounds very interesting. Its compiler approach and the use of fine-grained reactivity is refreshing. You know which framework already does this? Svelte.

Svelte already fixed what Million.js is coming to fix, and it does it right from the get-go. Svelte 4 lacks Solid's fine-grained reactivity, but Svelte 5 Runes will close this gap, making Svelte the top choice, at least of us developers that don't want to continue down ReactJS' rabbit hole.

Collapse
 
tbm206 profile image
Taha Ben Masaud

React has an excellent API. Why is it beyond repair?

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

There are many issues with it.

  1. Its API only grows more complex. A never-ending list of effects spring out of thin air to try to keep up with solutions to problems that more often than not, are part of the core.
  2. Its reactivity is not nearly as fine-grained as it can be. A clear example is shown in the article I leave below.
  3. Its performance is horrible, and if you add Redux, it is appalling.
  4. Asynchronicity is an afterthought, as clearly seen in the documentation for <Suspense>. It is eye-opening to see that documentation, and then going into Svelte and taking the lesson on the {#await} block. My Good Lord In Heaven.

That's just from the top of my head coming from me, someone that rarely work with it (I'm mostly a back-end developer). Imagine all the things that escape me.

Thread Thread
 
tbm206 profile image
Taha Ben Masaud

There are a few problems in your understanding.

  1. React's core API is simple for what it actually does. In fact, users should avoid using most of its API as most of it is geared towards library authors. State management and side-effects can be 100% handled by other libraries.
  2. React is essentially a view library. It is an easy interface to the DOM and the operations around manipulating the DOM.
  3. You make a claim that its reactivity and performance is not as good as other libraries. Unfortunately, you did not present any evidence to back this up. Moreover, software development is not always about performance and most benchmarks are based on faulty assumptions or fictional workloads; I'm yet to witness a large codebase where any of react-alternatives beat react in performance. However, react-based codebases tend to be easier to understand and follow, especially if they employ the excellent ELM architecture.
  4. Similar to the previous point, asynchronicity should not be blamed on react. react is only a view library.

Having said all of the above, the issue with react is that in recent years it started to try to cater for things beyond DOM manipulation: <Suspense />, RSC, data fetching and so on. This is a mistake and will probably spell the end of react because it makes it difficult to understand and maintain.

However, there are alternatives that are still focused like preact.

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas

State management and side-effects can be 100% handled by other libraries.

Pretty contradictory for a state-driven library, wouldn't you say? Isn't React about declarative programming? Isn't state the heart of declarative programming?

React is essentially a view library

Isn't data fetching part of viewing? Or is React content by just showing empty boxes? Fetching has always been an asynchronous operation, yet React never prepared for asynchronous programming.

Here's your evidence: Interactive Results. Tip. Look for React on the right, far away from Vanilla JS, Solid, Svelte...

I leave you a preview of LIghthouse results (results used by Google to promote search results):

Lighthouse results

Moreover, software development is not always about performance and most benchmarks are based on faulty assumptions or fictional workloads

So, are you saying that we should ignore results like the ones I am presenting, because maybe (but maybe not!) are based on "faulty assumptions or fictional workloads"? No sir, that's not the way it goes. Surely there may be tests like that, but even "faulty" tests can tell you a story. Furthermore, it is my strong belief that justifications like "computers are very fast and have a lot of RAM nowadays, so this is a non-issue" come from people unwilling or unable to actually make it better.

Similar to the previous point, asynchronicity should not be blamed on react. react is only a view library.

And yet other libraries have come to embrace it so beautifully that leave React in shame.

Without trying to be mean or anything, I believe you are trying to defend what cannot be defended. Overall, people need something that works. We don't want a library that is based on state that cannot effectively handle state. We want something that performs so our grievances are minimum with people in the lower end of the technology spectrum. We want something that can be programmed fast and error-free. React doesn't check any of these checkboxes.

Thread Thread
 
tbm206 profile image
Taha Ben Masaud

With all due respect, it's clear you are ignorant about what react is.

State management and data fetching is not part of DOM manipulation. These are add-ons that the react team added. Anyone familiar with how react evolved would know that.

Thread Thread
 
webjose profile image
José Pablo Ramírez Vargas • Edited

With all due respect, it's clear you are out of arguments.

Clearly React cannot fulfill the duties required by today's applications. Clearly, React is an underperformer, bloated and overall lacking library that was great 5 years ago, and now has been surpassed by several others. I see no shame in admitting reality. It was a good run, but that run is no more.

I know very well what React is: The past. I even brought numbers to the table. Whether or not you want to admit it, is entirely up to you (I see you disregarded the source of my data, even when you were so emphatic about asking for it, in bold and all).

It's clear we have reached an impasse in our discussion and will merely degrade in quality and facts from this point forward. Feel free to keep your own opinion, as I'll do the same with mine.

Have a good day.

Collapse
 
nisargio profile image
Nisarg Patel

A good video why you may choose React. youtu.be/uEx9sZvTwuI?si=xVokk-YsoN...

Collapse
 
warwizard profile image
War Wizard

This is a really bad take and the reason why tech is in really bad shape now that the bubble has popped.

There are no logical reasons to use React, it's inefficient, bloated, unintuitive, badly designed and promotes bad design. It does not solve a problem but creates one and provides a subpar solution to that problem they created.

(They only thing that might be somewhat useful is JSX, but you can use it without React)

There are only "social" reasons to use React. It's all peer pressure and people in charge who don't know about system design making bad decisions only on the basis of "it's what everybody is using" or "it's the future".

Thread Thread
 
lukasbloom profile image
Lucas Barros

What would you recommend instead of React?

Thread Thread
 
warwizard profile image
War Wizard

I'm against frameworks in general. In all my years I've used a lot of them and they all share the same fate. The short term benefits are always out-weighted by the long term drawbacks, and there will always be a new one in a few years that becomes the "current thing", at which point everything you invested in the previous one becomes just a lot of wasted resources.

I'd rather use a tool stack where each tool requires little investment from your team, that even if they become obsolete you didn't waste much by having used them. Things that are so simple in nature they can be learnt in a few days, that are intuitive enough that even if you have never used them, the code can be understood.

Most of your basic needs in web development are already covered by the current state of vanilla Javascript and HTML+CSS. I'm not saying that should be the way, but you can work from there adding tools, each specifically helping in a different aspect of the development. You don't need a one-size-fits-all framework that does everything.

I have a preferred tool for almost everything I think I would need for developing a web project in a relatively sane way, the exception being something like JSX, which I must admit it's quite convenient to handle HTML within a Javascript context. I'd rather have JSX create an HTMLElement I could use directly instead of React stuff. I don't need nor want the rest of React's functionality, it just makes the app slow and the code unnecessarily harder to write.

Thread Thread
 
ricardonunezio profile image
Ricardo Nunez

Definitely don't disagree with this take, and in fact, I've been leveraging lighter weight frameworks (SolidJS/Svelte) for quick projects and/or vanilla JS/web components where possible lately purely for the better performance and lower bandwidth overhead if it's necessary.

However, the network effect is real, and a ton of companies are going to continue to write/maintain React codebases, not only because devs are familiar with it and the codebases are huge, but also because the ecosystem has just grown too much to throw those codebases away in favor of a more performant, simpler library.

Server components are getting closer to this goal, albeit with the same caveat of not outputting HTMLElements (React Elements still). I fully agree that we're solving already solved problems that React created, but rather than push for vanilla JS, Million is trying to close the gap even more between vanilla JS performance and memory usage to the point where it's easier to make a performant React app than it is to make a vanilla JS/HTML/CSS app, because, again, React likely isn't going anywhere anytime soon.

Writing JSX is just a quicker experience than document.querySelector()..., document.getElementById(), etc. + DOM manipulation. It's a better idea (for Million.js at least) that people keep their codebases, put in almost no effort, and just increase performance by ~2x without anything but a CLI run and a dependency and get near vanilla JS performance. Caveat, of course, being you're still shipping a lot of kb's of dependencies for just React + Million.js. Baby steps!

Collapse
 
abrahamn profile image
Abraham

I'm not for or against React, seeing comment below, however the fact that React is big, in terms of community, support and provably, it's future is no joke. Of the many many frameworks out there, many compare themselves to React and each is addressing something. This is just another one of them addressing that. And everyone of them is contributing to a future where we have fast, dynamic, safe and performant code. The framework is not the point, it's how it's used and what it achieves.

This is fabulously written. Thank you.

Collapse
 
ricardonunezio profile image
Ricardo Nunez

There's definitely a network effect going on, and the fact that it's the biggest framework in terms of usage also means that knowing it is an advantage (or a commodity) for new devs looking to get employed or experienced devs completing a project at work, which further grows the network effect!

Thanks for the kind words!

Collapse
 
tobysolutions profile image
Tobiloba Adedeji

Awesome article!!!

Collapse
 
miketalbot profile image
Mike Talbot ⭐

You block and block DOM links are broken.