Incremental adoption of micro-frontends without a rewrite

Micro-frontend architectures built on Cloudflare Workers offer real benefits for Web applications: faster interaction times, independent deployability, and better scalability. But adopting that architecture usually implies a full rewrite, which is impractical for most existing applications. This post presents a path for incrementally converting selected views of a legacy client-side rendered application into server-side rendered micro-frontend fragments, without touching the rest of the codebase.

The limits of monolithic frontends

Large client-side applications often suffer from a common problem: too much JavaScript must be downloaded and executed before users can interact with the page. Even with lazy loading and server-side rendering, these applications can remain unresponsive for seconds. Beyond user experience, monolithic codebases create organizational friction — multiple teams coordinating on a single deploy pipeline makes it hard to ship individual features.

Micro-frontends address both issues, but the cost of converting an existing monolith is high. Months of engineering effort can pass before users or developers see any payoff. What's needed is a way to apply the architecture selectively, starting with the parts of the UI that matter most.

Rendering fragments before the shell exists

The fragment approach splits an application into micro-frontends that can be rendered and cached quickly by Cloudflare Workers. The challenge is integrating those fragments into a legacy application that only exists client-side.

In many applications, the most valuable UI elements are nested inside a shell of navigation and layout. A login form, a product detail panel, or an inbox are examples. These are also often the slowest parts of the experience: if a login form takes several seconds to appear, users may give up entirely.

Converting that form into a server-side rendered fragment lets it appear and become interactive immediately, while the rest of the legacy application still boots. The user can even submit credentials before the legacy shell finishes rendering. This delivers observable improvements to users in a fraction of the time a full rewrite would require.

The central technical problem is placement: once displayed, fragments should be embedded in the application shell, forming a correct DOM hierarchy, yet they must also be interactive before that shell even exists. The solution we developed, called "fragment piercing," handles this transition.

Fragment piercing

Fragment piercing combines the DOM produced by server-side rendered fragments with the DOM produced by a legacy client-side rendered application. On page load, fragments render directly at the top level of the HTML response and become interactive immediately. The legacy application boots in parallel, rendering as a sibling of those fragments.

When the legacy application is ready, its code pierces the fragments into place — moving each fragment's DOM nodes to the appropriate location inside the legacy shell, without causing visual flashing, focus loss, form data loss, or other state disruptions. Once pierced, fragments can communicate with the host application and appear as a fully integrated part of the UI.

[A login fragment and an empty legacy application root element exist side by side in the DOM before piercing.]

[After piercing, the fragment sits inside the "login-page" div within the rendered legacy application.]

CSS styles keep the fragment visually positioned identically before and after piercing, preventing any layout shift. Fragments can also be added or removed at any time — not just on initial load — allowing them to respond to client-side routing events and user interactions.

Because each fragment is independent, teams can incrementally adopt micro-frontends one at a time, choosing granularity as they go. Different fragments can also use different Web frameworks, which helps when switching stacks or integrating applications following an acquisition.

A demonstration: the Productivity Suite

To illustrate the technique, we built a demo application called the Productivity Suite. Its shell is a client-side rendered React application — the "legacy" app — that lets users manage todo lists and browse hacker news. Three routes have been updated to use fragments:

  • /login: a dummy login form with client-side validation, implemented in Qwik.
  • /todos: a todo manager built from two collaborating fragments — a list selector for creating and deleting lists (Qwik) and a TodoMVC-style editor (React).
  • /news: a clone of the SolidJS HackerNews demo (SolidJS).

This mix of frameworks in the shell and fragments demonstrates the technology-agnostic nature of the approach.

The demo is deployed at https://productivity-suite.web-experiments.workers.dev/. You can log in with any username and no password; data is stored in a cookie. The Todo Lists and News pages show piercing in action.

On any page, reload to see fragments render instantly while the shell continues loading. The page contains controls to simulate different load conditions:

  • "Legacy app bootstrap delay" sets how long the legacy application waits before booting.
  • "Piercing Enabled" toggles what the experience would look like without fragments.
  • "Show Seams" visually highlights which parts of the page are pierced fragments.

Architecture and request flow

The demo deploys coordinated pieces:

  • The Legacy application host serves the client-side React app's static files (HTML, JavaScript, CSS).
  • The Fragment Workers each host a single micro-frontend fragment.
  • The Gateway Worker receives browser requests, selects the appropriate fragments, and streams together responses from the different origins.

Consider the login page request flow:

[The browser, Gateway Worker, legacy host, and fragment workers exchange requests in parallel.]

When the browser asks for the initial HTML, the Gateway Worker identifies the login route and sends two parallel sub-requests — one to the legacy application for its index.html, and one to the login fragment worker for its server-rendered markup. The Gateway streams the two responses back as a single HTML document.

