Skip to content

    Frontend System Design

    Micro-Frontend Anti-Patterns and Migration From a Monolith

    Spot the distributed monolith, shared-everything and nano-fragment traps, and move a front-end monolith over route by route with a routing facade and flags.

    By Published 11 min read

    • frontend-system-design
    • micro-frontends
    • anti-patterns
    • migration
    • strangler-fig
    Share

    Standard lessonintermediateFrontend System Design

    Before this: Independent Deployment and Shared Dependencies, Cross-App Communication in Micro-Frontends

    What you'll learn: By the end of this lesson you will be able to recognise the common ways a micro-frontend system loses its independence (lockstep releases, shared-everything, chatty fragments, fragments cut too small, visible seams, a migration that never finishes), test a proposed split against the reason for splitting, and plan a migration from a front-end monolith with a routing facade, a first slice, flag-based traffic ramping and a way back.

    Imagine renovating a shop that has to stay open. You do not close it for a year and reopen with a new interior. You rebuild one aisle at a time, put up a temporary wall, and move customers through the finished part while the rest is still in use. Martin Fowler's name for the software version is the strangler fig: a new system grows around the old one and gradually replaces it. He says a simple replacement plan is one he has seen "go down in flames most of the time", and notes that small replacement pieces carry less risk and let the business reap value earlier.

    The running example is an online shop built as one large React application with one build and one release train, owned by many teams. All details are illustrative. This lesson is the cautionary and practical end of the micro-frontend module: for the style itself see the lessons before it in the course (monolith versus micro-frontends, composition approaches and shared dependencies are planned lessons, so they are not linked yet). For the back-end version of the migration, Strangler Fig Migration has the deep treatment; here we add what is specific to a browser.

    The core idea

    1. Micro-frontends exist to give teams independent delivery. Cam Jackson's article on martinfowler.com (June 2019) defines them as "independently deliverable frontend applications" composed into a greater whole. If the pieces still have to ship together, you pay the costs of distribution and keep the coupling.
    2. Most anti-patterns are coupling that crept back in through shared code, shared state, shared release steps or shared design decisions.
    3. A migration is a sequence of reversible routing decisions, not a rewrite. Put a facade in front, move one slice, ramp traffic, and be able to go back.

    Anti-patterns

    The term distributed monolith is in common use but is not defined by a standard, and the primary sources opened for this lesson do not define it. In this lesson it means a system split into separately built pieces that still have to change and release together. The detection signals below are judgement, not measurements from a study.

    Anti-pattern What it looks like Why it hurts A check A fix
    Distributed monolith Fragments are npm packages built into one bundle by the shell, so any change needs a shell release The article calls this build-time "lockstep release process" painful enough to "recommend strongly against" Can one team ship to production without anyone else? Runtime composition, or a route-level split with its own deploy
    Shared-everything One global store, one shared component library that holds business rules, exact shared dependency versions Jackson: externalised libraries create "an implicit contract" that everyone uses the same versions; shared components should hold only UI logic Does a library change need three teams to approve? Share design tokens and UI primitives only; keep domain logic in the owning fragment
    Chatty fragments Fragments call each other's functions or watch each other's state Every call is a hidden dependency How many cross-fragment events exist, and who owns each? The article advises communicating "as little as possible"; use the URL and a few named events as the contract
    Nano-fragments A fragment per button or card Vercel's guide: component-level migration brings "extra orchestration, version-skew, and state-sharing challenges" Can a fragment be shipped and tested alone in a way the user would notice? Merge fragments up to a page or a flow
    Visible seams Two headers, two spinners, a different focus style per area Users see the team structure Walk a task end to end on a phone Choose slices by user journey; share navigation and tokens
    Eternal migration Two stacks live for years Both must be patched, hosted and understood Is there a date and an owner for the last legacy route? Set milestones and delete as you go (judgement)
    No shell owner The shell is "everyone's" Nobody owns auth, routing or error reporting Who is paged for a shell bug? Name a platform owner

    Figure 1. Is this split worth it?

    flowchart TD accTitle: Testing whether a micro-frontend split is justified accDescr: A decision tree. If teams are not blocked by shared releases, keep the monolith or a modular monolith. If they are blocked, split along user journeys, check that each piece can ship alone, and keep shared code to tokens and UI primitives. Q1{"Are teams blocked by
    shared releases?"} -->|"no"| M["Keep the monolith
    and make it modular"] Q1 -->|"yes"| Q2{"Can the split follow
    user journeys?"} Q2 -->|"no"| M Q2 -->|"yes"| Q3{"Can each piece ship
    without the others?"} Q3 -->|"no"| X["You are building a
    distributed monolith"] Q3 -->|"yes"| S["Split, and share only
    tokens and UI primitives"]

    How to read this: the middle question matters most. A split that cannot ship alone adds network and tooling cost and removes none of the coupling.

    Shared state and dependencies, the quiet coupling

    Two cases deserve their own paragraph because they are easy to miss.

    Shared state. Jackson's article says micro-frontends should avoid shared state and communicate by sending messages or events. A global variable on window, a shared Redux store, or a shared localStorage key is shared state, whatever it is called. When the stacks differ, as they do mid-migration, the problem gets worse. Decide who owns each piece of data. For data that matters beyond one screen, such as the cart, the owner is a backend API that both sides read, not a front-end variable.

    Shared dependencies. The same article notes that independently built bundles can duplicate common dependencies, and that sharing them externally brings "an implicit contract" about exact versions. Treat that as a trade-off to decide once and write down, not as a default. Module Federation and independent deployment have their own lessons later in this course.

    Migrating with a strangler facade

    The facade is the one component that decides, per request, which application serves it. In the browser world the seam is usually the URL. Vercel's migration guide recommends starting page by page ("easier and recommended first"), warns that slices must be chosen well to avoid user-experience seams, and describes routing by feature flag: users are sent to the old or new application depending on a flag, so traffic can be switched gradually after the new one is verified. Component-by-component migration is described as more challenging, with extra orchestration, version skew and state sharing, and the guide notes that performance can suffer when both stacks run in one application.

    Illustration 1, minimal. A facade as a function of path and user. The percentages are illustrative.

    const ROUTES = [
      { prefix: "/account", app: "new", rolloutPercent: 100 },
      { prefix: "/search", app: "new", rolloutPercent: 25 },
    ];
    
    function bucket(userId) {            // stable number 0..99 per user
      let h = 0;
      for (const ch of userId) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
      return h % 100;
    }
    
    export function chooseApp(path, userId) {
      const route = ROUTES.find((r) => path === r.prefix || path.startsWith(r.prefix + "/"));
      if (!route) return "legacy";
      return bucket(userId) < route.rolloutPercent ? route.app : "legacy";
    }

    A Node 26 run printed new for chooseApp("/account/orders", "alice") and legacy for chooseApp("/cart", "alice") (no route matches). For /search, bob (bucket 17) got new and alice (bucket 40) got legacy. Over 10,000 generated user ids, 25.13 percent got the new page. The same user always gets the same answer, so nobody flips between versions on every request. Setting rolloutPercent to 0 sends everyone back to the legacy app, which is the way back.

    Figure 2. A facade in front of two applications

    A routing facade sends each path to the legacy application or a new sliceShopper to Routing facade (any URL); Routing facade to Legacy app (default); Routing facade to Account slice (flag on); Routing facade to Search slice (25 percent); Account slice to Backend APIs; Legacy app to Backend APIs; Search slice to Backend APIsShopperShopperRouting facade: path and flag decideRouting facadepath and flagdecideLegacy app: most routesLegacy appmost routesAccount slice: new, 100 percentAccount slicenew, 100 percentSearch slice: new, rampingSearch slicenew, rampingBackend APIs: one source of dataBackend APIsone source ofdataany URLdefaultflag on25 percent

    How to read this: only the facade knows how traffic is split. Both stacks read the same backend, so state that matters lives in one place. The 25 percent is illustrative.

    The steps, in order

    1. Put the facade in place with no change in behaviour. Everything routes to legacy. This proves the facade and its rollback before any risk.
    2. Pick the first slice. Vercel's guide advises low-risk, high-value pages first, and grouping related pages in the same batch to preserve a coherent experience. A page that changes often is a good candidate, because the new setup pays back sooner (the guide also says so).
    3. Agree the thin shared layer. Navigation, design tokens, error reporting and a session that both stacks accept. See Sharing Authentication Across Micro-Frontends for the session, and Failure Isolation and Observability for reports that carry the same fragment and version fields in both stacks.
    4. Ramp with a flag. Start at a small share, verify functionally and with analytics, then raise it. Strangler Fig Migration covers ramping rules in depth.
    5. Decommission. When a route is at 100 percent and quiet, delete the legacy route and its code. This step is the one that gets skipped; set a date and an owner when you start (judgement, not a quote).

    Figure 3. One route moving from legacy to new

    stateDiagram-v2 accTitle: Lifecycle of one migrated route accDescr: A route starts on the legacy app, moves to a ramping state behind a flag, reaches the new app only, and ends with the legacy code deleted. At any point before deletion the flag can send traffic back. [*] --> Legacy Legacy --> Ramping: new slice built, flag at a small share Ramping --> NewOnly: flag at 100 percent, metrics healthy NewOnly --> Deleted: legacy route removed Ramping --> Legacy: flag to zero NewOnly --> Ramping: flag lowered Deleted --> [*]

    How to read this: reversibility lasts until the legacy code is deleted. That is why deletion is a decision with an owner, not an afterthought.

    Figure 4. What the facade does on each request

    sequenceDiagram accTitle: Facade deciding between legacy and new for one request accDescr: The shopper requests a search URL. The facade looks up the route and the user's bucket. If the bucket is below the rollout percent, the new slice serves the page, otherwise the legacy app does. Both read the same backend. participant U as Shopper participant F as Facade participant N as New slice participant L as Legacy app participant B as Backend APIs U->>F: GET /search F->>F: route found, bucket vs percent alt bucket below percent F->>N: serve N->>B: data else otherwise F->>L: serve L->>B: data end

    How to read this: the choice is made once per request from stable inputs, so a user's experience does not flicker.

    Strategy Good for Costs Risk
    Page by page Pages with clear boundaries Seams if slices cut a journey Low (the guide recommends it first)
    Component by component No page boundary exists Orchestration, version skew, shared state Higher
    Rewrite, then switch Rarely anything Long wait, details of old behaviour are missed Fowler has seen such plans "go down in flames most of the time"

    In practice: moving search and account out of a front-end monolith

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every name and number in it is an illustrative assumption.

    The situation

    The shop's single React application has one release train. A change to search waits for the checkout team's freeze. Teams want to ship search and account pages on their own schedules. Leadership wants no big-bang cutover.

    Starting architecture

    Figure 5. Before: one application, one release

    Starting architecture: one front-end application with one build and one release trainShopper to One front end (every URL); One front end to Backend APIsShopperShopperOne front end: one build, one releaseOne front endone build, onereleaseBackend APIsBackend APIsevery URL

    How to read this: every change goes through the same build and the same freeze.

    Options considered

    Option What it fixes What it costs
    Keep one app, make it modular Cleaner code Release train unchanged
    Rewrite everything Clean slate Long wait, high risk
    Strangle: facade plus search and account slices Independent deploys for those routes A facade, a thin shared layer, two stacks for a while

    The decision

    Judgement, labelled as such: strangle in this order: facade with no change, account (low risk), then search (high change rate), with the cart and checkout last. Share only navigation, tokens, a session and an error-reporting contract. Set a date to delete each legacy route when it starts.

    Rollout and verification

    1. Ship the facade with every route on legacy and confirm no behaviour change.
    2. Ramp account to 100 percent, watching error rates and task completion.
    3. Ramp search in steps, with the flag as the way back.
    4. Delete legacy code for each route after a quiet period you agree up front.

    The 2 a.m. failure

    Customers report an empty cart after adding items on the new search page. The new slice kept its own cart count in a store; the legacy header still read the count from a global variable the old code set. Items were added on the server, but the header and the legacy cart page showed stale values for users in the ramp.

    First three checks: (1) compare affected users' bucket and route; (2) confirm the server cart is correct; (3) look for any data that two stacks both write. Mitigation: set search's rolloutPercent to 0. Follow-up: make the backend cart API the only source, have both stacks read it, and add a test that adds an item through each stack.

    Architect's notes

    • Start from the reason. Write down which team is blocked by which release step. If you cannot, do not split.
    • Make the first slice boring. Prove the facade and rollback on a low-risk route.
    • Put one owner on the shell and the facade.
    • Keep the shared layer thin and versioned. Everything you share is a contract.
    • Schedule the deletion when you schedule the migration.
    • Measure release frequency per team, lead time for a change, and the share of traffic on each stack.

    Common mistakes

    • Splitting by technical layer (header, footer, list) instead of by user journey.
    • Sharing a store "temporarily". Temporary shared state tends to stay.
    • Leaving the migration half-done because the last routes are hard.
    • Migrating component by component first when a page-level split was possible.
    • No way back, because the flag was removed before the legacy route was safe to delete.
    • Judging success by the number of fragments, not by independent releases.

    Key takeaways

    • The goal is independent delivery; if pieces ship together you built a distributed monolith (a common informal term, not a standard).
    • Build-time package integration forces a lockstep release, which the original article recommends strongly against.
    • Share design tokens and UI primitives; keep domain logic and state with their owner.
    • Communicate between fragments as little as possible, through the URL and a few named events.
    • A strangler migration puts a routing facade in front and moves one slice at a time.
    • Page-by-page migration is easier and recommended first; component-by-component is harder.
    • Ramp with a stable per-user flag and keep a way back until you delete the old route.
    • Set a deletion date and an owner when you start.

    Practice exercises

    Exercise 1, Starter (30 min): Take a monolith you know. List the last five features and write down which teams had to coordinate. Is that a reason to split?

    Exercise 2, Intermediate (2 hours): Implement chooseApp with a test that proves the same user always gets the same app and that setting the percentage to 0 sends everyone to legacy.

    Exercise 3, Advanced (half day): Plan a migration for a shop with five areas (home, search, product, cart, account). Choose the order, name the shared layer, and write the exit criterion and deletion date for each route.

    In this course:

    Elsewhere on the site:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments