When a client asked me to add a Vue-based dashboard to an existing React admin panel, the first thing I heard was "we can't afford another megabyte in the bundle". The site was already pushing the browser's limits on a low-end Android device, and adding another framework would have broken the performance budget.
I ended up solving the problem by moving the new Vue widget into a Web Worker and exposing a tiny messaging API. The result was three frameworks living on the same page with almost no extra JavaScript on the main thread. Below is the walk-through of why this works, how to wire it up, and the pitfalls to watch for.
Why Web Workers are a natural fit for micro-frontends
A micro-frontend is essentially a self-contained UI slice that can be developed, deployed, and even versioned independently. The usual approach is to bundle each slice into the main JavaScript payload and let the browser execute everything in the same global context. That works for small apps, but as soon as you start mixing heavy frameworks the bundle size balloons and the main thread becomes a bottleneck.
Web Workers give you a separate JavaScript environment with its own global scope, event loop, and memory heap. The main thread communicates with a worker via postMessage and receives data back through onmessage. Because the worker runs in a different thread, heavy parsing, rendering calculations, or even a full framework bootstrap won’t block UI interactions.
Key benefits for micro-frontends:
- Isolation, each slice can import its own version of React, Vue, or Svelte without worrying about version clashes.
- Parallel boot, workers start up concurrently, reducing the time to first paint.
- Reduced main-thread work, only the DOM-related code stays on the main thread, keeping scroll and click handling buttery smooth.
The part that trips people up: DOM access from a worker
Workers have no direct access to the DOM. If you try to call document.querySelector inside a worker you’ll get a ReferenceError. The usual pattern is to keep the UI rendering on the main thread and move the logic into the worker. For a micro-frontend this means the framework itself runs in the worker, but its virtual DOM output must be serialized and applied by the main thread.
Two strategies exist:
- String-based HTML patches, the worker sends a string of HTML, the main thread replaces a container’s
innerHTML. Simple but can cause re-flows and loses component state. - Structured patches, send a JSON representation of the virtual DOM (e.g., a lightweight diff) and apply it with a tiny reconciler. This preserves state and is far more efficient.
I chose the second approach because I needed to keep component state (form inputs, scroll position) alive across updates.
Setting up a minimal worker-based micro-frontend
Below is a stripped-down example that runs a Vue component in a worker and renders it into a <div id="vue-root"></div> on the page. The same pattern works for React or Svelte; you just swap the framework import.
// main.js, runs on the main thread
const vueRoot = document.getElementById('vue-root');
const vueWorker = new Worker(new URL('./vue-worker.js', import.meta.url), { type: 'module' });
// Receive patches from the worker and apply them
vueWorker.onmessage = (e) => {
const { type, payload } = e.data;
if (type === 'patch') {
// Very naive patcher: replace innerHTML
// In production you would use a diff algorithm here
vueRoot.innerHTML = payload.html;
}
};
// Tell the worker to mount the app
vueWorker.postMessage({ type: 'init', selector: '#vue-root' });
// vue-worker.js, runs inside a Web Worker
import { createApp, h } from 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.prod.js';
let app = null;
self.onmessage = (e) => {
const { type, selector } = e.data;
if (type === 'init') {
// Create a minimal Vue app that renders a counter
const Counter = {
data: () => ({ count: 0 }),
template: `<button @click="count++">Clicked {{ count }} times</button>`
};
app = createApp(Counter);
// Instead of mounting to the real DOM, we render to a string
const { renderToString } = await import('https://cdn.jsdelivr.net/npm/@vue/server-renderer@3');
const html = await renderToString(app);
// Send the HTML back to the main thread
self.postMessage({ type: 'patch', payload: { html } });
}
};
What the code does
self.postMessagesends a JSON packet containing the rendered HTML string.vueRoot.innerHTML = payload.htmlreplaces the container’s content on the main thread.- The Vue app never touches the real DOM; it only produces markup.
Why this works
- The heavy Vue runtime and its component logic stay inside the worker.
- The main thread only receives a plain string, which is cheap to parse.
- No global
windowordocumentis needed inside the worker, keeping the environment clean.
Scaling to multiple frameworks
When you need more than one slice, you spin up a separate worker per framework. Each worker gets its own container element ID, and the main thread maintains a simple registry:
const workers = {};
function loadSlice(name, scriptUrl, containerId) {
const worker = new Worker(new URL(scriptUrl, import.meta.url), { type: 'module' });
workers[name] = { worker, containerId };
worker.onmessage = (e) => {
const { type, payload } = e.data;
if (type === 'patch') {
document.querySelector(containerId).innerHTML = payload.html;
}
};
worker.postMessage({ type: 'init', selector: containerId });
}
loadSlice('react', './react-worker.js', '#react-root');
loadSlice('svelte', './svelte-worker.js', '#svelte-root');
Each worker runs its own bundle (React, Svelte, etc.) without colliding. Because workers are isolated, you can even use different versions of the same library, a huge win for teams that own separate micro-frontends.
Practical warnings you’ll hit early
- Transfer size, sending large HTML strings every frame can negate the performance gain. Use a diff format (e.g., JSON Patch) or a binary protocol like MessagePack if updates are frequent.
- Event handling, UI events still fire on the main thread. You need to forward them to the worker if the component’s logic depends on them. A simple wrapper that listens for
clickon the container and posts a message back works for most cases. - Third-party scripts, libraries that rely on
windowglobals (e.g., analytics) must be loaded on the main thread, not inside the worker. - Debugging, Chrome’s DevTools treat workers as separate contexts. Open the Sources panel, click the worker name, and you’ll see its source files. Remember to set breakpoints inside the worker, not the main script.
When to avoid this pattern
If your micro-frontend is tiny (a few kilobytes) and doesn’t import a heavyweight framework, the overhead of creating a worker (≈ 1 ms) and serializing messages may outweigh the benefits. Also, if you need tight integration with the surrounding page (e.g., shared CSS variables or global state), keeping everything on the main thread is simpler.
Performance numbers I observed
On a mid-range Android phone:
- Baseline, React admin panel alone: 1.8 s to interactive, 3 MB JavaScript.
- Add Vue slice directly, bundle grew to 5 MB, time to interactive jumped to 3.2 s.
- Vue in a worker, main bundle stayed at 2 MB, time to interactive was 2.0 s. The Vue worker booted in ~ 400 ms in parallel, and UI remained responsive during that time.
These are anecdotal, but they illustrate the trade-off: you keep the main thread lean while still delivering rich UI pieces.
Tooling tips
- Rollup/webpack, configure a separate entry point for each worker. Set
output: { filename: '[name]-worker.js' }and markselfas the global object. - esbuild, its
--workerflag automatically bundles code for a worker environment. - vite, use the
workerplugin to import workers as modules (new Worker(new URL('./my-worker.js', import.meta.url))). - Comlink, abstracts the
postMessageboilerplate. It lets you call functions on the worker as if they were local, which is handy for complex interactions.
FAQ
How do I pass complex data (e.g., FormData) to a worker?
You can use the structured clone algorithm: just postMessage the object and the browser will copy it. For large binary blobs, consider using Transferable objects like ArrayBuffer to avoid copying.
Can I share CSS between workers?
CSS lives on the main thread. Load the same stylesheet globally, then each container inherits it. If a worker needs scoped styles, generate a <style> tag in the main thread and inject it into the container.
What about SEO, will search engines see the content rendered by workers?
Most crawlers execute JavaScript, but they may not wait for worker messages. To be safe, render critical SEO content on the server or in the initial HTML, and treat worker-generated UI as progressive enhancement.
Wrapping up
If you ever find yourself fighting bundle size because different teams keep pulling in their own frameworks, give Web Workers a try. They let you keep each micro-frontend truly independent, boot them in parallel, and protect the main thread from heavy parsing work. The only extra work is serializing UI updates and wiring events, but once you have a small utility library in place, adding a new slice feels as easy as dropping a new worker script. Next time a client says "we need React, Vue and Svelte on the same page", you’ll have a concrete answer that doesn’t involve a massive download or a sluggish UI.