In the browser, that document contains both an empty root element for the legacy application and an already interactive login fragment. The browser then requests the legacy application's JavaScript bundle; the Gateway proxies that to the legacy host, and does the same for any fragment assets. Once the legacy script executes and renders the application shell, piercing moves the fragment into place, preserving all UI state.

Although the explanation centres on the login route, the same mechanism underpins the fragments on the /todos and /news routes.

How the piercing library ties fragments into the legacy app

All fragments in the demo, regardless of the Web framework they were built with, connect to the legacy application through the same set of helpers from the Piercing Library. This open-source collection of server-side and client-side utilities handles the integration work. Its main building blocks are the PiercingGateway class, the piercing-fragment-host and piercing-fragment-outlet custom elements, and the MessageBus class.

PiercingGateway: routing and stream composition on the server

The PiercingGateway class instantiates a Gateway Worker that intercepts every request for HTML, JavaScript, and other assets. That Worker routes each request to the correct Fragment Worker or back to the legacy application host, then merges the streams of HTML from all sources into one document sent to the browser. Building this Gateway Worker is a small amount of code: create a gateway instance with the legacy app's URL and an enablement check, then export it as the Worker's default export.

const gateway = new PiercingGateway<Env>({
  // Configure the origin URL for the legacy application.
  getLegacyAppBaseUrl: (env) => env.APP_BASE_URL,
  shouldPiercingBeEnabled: (request) => ...,
});
...

export default gateway;

Fragments are registered through the registerFragment() method. This lets the gateway automatically direct a fragment's HTML and asset requests to the right Worker. For instance, registering the login fragment looks like:

gateway.registerFragment({
  fragmentId: "login",
  prePiercingStyles: "...",
  shouldBeIncluded: async (request) => !(await isUserAuthenticated(request)),
});

Fragment host and outlet: piercing in the browser

Server-side routing and stream merging only cover half of the piercing flow. The browser-side work happens through two custom elements: <piercing-fragment-host> and <piercing-fragment-outlet>.

The Gateway Worker wraps each fragment's HTML inside a fragment host. In the browser, this host is responsible for the fragment's lifecycle and for moving the fragment's DOM into the right spot of the legacy page.

<piercing-fragment-host fragment-id="login">
  <login q:container...>...</login>
</piercing-fragment-host>

On the legacy application side, the developer adds a fragment outlet where the fragment should eventually land. The demo app's login route declares it like so:

export function Login() {
  …
  return (
    <div className="login-page" ref={ref}>
      <piercing-fragment-outlet fragment-id="login" />
    </div>
  );
}

Whenever an outlet appears in the DOM, it first looks for a matching fragment host elsewhere in the document and relocates that host into the outlet. If no host is present, the outlet requests the fragment's HTML from the gateway Worker instead and streams the response directly into the outlet using the writable-dom library from the MarkoJS team. That fallback path is what makes client-side soft navigation work when a route contains fragments that haven't been loaded yet — initial hard navigation and soft navigation both end up producing the fragments in the browser.

MessageBus: cross-fragment communication

Fragments that aren't purely presentational need a channel to talk to the legacy application and to one another. The MessageBus is an asynchronous, framework-agnostic bus that is isomorphic so both server and client code can share it.

In the demo, the login fragment notifies the legacy app once authentication completes. Inside the Qwik LoginForm component, the message dispatch is:

const dispatchLoginEvent = $(() => {
  getBus(ref.value).dispatch("login", {
    username: state.username,
    password: state.password,
  });
  state.loading = true;
});

The legacy application subscribes to those auth messages like this:

useEffect(() => {
  return getBus().listen<LoginMessage>("login", async (user) => {
    setUser(user);
    await addUserDataIfMissing(user.username);
    await saveCurrentUser(user.username);
    getBus().dispatch("authentication", user);
    navigate("/", { replace: true, });
  });
}, []);

The team chose this design specifically because it stayed framework-agnostic and worked equally well server-side and client-side.

Trying it yourself

Fragment piercing combined with Cloudflare Workers lets you improve performance and development speed on a legacy client-side rendered app without a risky rewrite. Every change is incremental, and each fragment can be written in any Web framework you prefer.

The working example, called "Productivity Suite," runs at https://productivity-suite.web-experiments.workers.dev/, and all code shown here is published on GitHub at https://github.com/cloudflare/workers-web-experiments/tree/main/productivity-suite. The repository runs locally and can be deployed to Cloudflare for free. The reusable core logic lives in the piercing library, which you can try in your own projects. Feedback and questions are welcome via the GitHub discussion or the Discord channel.