Re-Writing in Rust == faster with more features

Making a feature faster than the node equivalent, whilst adding more capabilities

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.

baseline
https://browsersync.io
proxy in node js
http://localhost
load time250-320ms780-900ms
page weight368kb562kb
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 πŸ’ͺ

baselineproxy in rustproxy in node js
load time250-320ms250-320ms780-900ms
page weight368kb368kb562kb
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 πŸ‘Œ πŸ’»