Node.js incompatibility with Runtime Resolve Micro-frontends

February 18, 2023

Node.js’s incompatibility with Runtime Resolved Micro-frontends

I’ve been working on building a web platform that (among other things) supports micro-frontends.

A hard requirement of this micro-frontend platform is that these modules are indpendently shippable and server-side rendered. Because of that, the modules need to be resolved at runtime both in a browser setting and in a node.js runtime (the renderer).

One tricky issue with this is that rendering (at least in React apps) on the server is a single-pass synchronous operation, but resolving one of these (remote) modules is asynchronous.

Data fetching is also asynchronous though, and obviously we need to have data to render our app, so why not just resolve all remote modules, in the same async render blocking process that our data fetch lives in. We’ll have to resolve the remotes first then data fetch (in case the remote modules contains the data requirements of the data fetch), but no problem.

Importing Modules in Node.js

The way node.js loads module code when you use require() goes like this: the code is read from the file system, it is evaluated with a native node.js function called vm.runInThisContext() and the result of the evaluation is stored as a javascript object on the node.js global require.cache. Each time node.js tries to resolve that module again, it will look to require.cache and resolve the module from in-memory, rather than reading the code and evaling it again.

This design proposes the following tradeoff: an increased memory footprint for a decrease in latency and CPU usage on subsequent loads of modules. Keep in mind this is node.js, so the extra CPU usage of the evals blocks the main thread as well, aggravating latency issues you will get from event loop delay on your service. This tradeoff makes a lot of sense.

Importing Nested Runtime Resolved Modules

My original design for loading remote modules closely mirrored node.js’s design, but instead of reading the code from the local filesystem it fetched the javascript application code from a remote statics server and we cached the resulting object with a version string. This allowed us to ensure that when a developer shipped a new version of that module, we did not resolve that module from the cache (resulting in a stale module version being delivered to the user). This was performant and fast and scalable and worked great…until (and you may have already anticipated this) we needed to support nested runtime resolved modules.

The problem with the above design with nested modules is that the module cache can become inconsistent. When we load a remote module, it goes down the chain and resolves all of its dependents in the same async render blocking operation, then the result of this all gets cached under the top-level parent module and its version. When a child remote module is updated but the parent is not, we resolve the module from the in-memory cache without noticing that the nested child remote should actually be a new version, and the result is that we return a page with a stale module on it.

So, the node.js design of loading modules does not only tradeoff memory for latency and CPU usage of service, it also trades off supporting nested, runtime-resolved modules.

A solution that didn’t scale

The first solution we tried was this: reverse the trade-off, take away the in-memory module cache and instead read the application code from redis (very fast), and eval the module code on every request/render cycle.

As you can tell from the title of this section, this did not scale. It added ~200ms of latency to every request for a full page that is in the ~5MB range. The extra CPU usage and event loop delay that we would’ve experienced at production scales would have cost us lots of money (in extra compute, and in lost users).

So what to do?

Our current hacky solution is to keep using the in-memory module cache and eagerly re-evaluate the entire remote module dependency chain outside of renders/requests to make sure that when a new remote ships, it will only be stale for X minutes (X being the cadence at which we decide to eagerly re-resolve remotes and cache them.)

There are more robust solutions I know, (even just moving to a push model that eagerly updates the module cache upon a remote module being published) but this is what we got for now. What bugs me is that this seems to add complexity and move us outside of the patterns that node.js provides.

Does node.js paradigms just not support this mciro-frontend/runtime resolution? Are other folks not trying to do what we are doing? If not, I’d be surprised, but this means everyone is implementing some hack, or they are accepting a bunch of added latency and cpu usage?

Anyone, if anyone knows anything drop me a line.

P.S. I know this post would’ve been easier to understand with a few diagrams, but don’t got the bandwidth at the moment.