Re-Writing in Rust == faster with more features
At over 3.2m downloads a month from NPM, https://browsersync.io is still heavily used in the industry. As I continue to modernise the implementation (by working on a Rust port) I came across an interesting case study that became up to 5x faster in a real-world setting, whilst also allowing me to add additional features.
Browsersync's proxy option
One of my favourite features in Browsersync is the ability to run a localhost
server, but proxy certain requests
through to a 3rd party domain.
browserSync.init({
proxy: "https://example.com"
});
This will give you back a localhost address that you use to make requests to, like localhost:3000
. The NodeJS implementation
will then send each request on to the configured proxy target ("https://example.com"
in this example), and return the
response as normal.
Sounds easy so far...
Actually, it's a bit more complicated than that π€£. Browsersync wants more control over the response, so that it can replace any links in the markup and inject an HTML snippet.
To do this in node is a bit of pain, and as I've come to realise, very, very slow too.
How the current proxy works
When a request comes in, for something like localhost:3000/index.html
, Browsersync needs to forward that onto the
proxy target. But before it can do so, it has to mess around with a couple of things.
- it appends a header to the outgoing request that effectively disables any compression
- it monkey patches the stream methods such that it can buffer the response body in memory
- once the response is fully buffered, string replacements are performed
Outgoing requests get transformed like this:
GET localhost:3000/index.html
Accept-Encoding: gzip, deflate, br, zstd
# becomes...
GET https://example.com/index.html
Accept-Encoding: identity
and incoming responses have the equivalent of this applied (of course, it's not exactly this, but you see the point):
const buffered_body = await req.get("example.com");
const altered_html = buffered_body.replaceAll("example.com", "localhost:3000");
res.write(altered_html)
It's a really cool feature, because you can combine it with local static assets, optionally overriding individual requests. Imagine a remotely hosted Wordpress site, and you just want to tinker with the CSS without having to set up an entire environment? Those are the kinds of weird and wonderful workflows that our community use Browsersync for π
Was it fast though?
Part of the reason for sending Accept-Encoding: identity
in the outgoing request, is to force the 3rd party to not
send a gzip/brottli response. It makes the body a bit larger, but it means that we don't need to do the decompression
on the node layer, so in theory the proxy method should be fairly fast.
In real world scenarios though, we see that the proxy introduces a noticeable slowdown.
We can use the Browsersync homepage as a nice, simple example, because it's served from Netlify's CDNs and the response bodies are all encoding with brottli.
If we run the following command, we can take a look at some simple metrics
browser-sync https//browsersync.io
Now we can access localhost:3000
and all requests will be forwarded onto https//browsersync.io
, but
with the modifications mentioned above.
baselinehttps://browsersync.io | proxy in node jshttp://localhost | |
---|---|---|
load time | 250-320ms | 780-900ms |
page weight | 368kb | 562kb |
compression | β | β |
The page weight is much larger since we're opting out of compression for the reasons mentioned above, but it's striking to me just how much slower the overall page load time is. π
Enter the Rust-Rewrite
Even though it's still an ongoing effort, I have the Rust implementation far enough along now to be able to make initial comparisons. The short version is that the new Rust based proxy introduces no noticeable overhead, whilst also supporting compression on both ends of the proxy πͺ
baseline | proxy in rust | proxy in node js | |
---|---|---|---|
load time | 250-320ms | 250-320ms | 780-900ms |
page weight | 368kb | 368kb | 562kb |
compression | β | β | β |
So the rust version is much, much faster and is easier to apply compression/decompression where needed.
This is just the tip of the iceberg though, since the composition model in the axum and tower ecosystem is so strong that it doesn't require the monkey-patching of any request/response stream writers like it does in node π
Looking forward
Although it's fun to jump on the 're-write it Rust' bandwagon, there are some other reasons improving the proxy element of Browsersync is so important.
If you have a close-to-zero-cost local development proxy, then you can start applying more modifications to the requests/responses without the fear that it's going to grind to a halt.
Some of the recent examples I've been playing with whilst developing the proxy is recording and then playing back streaming responses from LLM providers π π»