The behaviour of browsers and their loading of resources
Browser Streaming: How the DOM and Images Load Progressively
The speaker opens with a live demo showing how browsers stream HTML content and parse it into the DOM incrementally, without needing the full document first. A second demo with multiple images illustrates the same progressive rendering behaviour, and reveals the HTTP/1 limit of six simultaneous requests per server on Chromium — leading to a recommendation to consider HTTP/2 or HTTP/3 for asset-heavy applications.
Render-Blocking Stylesheets and the Flash of Unstyled Content
The speaker demonstrates that stylesheets are render-blocking: the browser continues parsing content into the DOM but withholds display until the stylesheet has loaded, preventing a flash of unstyled content. A slow-motion playback makes the blocking behaviour visible, and the speaker explains why placing the stylesheet in the document head means users see nothing until it arrives.
Splash Screens as a Stylesheet Loading Strategy
The speaker shows a practical workaround for render-blocking stylesheets: injecting a styled splash screen element before the main stylesheet link so users see something immediately. Once the main stylesheet loads, the splash screen is hidden and the regular content appears. A brief note covers how JavaScript scripts behave similarly as parse-blocking resources.
ESM Modules in the Browser and the Waterfall Problem
The speaker introduces ES modules (ESM) in the browser, tracing a chain of synchronous imports across four files (a, b, c, d) and showing in the network waterfall that each module must resolve before the next can load — creating sequential round trips. This waterfall problem is identified as the second reason to use a bundler like Webpack or Vite/Rollup, and the speaker demonstrates that concatenating all code into one file eliminates the round trips.
Module Preloading and Dynamic Imports for Performance
The speaker covers two browser-native strategies to improve ESM loading. Module preload (baseline 2023) tells the browser to fetch all modules simultaneously, eliminating sequential waterfall delays. Dynamic imports (lazy loading) allow modules to be fetched on demand so the browser can act before all modules are downloaded, though they reintroduce the waterfall. The speaker notes the two techniques can be combined.
Content Hashing, Caching, and the Cache Invalidation Cascade
The speaker explains how content hashes appended to filenames enable immutable caching of static assets. Using a Vite demo with a chain of twenty modules, the speaker shows that changing a single leaf module (a20) cascades filename changes all the way up to the entry point because each parent module imports by the child's hashed filename — invalidating the entire bundle chain and forcing users to re-download everything.
Import Maps as the Solution to Cache Invalidation Cascades
The speaker introduces import maps (baseline 2023) as the correct fix for cache invalidation cascades. By mapping bare module names to URLs in a central import map, individual modules can reference a neighbour by name rather than by hashed filename, so only the changed module's entry in the map needs updating — leaving all other module files unchanged and cache-valid.
Webpack's Module Registry and Why It Avoids the Cascade
The speaker demonstrates that Webpack does not suffer from cache invalidation cascades because its compiled modules do not import each other by filename. Instead, each module registers itself on a global object and the entry point reconstructs the application from that registry at runtime. A live browser inspection of the global module object confirms this architecture, showing why changing a20 only invalidates a single Webpack chunk.
Summary: Bundling Trade-offs and ESM Strategy Comparison
The speaker closes with a concise comparison of four approaches — one big bundle, synchronous ESM imports, async ESM imports, and Webpack modules — evaluating each against download cost, server round trips, caching independence, and cache invalidation cascade risk. Module preloading and import maps are highlighted as mitigations for ESM drawbacks, while Webpack's cascade immunity comes at the cost of non-standard module format and reduced tree-shaking potential.
Alright. Let's jump straight into it. So starting off nice and easy. I have some HTML here. I have a little tiny bit of CSS just to put a box around each one of these. And each one of these is actually 50 kilobytes of of random HTML. I have six of those.
Let's just open this one up. And what I'm doing here is we've disabled the cache and we've throttled it to three g. Let me just turn that responsive off. And I'll hit refresh. And what we can see here is each of these devs come in one at a time.
We can see the content loading here in that waterfall. So the point here is that the browser doesn't need the entire document to be there before it can make use of it. So what the browser is going to do is it's going to stream the content. As it streams it, it's gonna parse it into the DOM, at which point any JavaScript that's running will be able to use that element.
They'll be able to see it. And then as the element is parsed into the DOM, the browser will render it, and we can see it. In this example here, I have a whole bunch of images. These are all the same image, but I've got a query param on each of them. And so that's just gonna get the browser to treat them as separate images.
I'll just hit refresh. And what we can see here is that the images are being displayed as the browser gets them. Again, the browser doesn't need to have the whole image before it displays it. And if you ever use dial up Internet, you'll be well familiar with this behavior. Note also that only six of these images are loading at a time. So those ones just finished, another six are about to kick off. And the reason this is happening is because the protocol is h t p one, and that's a limitation of h t p one. There's a maximum number of simultaneous requests per server the browser can make. That limit is browser dependent.
On a Chromium browser, the limit is six. So if your application makes a lot of simultaneous requests either because you have a lot of assets, you have a lot of images, or perhaps just your JSON REST API makes a lot of requests simultaneously, consider looking at h t p two or three. Okay. In this example here, I have three of those 50 kilobyte DOMs, devs, and then I have a style sheet and I have another six devs. And the style sheet, of course, has about a 150 kilobytes of of random CSS.
So that's what it's gonna look like at the end, but if we play this in slow motion, I'm just gonna go about blank here. The reason I'm doing this is because, otherwise, the style sheet will already be there, and it kinda messes up with how how the presentation plays. So here, we can see those, devs come in unstyled, and now we can see the style sheet coming in.
And note that nothing is happening here. So those other devs are still being streamed, but we're not seeing them. And then once we got the style sheet, then they all popped in all at once. And the reason, this happens is because style sheets are render blocking. So the, the browser is gonna stream the content in, and it's gonna parse them parse the content into the into the DOM.
And so any JavaScript that's running is still gonna be able to to see these elements, but the browser's gonna hold off actually displaying them. And the reason it does that is because we don't want to show the user a flash of unstyled content. So if we've been just displaying them the content before the the style sheet got there, then, yeah, we would get a big layout shift and and whatnot.
Now in this example, I'm just gonna turn off Throttle in. In this example, we actually do have a a flash of unstyled content, and that was for the purposes of the demonstration where I wanted to show those three divs come in unstyled. But in a regular sort of usage, we would probably put our style sheet up here in the in the document head.
It'd be the first thing you start loading. And so then be aware that your the your browser is not gonna show you anything until you've got that style sheet. And so maybe one thing you can do is display a little bit of a splash screen while that main style sheet comes in. So that's what I've got here.
So I've got a little splash screen dev here, and I've got some some styling for that that splash screen, a bit of fancy CSS. And then I load my style sheet after after the splash screen element. And once we've got that main style sheet, we're gonna hide the splash screen. Oops. Sorry. I gotta turn that back on.
So here, we can see our splash screen. We can see the the style sheets loading. And once that style sheet's loaded, then the splash screen's gonna disappear, and we're gonna see our regular content. Okay. Now, in the interest of time, I'm actually gonna skip over, some of the nuances of JavaScript regular standalone JavaScript scripts.
But just be aware that, JavaScript scripts, behave in the same in in a similar manner. JavaScript scripts are, parse blocking. So similarly, if, this instead of a link ref, this was a script source and where we had a large JavaScript script.
Again, the, we wouldn't see anything until that script had fully loaded and executed. The difference being it would actually be pass blocking, meaning that any JavaScript that was running at the time wouldn't be seeing new elements while the script was loading. So let's jump to, modules.
Okay. So here, I've got my HTML, and I'm referencing a script and its type module. And I'll click into that. I've got 50 kilobytes of filler down here just for sort of timing and demonstration purposes. What this script is gonna do is it's gonna add some text to the screen saying, I'm entry point, and then it calls function a. Function a is imported here.
When we look at function a function a, similarly, has a bunch of filler. It's gonna write I'm a block, and it calls function b, which is imported like this and so forth, a, b, c, d. Now as front end developers, we've been writing modular code like this with these imports and exports for far longer than browsers have been able to support the syntax.
This is what we're calling ESM modules. So ESM in the browser has has had baseline support since 2017, but we were writing modules like this far longer, far before that. And that brings us to the first reason why we want to use a bundler, so something like Webpack, is because we needed to convert this code into something that the browser could actually read.
And so I'm gonna be talking about bundlers. I'm gonna talk about Webpack, and then there's also Vite. So Vite isn't actually a bundler. The bundler is Rollup, but most people seem to be more familiar with Vite. So so just gonna use those interchangeably. And roll up, its whole thing is that it it compiles into ESM modules, whereas Webpack is still has its own Webpack modules.
So we take a look at how this runs. Okay. So what we can see here is the main script is loading, and now a is loading. And note we're not seeing anything here. Right? Now we've got c loading, and now we got d loading.
And then finally, once d is loaded, then we get some action here. Okay? And if we look at the waterfall here, these things had to happen one after another. And this brings us to the second reason to use a bundler. So even if you're even if the browser is supporting ESM modules, we're gonna save on these these kind of round trips.
So if we look at this, note that most of the time is actually this green waiting for server response part. The actual content download is it's four hundred milliseconds, which is still a long time. But, yeah, we have a bunch of these green bars, whereas if we could just do that once we bundle all into one file, we still have to download all of the content before we get some action here, but at least we're saving on those those round trips.
And so that's what I've done here. I haven't used the bundle. I've just copy pasted all of the code into the one file, and we just got the one import. And, of course, nice and simple, it downloads and executes. So, bundling our code into one to one chunk, that's one optimization we can make, there are some other things we can do.
So one thing we can do is we can preload our modules. Now module preload is quite a new feature that has baseline support since 2023. And in this scenario, we're telling the browser, hey. Start loading these modules now. And, of course, all of them start loading at the same time.
They're all about the same time. So they're all about the same size so that you'll finish it about the same time, and and it all executes. The other thing we can do, and you will commonly see, React will be recommending this.
The bundling tools themselves will recommend this, is if the the bundle size becomes too big, they'll start warning you to to to reduce your, to do some bundle splitting, and they'll recommend using dynamic imports or or lazy loading. I'm using those terms interchangeably. So here, instead of having that synchronous import I might just make that one bigger. Instead of having that synchronous import up the top, we've got this await import round bracket syntax here.
And if I click into that function a, again, the the import is inside the function body itself. We don't have the synchronous import at the top. And if we take a look at how this behaves, So we get main loading, and then something happens here.
Now a is loading. Something happens there. B is loading. Something happens there, and so on. So with dynamic imports, we still have this loading waterfall, but at least we get some action on the browser. And, of course, you can combine, dynamic imports with module preloading.
We could do both of those. And so at this point, let's talk about caching a bit. Because if we had, say, this scenario, this loading situation wouldn't matter if the the browser already had these, these, resources cached locally.
So they'd already visited their website. They'd already been cached. Yeah. They would just they would just execute immediately. Of course, if it was a user's first time visiting your website, they would still have to download everything. Possibly, you've pushed your assets out to a CDN, so the the download speed, you're not having to go all the way back to the server. And this might represent a a worst case scenario for the user. But depending on what the business model is, if if, this is a product that is, behind an authentication wall anyway, maybe this stuff doesn't matter.
But we want to, we want to be able to cache, these, these static assets. And the way we usually cache static assets is we attach a content hash to them. So let's, demonstrate that. So this example here is a just a very simple Vite application.
I have some HTML. There's our entry point script. Our entry point is gonna call a one. A one has a bunch of filler, and it's gonna call a two. And it goes all the way down. Alright. And let me just get rid of all of this.
Okay. And so when I build it, we get this one, we get this one, bundled, chunk here, this index file, and note that's got this gibberish on the end. So that gibberish is a content hash. So it's a a a one way function that is deterministic is deterministically determined, by the the content of the file.
If I run build again without changing anything, so that's that's never gonna change. But if I go and I change some of this content, then that content has changed. And we can use this for caching because we it means we can safely add an immutable cache directive to our static assets because if the content ever changes, the file name's gonna change.
So the the browser's never gonna get confused and use an old resource. But the problem we have here is this is a a one megabyte chunk. So if I make any change to any of these files, it's gonna invalidate the whole bundle. And you can see here Vite is warning us about this.
Right? It's saying, this is a big chunk. Maybe try code splitting it, and it's suggesting using dynamic imports, or the, the manual manual chunking, which is essentially to have the developer define boundaries about around the parts of the code that you want to turn into a single chunk or another.
And so in this example here, there's a few different ways. So I could be doing the dynamic splitting sorry, the I could be doing the dynamic imports. I could be doing the manual chunking. Sorry. Wrong one. I'm just gonna use this option as for clarity of this exercise.
There's a a few other dramas with the other things. But those other options are also gonna have this problem that I'm about to demonstrate. What this is going to do is when I go NPM run build, it just retains the the module structure as it was.
So if we look into that disk folder, we can see those a one, a two, and they've all got their their content hashes attached. Now note that I haven't git ignored the dist folder. Ordinarily, you would, get ignore your dist folder, but, for me, this is a helpful way for me to demonstrate which files have changed.
So if I go oops. I go get status. That other file changed in the other example, but nothing has changed here. Now if I come in here and I change a one, and we go, b m run build and get status, we can see that the a chunk has changed and so has the main chunk. Well, okay. But if I come down and I change a 20, they do the same thing.
What we can see is that not only has a 20 changed, but all of, a one through a 20 has changed. And the reason for this is if we look in at the compiled code well, so the content of a 20 changed. Of course, its filename changed.
But when a 19 goes to import a 20, well, the a 20 filename has changed. So, of course, a 19 has changed, its file changes. And then so when a 18 goes, right, and we get this cascade all the way back up. Now so the at this point, there's there's good news and there's bad news. So the good news is that there is a solution to this problem, what we call location validation cascade.
And the bad news is that Vite hasn't currently implemented the solution. And so the solution is called an import map. And you can see it's, baseline available 2023.
An import map looks like this where we declare the names of our modules and then where to get them. That could either be a a full URL because it could be from from a different, domain or or a relative URL. And the way we would use an import map to solve this problem in this case is if we jump back here to a 19, instead of doing our import, sorry, with the full content hash, we would just go import a 20 from a 20, and then we would have an a 20 and, reference the updated file name.
So that way, a 19 doesn't need to to change, when a 20 did change. Now note that this problem doesn't exist for Webpack, and let me demonstrate that. So in this example, I've got a a simple Webpack configuration.
Webpack's being configured here to just keep the keep the chunks between twenty and fifty kilobytes. And the structure of this application is the same where we've got our entry point and it's importing a one, which is gonna import a two, etcetera. Oops. Let me just go back to that other one.
I'll just get rid of all of that. Alright. Sure. If I go NPM run build well, let me just not sure why we're seeing index change, but, so we're not really seeing any changes here.
And if I go in and change, was it services linear, and I go and change a 20 here, we can see that just the one chunk has changed. And the reason that is is because if we go and go find that new one.
The reason that is is because I know this doesn't look readable, but I'm just pointing out one thing. The reason that is is because Webpack modules don't import from each other directly. They don't reference each other directly. Instead, what each what each module is going to do is it registers itself onto a global object, and then it's up to the entry point, module to then inspect that global object and kinda put the application back together.
So I'll just copy paste that. And if we look at, this in the browser, we go window dot so here, we can see I feel like that's the wrong one. Maybe that was a loading thing.
There we go. Okay. There we go. We can see all of our, all of our modules here, and that's how the modules are able to communicate each other with each other. They go via this one global object. And so now back to some slides. Alright. So to just summarize all of that.
Okay. So if you've got one big bundle, then the client needs to download the whole thing before it connect. And any change to any part of it is gonna invalidate the whole thing, and the the user will have to download the whole thing again. If you've got synchronous ESM mod imports, then all of those synchronous imports need to be resolved before the whole thing can act.
And so you're gonna get the same total content as just having one big bundle but with more server round trips. But you can re you can mitigate that with module preloading, and that has baseline support since 2023. And, also, each of those each of those chunks can be cached independently, but, of course, you, dependencies will cause a cache and validation cascade.
But the cache invalidation cascade's not worse than invalidating the one big bundle. Right? So if you think it was like a tree, maybe only one branch of it is, is being invalidated. And this can be mitigated with import maps. So async ESM, imports, so the modules can act as they load.
The round trips will still exist, and that can be mitigated with module preloading. They can be cached independently and has the same caching validation cascade. And Webpack modules, they're not subject to, cache and validation cascades, but they're not ESM modules. And, with all the drama that comes with that, just quickly, I mean, my understanding is that ESM, is better for, like, tree shaking.
Alright. So that's the talk. This is me on LinkedIn. That's what I look like. That's my blog. I've got presentation notes here. I'm including links to various open Veep issues about all of this stuff. It's definitely not particularly smooth sailing in in my impression.
But, yep, that's it.
Technologies & Tools
- dynamic imports
- import map
- module preload
- Rollup
- Vite
- Webpack
Standards & Specs
- ESM modules
- HTTP/1
- HTTP/2
- HTTP/3
Concepts & Methods
- cache invalidation cascade
- CDN
- content hash
- immutable cache directive
- render blocking
- tree shaking
Organisations & Products
- Chromium
Frontend frameworks may abstract the details away from us, so what’s really happening as
it relates to the loading of HTML, CSS and JS?
This talk uses real code examples to concisely demonstrate browser behaviours of
streaming, parsing, rendering and execution.
If you’re using ES Modules – beware of the cache invalidation cascade!















