Rocket Loader is in the news again. One of Cloudflare's earliest web performance products has been re-engineered for contemporary browsers and Web standards.
For a high-level discussion of Rocket Loader aims, please refer to our sister post, We have lift off - Rocket Loader GA is mobile!
Below, we offer a lower-level outline of how Rocket Loader actually achieves its goals.
Early humans looked upon Netscape 2.0, with its new ability to script HTML using LiveScript, and
<script> tag. The possibilities were endless.
Soon, the introduction of the
src attribute allowed them to import a file full of JS into their pages. Little need to fiddle with the markup, when all the requisite JS for the page could be included in a single, or a few, external files, specified in the page’s
<HEAD>. It didn’t take our ancestors long before they decided that the same JS file(s) should be in all pages, throughout their website, containing JS for the complete site. No worries about bloat; after all, the browser would cache it.
A clear, sunny, road to dynamic, interactive sites lay ahead. What could go wrong?
The solutions poured in, both from the developer community and browser vendors:
Community: Move script location to end of HTML page
A classic duh! moment. Amazingly, this simple suggestion helped, unless the script was required to help build the page, eg. using
It’s 1997, and IE4 introduces the
deferattribute. Scripts that do not contribute to the initial rendering of the page should be marked with
defer, and they will load in parallel, without blocking, and be executed in their markup order before
window.loadis fired (later, before
document.DOMContentLoaded). Script tags could remain in the
<head>, and execute as if they were at the end of page.
The main benefit to page rendering was the saving in script retrieval time.
Community: Reduce latency by reducing actual script size.
What began as script obfuscation for intellectual property and vanity reasons, quickly became script minification, still used widely.
Community: Reduce latency and http handshake instances through concatenation of all scripts, delivered as one.
In 2010, 13 years (yes, 13, thirteen) after
deferwas born, HTML5 provided
deferwith a sibling,
async. Scripts can be loaded asynchronously, be non-blocking, and be executed when they load. Markup order is irrelevant to execution order. A clear benefit over
load/DOMContentLoadedevents were not delayed.
Community: Lazy Loading.
Use JS to load JS by dynamically creating non-blocking script tags.
Cloudflare: Rocket Loader
It's 2011, and Cloudflare enters the fray, leveraging our network to reduce http requests for 1st party scripts, “bag”ging 3rd party scripts into a single file, and delaying and controlling JS execution.
<link rel="preload">in the
Important resources like scripts, in our case, can be specified for preload. The browser will load scripts in parallel and not block render-parsing.
Rocket Loader, The Early Years
If reading outdated blog posts is not your thing, perhaps watching an extremely short video of a high-profile early Rocket Loader success (June 9, 2011) is:
CloudFlare Rocket Loader makes the Financial Times website (FT.com) faster
Rocket Loader improved page load times by:
- Minimising network requests through the bundling of JS files, including third-party, speeding up page rendering
- Asynchronously loading the bundles, avoiding HTML parsing blockage
- Caching scripts locally (using LocalStorage), reducing refetch requests.
As browsers matured, Rocket Loader fell behind, leading to several severe shortcomings:
It did not honour Content-Security-Policy.
Rocket Loader was unaware of CSP headers, and loaded scripts indiscriminately.
It did not honour Subresource Integrity
Rocket Loader loaded scripts through XHR, so browsers could not validate the fetched script.
It allowed for XSS Persistence
Since Rocket Loader stored scripts in LocalStorage, a site’s compromised script could exist as a trojan in a customer’s storage, loading whenever the customer visited the site.
It was just out-of-date
- Script bundling fell out of favour with the introduction of http2.
- The use of
eval()was finally recognised as evil.
- Mobile use skyrocketed; mobile browsers became sophisticated; eventually Rocket Loader was unable to support mobile.
New and Improved Rocket Loader
We recently rebuilt Rocket Loader from the ground up.
Although our aim remains the same, to improve customer page performance, we incorporated lessons learned. Most importantly, we learned not to aim too high. In order to satisfy all permutations of page layout, the old Rocket Loader created a virtual DOM, a decision that ultimately led to fragility. We've gone the simple, elegant route, knowing full well that there will be a minority of websites that will not benefit.
The main concept behind Rocket Loader is quite straightforward: execute blocking scripts after all other page assets have loaded.
The scripts need to be loaded and executed in the originally intended order. Only external blocking scripts curtail page resources, but any script may rely on another one. We must simulate the loading process of scripts, mimicing how the browser would handle them during page load, but do it after the page is actually fully loaded.
On the Server
Rocket Loader has both a server-side and a client-side component. The goal of the former is to
<script>tags in the page markup to make them non-executable, and
- insert the client-side component of Rocket Loader into the page.
The server-side component is built on top of our CF-HTML pipeline. CF-HTML is an nginx module that provides streaming HTML parsing and rewriting functionality with a SAX-style (Simple API for XML) API on top of it.
To make the scripts non-executable, we simply prepend their
type attribute value with a randomly generated value (nonce), unique for each page request. Having a unique prefix for each page prevents Rocket Loader from being used as an XSS gadget to bypass various XSS filters.
Markup that looked like this:
So far, no rocket science, but by making most, or all, scripts non-executable, Rocket Loader has unblocked page-parsing. Browsers display content sooner, improving perceived page load metrics, and engaging the user.
On The Client
Generally, scripts can be divided into four categories, each having distinct load and execution behaviours when inserted into the DOM:
- Inline scripts - executed immediately upon insertion.
- External blocking scripts - start loading upon insertion, preventing other scripts from loading and executing.
deferscripts - start loading upon insertion, without preventing other scripts from loading and executing. Execution should happen right before
asyncscripts - start loading upon insertion, without preventing other scripts from loading and executing. Executed when loaded.
Modified diagram from HTML Standard
To handle load and execution of all script types, Rocket Loader needs two passes.
On the first pass, we collect all scripts with our nonce onto a stack, then re-insert them into the DOM, with nonce removed, and wrapped in a comment node. These serve as our placeholders.
Rocket Loader now iterates through the scripts in our stack and re-inserts them, maintaining their intended position in relevant DOM collections (
This process of script insertion and execution differs for each script category:
Inline scripts - Placeholder is replaced with the original script element, without nonce, making the script executable. Browsers execute such scripts immediately upon insertion, in the same execution tick.
External blocking scripts - As above, but Rocket Loader waits for the script’s
load event before unwinding the script stack further. This delay simulates the script's blocking behaviour manually. Only parser-inserted external scripts (i.e. scripts present in the original HTML markup) are naturally blocking. External scripts inserted or created via a DOM API are considered async. This behaviour can’t be overridden, so we need our simulation.
async scripts - The same insertion procedure as inline scripts. Browsers treat all inserted external scripts as async, so the default behaviour suits us.
defer scripts - These are not executed during the first pass, since in the simulated environment we haven’t reached the
DOMContentLoaded event yet. If we encounter a
defer script on the stack we re-insert it, as is, without removing the nonce prefix. It remains non-executable, but in the correct DOM position.
The second pass loads the
defer scripts. Again, Rocket Loader collects all scripts with the nonce prefix (these are now just
defer scripts) onto the execution stack, but does not replace them with placeholders. They remain in the DOM, since at this point in our simulated environment the complete document has loaded. We then activate them by replacing the
<script> elements with themselves, nonce removed, and let the browser do the rest.
Quirks I: Taming the Waterfall
Ostensibly, we have now simulated browser script loading and execution behaviours. However, there are some one-off issues we must deal with, quirks if you will.
There is one not-so-obvious difference between our algorithm and the real behaviour of browsers. Modern browsers try to be clever with the way they manage page resources, engaging various heuristics to improve performance during page load. These are, generally, implementation-specific and not set-in-stone by any specification.
Our waterfall is replaced with improved parallel loading and better load metrics.
document.write() is not dead yet
We've simulated script execution and insertion. We still need to deal with dynamic markup insertion. We can’t use
document.write() directly since the document is already parsed and
document.close() has been implicitely executed. Calling
write() will create a new document, erasing the entire current document. We must manually parse content created by the
document.write function and insert it in the intended location.
Not so simple, if one considers that
document.write can insert partial markup. In the following example, if we parse and insert content on the first
document.write call, we’ll completely ignore the completion of the
id attribute that should be inserted with the second call:
document.write('<div id="elm'); document.write(Date.now()); document.write('">some content</div>');
So, we have a hard choice:
- We can buffer all content inserted via
document.writeduring script execution and flush it afterwards, in which case already executed code expecting elements to be in the DOM will fail, or
- We can flush inserted markup immediately, but not handle partial markup writes.
Choosing the lesser of two evils, we decided to go with the first option: our observations showed cases like these are more common.
(Actually, there is a third option that allows for handling of both cases, but it requires proxying of a significant number of DOM APIs, a rabbit hole that we don’t want to dive into, KISS FTW, you know…).
Quirks III: I ain't got no
As mentioned, it’s not enough to just insert parsed markup. There are various modifications of the DOM performed by the parser during full document parsing that contend with malformed markup. We felt we should simulate at least some of them, because, well… scripts may rely on malformed markup.
Our initial implementation even included simulation of relatively exotic mechanisms such as foster parenting, but eventually we decided to keep things simple and the only thing that Rocket Loader simulates is the squeezing out of unallowed content from the
To perform this simulation we wrap our
document.write buffer in a
<head> element and feed this markup to the DOM Parser.
Using the resulting document from the parser, we identify all nodes in its
<head> and move them into the page, immediately following the script that performed the
document.write. If we encounter any nodes in the parsed document's
<body> element, we copy all nodes that follow the current script to the
<body> element, prepended with the nodes in the parsed document.
To illustrate this simulation, consider the following page markup:
<!DOCTYPE> <head> <script> document.write(‘<link rel=”stylesheet” href=”1.css”>’); document.write(‘<div></div>’); document.write(‘<link rel=”stylesheet” href=”2.css”>’); </script> <link rel=”stylesheet” href=”3.css”> </head> <body> <div>Hey!</div> </body>
The buffered, dynamically inserted, markup after script execution will be
<link rel=”stylesheet” href=”1.css”> <div></div> <link rel=”stylesheet” href=”2.css”>
and the string that we’ll feed to the DOMParser will be
<!DOCTYPE> <head> <link rel=”stylesheet” href=”1.css”> <div></div> <link rel=”stylesheet” href=”2.css”> </head>
The parser will produce the following document structure from the provided markup (note that
<div> is not allowed in
<head> and was squeezed out to the
<!DOCTYPE> <html> <head> <link rel=”stylesheet” href=”1.css”> </head> <body> <div></div> <link rel=”stylesheet” href=”2.css”> </body> </html>
Now we move all nodes that we found in parsed document's
<head> to the original document:
<!DOCTYPE> <head> <script> document.write(‘<link rel=”stylesheet” href=”1.css”>’); document.write(‘<div></div>’); document.write(‘<link rel=”stylesheet” href=”2.css”>’); </script> <link rel=”stylesheet” href=”1.css”> <link rel=”stylesheet” href=”3.css”> </head> <body> <div>Hey!</div> </body>
We see that parsed document's
<body> contains some nodes, so we prepend them to the original document’s
<!DOCTYPE> <head> <script> document.write(‘<link rel=”stylesheet” href=”1.css”>’); document.write(‘<div></div>’); document.write(‘<link rel=”stylesheet” href=”2.css”>’); </script> <link rel=”stylesheet” href=”1.css”> <link rel=”stylesheet” href=”3.css”> </head> <body> <div></div> <link rel=”stylesheet” href=”2.css”> <div>Hey!</div> </body>
And as a final step, we move all nodes in the
<head>, that initially followed the current script, to after the nodes that we’ve just inserted in the
<!DOCTYPE> <head> <script> document.write(‘<link rel=”stylesheet” href=”1.css”>’); document.write(‘<div></div>’); document.write(‘<link rel=”stylesheet” href=”2.css”>’); </script> <link rel=”stylesheet” href=”1.css”> </head> <body> <div></div> <link rel=”stylesheet” href=”2.css”> <link rel=”stylesheet” href=”3.css”> <div>Hey!</div> </body>
Quirks IV: Handling handlers
There is one edge case which drastically changes the behaviour of our script-loading simulation. If we encounter elements with inline event handlers in the HTML markup, we need to execute all scripts that precede such elements since the handlers may rely on them.
We insert the Rocket Loader client side script in special "bailout" mode immediately before such elements. In bailout mode, we activate scripts the same way as in regular mode, except we do it in a blocking manner (remember, we need to prevent element from being parsed while we activate all preceding scripts).
As noted, it’s impossible to dynamically create blocking external scripts using DOM APIs such as
document.appendChild. However, we have a solution to overcome this limitation.
Since the page is still loading, we can
outerHTML of activatable script into the document, forcing the browser to mark it as parser-inserted and, thus, blocking. However, the script will be inserted in a DOM position different from its original, intended, position, which may break traversing of surrounding nodes from within the script (e.g. using
document.currentScript as a starting point).
There is a trick. We leverage browser behaviour which parses generated content in the same execution tick as the
document.write that produced it. We have immediate access to the written element. The execution of the external script is always scheduled for one of the next execution ticks. So, we can just move script to its original position right after we write it and have it in the correct DOM position, awaiting its execution.
"I can resist everything except temptation"
The need to account for every quirk, every variation in browser parsing, is strong, but implementation would eventually only weaken our product. We've dealt with the best part of browser parser behaviours, enough to benefit the majority of our customers.
As Rocket Loader matures, and inevitably is affected by changes in Web technologies, it may be expanded and improved. For now, we're monitoring its use, identifying issues, and ensuring that it's worthy of its predecessor, which lasted through so many advances and changes in Web technology.