Let's talk about the tools we have to build web apps and how those tools deceive us.
You’re starting a new greenfield project, and this time, you’ll make sure that the site will be fast and slick! The starting line looks good, but soon your application gets big, and the startup performance starts to suffer. Before you know it, you have a huge app on your hands, and you are feeling powerless to fix it. Where did you go wrong?
Every tool/framework promises a better, faster result, yet we have the whole internet to tell us that the result is anything but a better, faster web. And who else is there to blame for this but the developers?
They messed up, cut corners, etc., which is why they ended up with a slow site. Or so we’re told.
If a few sites are slower, then sure, blaming the developer may make sense. “Look, the other sites are fast; clearly, the rest of the world knows how to do it right.” Something like that. Except that is not the world we live in. All sites are slow! How can you blame the developer when no one is succeeding? The problem is systemic. Perhaps it’s not the developer's fault.
This is a story of how we build apps, the tools we use, the promises the tools make, and the world of slowness we end up with. There’s only one conclusion. The tools have over promised, and this is a systemic problem in the industry. It’s not just a few bad apples, but the whole world wide web.
It's like a YouTube fitness channel telling you that all you need to do to lose weight is eat fewer calories. Easy advice, and yet the success rate is dismal. The reason is that the advice ignores appetite hormones. How can you keep your will to eat fewer calories when you are hungry, weak and all you can think about is food? The situation is stacked against you. The secret to losing weight may not be cutting calories after all, but controlling your appetite. But when was the last time you heard anyone talk about appetite control?
Let's take a look at how we got into this situation first, and then let's discuss a path forward.
Before ECMAScript modules, there was nothing. Just files. Bundling was simple. The files were concatenated together and wrapped in IIF. It was as simple as we could get.
The upside was that it was hard to add more code to your application, so the bundles stayed small. Code reuse just was not a thing with this model. Everyone concatenated code slightly differently.
Enter ECMAScript modules, a proper syntax for importing and exporting your code. This was awesome, but delivering thousands of files to the browser was a problem. And so, a whole cottage industry of bundlers arose: WebPack, Rollup, CommonJS, etc.
Now, code reuse has become possible. However, it was a bit too easy to npm install a dependency and bundle it in. Quickly size became a problem.
To their credit, the bundlers know how to do tree-shaking and dead code elimination. These features make sure that only reachable code gets bundled.
Recognizing the issue of large bundles, the bundlers started to offer lazy loading. Lazy loading is great as it allows code to be broken up into chunks and delivered to the browser on an as-needed basis. This is great because it allows the application to be delivered in parts, starting with the most needed part. It also allows the paralyzation of download, parsing, and execution.
The problem is that in practice, we build apps using frameworks, and the frameworks have a lot of influence on how bundlers break up our code into lazy loaded chunks. The trouble is that lazy loading a chunk introduces an asynchronous API call—a PROMISE. And if the framework expects a synchronous reference to your code, the bundler can’t introduce a lazy loaded chunk.
We are in a bit of a fallacy. Bundlers truthfully claim that they can lazy load code, but unless the framework can accommodate a PROMISE as a developer, you may not have much of a choice.
Frameworks quickly scrambled to take advantage of the lazy loading capability of the bundlers, and today almost all know how to do lazy loading. But there is a BIG caveat! Frameworks can only lazy load components that are not in the current render tree.
What is the render tree? It’s a set of components that make up the current page. An application usually has many more components than are on the current page. A portion of the render tree is the view. It’s what you currently see in the browser viewport. But a render tree encompasses the whole DOM, even the components outside of the current view.
Suppose a component is in the render tree. In that case, the framework must download the component because the framework needs to rebuild the render tree of components as part of hydration or update. Frameworks can only lazy load components that are currently NOT in the render tree.
Another point is that the frameworks can lazy load components but that always includes behavior. The component unit is too large because it encompasses the behavior. It would be better if the lazy loading unit were smaller. Rendering a component should not require the download of the component's event handlers. The framework should only download the event handlers on user interaction and not as part of the component render method. Depending on the kind of application you are building, the event handlers may represent the bulk of your code. So coupling the download of component's render and behavior is suboptimal.
It would be great to lazy load the component render function only when the component needs to be rerendered and lazy load the event handlers only if the user is interacting with them. The default should be that everything is lazy loaded.
But there is a big problem with that approach. The issue is that the frameworks need to reconcile their internal state with the DOM. And that means that at least once on application hydration, they need to be able to do a full render to rebuild the framework's internal state. After the first render, the frameworks can be more surgical about their updates, but the damage has been done, the code has been downloaded. So we have two issues:
So the status quo of frameworks today is that every component (and their handlers) in the SSR/SSG render tree must be eagerly downloaded and executed. Lazy loading with today's frameworks is a bit of a lie because you can't do lazy loading on the initial page render.
It’s worth pointing out that even if a developer introduces lazy loaded boundaries into the SSR/SSG initial page, it does not help. The framework will still have to download and execute all of the components in the SSR/SSG response; therefore, as long as the component is in the render tree, the framework will have to eagerly load the components that the developer tried to lazy load.
The eager download of the components in the render tree is the core of the problem, and there is nothing the developer can do about it. Still, that doesn’t stop the developer from getting blamed for how slow the site is. Fun!
So, where do we go from here? The obvious answer is that we need to go more fine-grained. The solution is both obvious and difficult to implement. We would need to:
If your framework can do the two parts above, the user would see huge benefits. The application would have lite startup requirements because no rendering would need to happen on startup (the content is already rendered at SSR/SSG). Less code is downloaded. And when the framework determines that a particular component needs to be rerendered, the framework can do so by downloading the render function without downloading all of the event handlers.
Fine-grained lazy loading would be a huge win for site startup performance. It’s much faster because the amount of code downloaded would be proportional to the user interactivity instead of being proportional to the initial render tree complexity. Your sites would become faster, not because we got better at making the code small, but we got better at only downloading what we need rather than downloading everything up front.
It’s not enough to have a framework that can do fine-grained lazy loading. Because, to take advantage of fine-grained lazy loading, you must first have bundles to lazy load.
For bundlers to create lazy loadable chunks, bundlers need an entry point for each chunk. If all you have is a single entry point to your application, bundlers can't create chunks. And if that’s true, even if your framework could do fine-grained lazy loading, it would have nothing to lazy load.
Creating entry points is cumbersome today because it requires that the developer writes extra code. When developing the application, we can really only think about one thing, which is the feature. It’s simply unfair to developers to make them think about the feature they’re building and lazy loading at the same time. So in practice, creating entry points for bundlers is not a thing.
What’s needed is a framework that creates the entry points without the developer thinking about it. Creating entry points for the bundlers is a framework’s responsibility, NOT the developer’s responsibility. The developer's responsibility is to build features. The framework's responsibility is to think about the low-level implications of how the feature should be accomplished. If the framework doesn’t do that, then it’s not fully serving the developer's needs.
The goal should be to create as many entry points as possible. But, some might ask, wouldn’t that cause the download of lots of small chunks rather than a few big ones? The answer is a resounding “no”.
Without an entry point, a bundler can’t create a new chunk. But nothing stops the bundler from putting several entry points into a single chunk. The more entry points you have, the more freedom you have to assemble the bundles in the most optimal way. The entry points give you the freedom to optimize your bundles. The more of them, the better.
The next-gen frameworks will need to solve these problems:
The developers will build their sites, just as they do today, but the sites will not overwhelm the browser with huge bundles and execution on application startup.
Qwik is a framework that is designed with these principles in mind. Qwik fine-grained lazy loading is per event handler, render function and effect.
Our sites are getting bigger, with no end in sight. They are big because the sites do more today than before—more functionality, animation, etc. We can assume that the trend will continue.
The solution to the above problem is to do fine-grained lazy loading of code so that the browser doesn’t get overwhelmed on the initial page load.
Our bundling tools support fine-grained lazy loading, but our frameworks don’t. Framework hydration forces all components in the render tree to be loaded on hydration. (The only kind of lazy loading framework support today is for components that are currently not in the render tree.) Frameworks also download event handlers with the component even though event handlers can be the bulk of the code and will not be needed for initial hydration.
Because bundlers can fine-grain lazyload, but our frameworks don’t, we fail to recognize the subtlety in this. The result is that we blame the slow site startup on developers because we mistakenly believe that they could have done something to prevent the situation, even though the reality is that they had very little say in this matter.
We need new kinds of frameworks that are designed with fine-grained lazy loading as a core feature of the frameworks (such as Qwik). We can't expect the developers to take on this responsibility; they’re already overwhelmed with features. The frameworks need to think about both lazy loading runtimes as well as creating the entry points so that the bundlers can create the chunks to lazy load. The benefits of the next-gen frameworks would outweigh the cost of switching to them.