Frontend Lead Interview: Frontend System Design With Micro-Frontends, Monorepos and Trade-offs
Bài này có bản tiếng Việt. Đọc bản tiếng Việt →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
A Frontend Lead interview differs from a senior one in one way. They stop asking “how does this hook run”. The question becomes: “The company has 6 teams working on one web app, the build takes 25 minutes, and releases have to queue up. What do you do?”
There’s no single right answer. The interviewer is listening for what you ask back first, which solutions you turn down, and whether you can name the price of each choice.
Here’s how I work through this round, pieced together from sitting on both sides of the interview table. The example is a composite scenario, not tied to any company.
1. Don’t Draw the Architecture Yet, Ask About the Organization First
At Lead level, the story usually starts with people and process, and only later shows up as a code problem. I always open with a few questions:
- Who is in pain? Slow builds, deploys that need coordination, or two teams constantly editing the same file and hitting conflicts?
- How many teams, and do they own clear domains? Teams organized by feature (checkout, catalog, account) are very different from teams organized by layer (UI team, API team).
- Stack constraints: is everyone on React, or is there legacy Angular/Vue? Is there SEO/SSR?
- The release frequency they want, and how much risk they’ll accept with independent deploys?
- Current numbers: CI time, bundle size, flaky test rate, time from merge to production (lead time).
If the interviewer doesn’t give numbers, I state my assumptions: “I’ll assume 6 teams, ~40 frontend engineers, one large Next.js codebase, CI around 25 minutes.” Say the assumptions out loud instead of silently inferring them.
2. Micro-Frontends: Answer “When NOT to Use Them” First
Many candidates hear “multiple teams” and jump straight to micro-frontends (MFE). I do the opposite: I say when not to use them.
MFE exists to solve independent deployment and independent ownership between teams. Slow builds, messy code, missing conventions: it doesn’t really fix those. The price you pay:
| Cost | What it looks like in practice |
|---|---|
| Duplicated bundles | Each remote ships its own libraries if shared is misconfigured |
| Inconsistent experience | Spacing, fonts and loading behavior differ between regions |
| Runtime complexity | Version skew between host and remote, bugs that only show up in production |
| Hard to debug and test | E2E has to run across combinations of multiple versions |
| Platform cost | You need a platform group to maintain the shell, routing and shared libs |
So I don’t propose MFE when:
- There are fewer than about 3-4 teams, or fewer than a few dozen engineers. Coordination cost is still lower than the cost of MFE infrastructure.
- The teams don’t have clear domain boundaries yet. Cut the boundaries wrong and every feature touches 3 remotes, and you’ve built a distributed monolith.
- The real problem is slow builds or slow CI. That belongs to monorepo, caching and test selection (section 3).
- You need a seamless UX with lots of shared state: complex editors, dashboards with intertwined state.
- There’s no design system yet. MFE without a design system makes the product look stitched together from several websites.
My usual rule of thumb: if you haven’t tried a modular monolith with boundaries enforced by tooling (lint rules, CODEOWNERS, dependency boundaries), you shouldn’t jump to MFE yet.
Three Approaches, and When to Pick Each
Route-based (split by URL)
/ -> app-shell
/checkout -> app-checkout (deployed separately, full page load or rewrite at the edge)
Module Federation (runtime composition)
Shell (host) --import()--> remoteCart/CartWidget (JS loaded at runtime)
iframe
Shell --<iframe src="https://partner.x/widget">
| Criterion | Route-based | Module Federation | iframe |
|---|---|---|---|
| Isolation | High (separate app) | Low (same JS context) | Very high (browser sandbox) |
| Compose multiple remotes on one page | No | Yes | Yes |
| Sharing state/UI | Hard (via URL, cookie, storage) | Easier but prone to coupling | Very hard (postMessage) |
| Performance | Good, each app can SSR | Risk of duplicated bundles, waterfalls | Heavy, each iframe is a separate document |
| SEO/SSR | Easy | Complicated | iframe content is barely counted for the parent page |
| Best for | Most cases that truly need splitting | Many teams on one page, widget composition | Third-party content, legacy, security sandboxing |
My position: route-based is the sensible default to try first, because it keeps the deploy boundary without complex runtime integration. Module Federation is only worth it when you really need to compose pieces from different teams on the same screen. iframe is for cases that need absolute isolation: third-party content, or a legacy app that isn’t worth rewriting.
The decision tree I sketch when someone asks “so which one do you pick”:
flowchart TD
A{"Have you tried a modular monolith with boundaries enforced by tooling?"}
A -->|"not yet"| B["Stay on the modular monolith: lint rules, CODEOWNERS, dependency boundaries"]
A -->|"yes"| C{"What is the real problem?"}
C -->|"slow build, slow CI, duplicated code"| D["Monorepo + build cache + affected tests"]
C -->|"need independent deploy and independent ownership"| E{"Need to compose pieces from several teams on the same screen?"}
E -->|"no"| F["Route-based: separate apps by URL"]
E -->|"yes"| G["Module Federation"]
C -->|"need absolute isolation"| H["iframe: third-party content, legacy"]
If You Pick Module Federation, Where It Breaks
// webpack.config.js of a remote (Module Federation, webpack 5)
new ModuleFederationPlugin({
name: "cart",
filename: "remoteEntry.js",
exposes: { "./CartWidget": "./src/CartWidget" },
shared: {
react: { singleton: true, requiredVersion: "^18.2.0" },
"react-dom": { singleton: true, requiredVersion: "^18.2.0" },
},
});
Three things I always bring up when pushed on details:
singletonfor React and for libraries that hold context (router, query client). Two copies of React on the same page means hooks throw errors right away, or context can’t be shared.- Version skew. Host and remote deploy independently, so you need a compatibility policy. The contract of every exposed component has to be versioned and tested (contract tests), and the host needs a fallback when a remote fails to load: an error boundary with a replacement UI.
- Who owns the shared dependencies? If nobody does, upgrading React from 18 to 19 becomes a cross-team project that drags on for quarters.
The bar I set for myself: MFE is an organizational decision, implemented with technology. Only do it when the coordination cost between teams has become bigger than the cost of running the platform.
3. Monorepo and Build Cache
If the problem is slow builds, slow releases and duplicated code, a monorepo plus caching is usually far cheaper than MFE.
repo/
├── apps/
│ ├── web/ # Next.js
│ └── admin/
├── packages/
│ ├── ui/ # design system
│ ├── config/ # shared eslint, tsconfig
│ ├── api-client/ # client generated from OpenAPI
│ └── utils/
├── turbo.json # or nx.json
└── pnpm-workspace.yaml
Build Cache: The Part Worth the Most
Turborepo and Nx start from the same idea: hash a task’s inputs (source, dependencies, declared environment variables, config). If a hash has already run, take the output from the cache and skip the work. There are two kinds of cache, local and remote. The remote one is shared by CI and dev machines.
// turbo.json (Turborepo 2.x uses the "tasks" key)
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": { "dependsOn": ["build"], "outputs": ["coverage/**"] },
"lint": {}
}
}
Notes to have ready when you’re pushed on details:
dependsOn: ["^build"]means build the packages it depends on first.- The cache is only correct if inputs are declared correctly. A task that reads an environment variable without declaring it gets you a stale cache, and that kind of bug is very hard to trace. I see this as the most common mistake when a team first adopts it.
- Affected/—filter: on CI, only run tasks for packages affected by the change (Nx has
nx affected, Turborepo has--filter=...[origin/main]). Combined with a remote cache, a PR that touches one package usually costs a small fraction of a full build.
Turborepo or Nx?
I don’t pick by “whichever is trending”. Just a practical comparison:
- Turborepo: lightweight, little config, fits a pure JS/TS repo that needs task orchestration and fast caching.
- Nx: more features: project graph, generators, enforced module boundaries, multi-language support. In exchange you get a steeper learning curve and a more opinionated tool.
The safe answer I usually give: if the team mostly needs caching and a task graph, start with the lighter tool. If you need to enforce boundaries between modules and want generators, Nx is worth considering. And the tool is only half of it; the other half is discipline: CODEOWNERS, one-way dependency rules, someone who owns CI.
The Cost of a Monorepo, Said Plainly
- CI has to be redesigned so it doesn’t build everything.
- Access control: who gets to merge into
packages/ui? You need CODEOWNERS and mandatory review. - Upgrading dependencies repo-wide is a centralized decision, which is good (consistency) but needs a process.
- Releases: for any package published externally, use Changesets or an equivalent to manage versions.
4. Design System Governance, and Where It Gets Stuck
A question interviewers love: “How do you get 6 teams to share one UI kit without everything bottlenecking on the design system team?” This is a governance question, not a Storybook question.
I usually describe it in three layers:
Tokens (color, spacing, typography) <- rarely change, strict review
└─ Primitives (Button, Input, Modal) <- stable API, strict semver
└─ Patterns / Domain components <- owned by product teams, not required to enter the core
Principles I hold to:
- A small, stable core with clear owners. Not every component deserves to be in the design system. A rule like “it must be used by at least 2-3 teams before it’s promoted” keeps it from bloating.
- A clear path to contribute. Product teams propose and contribute, the DS team reviews. If only the DS team can change things, you get a bottleneck; if anyone can, you get chaos.
- Versioning and deprecation: semver, changelog, codemods for breaking changes, and deprecation deadlines announced in advance.
- Automated testing: visual regression (snapshots from Storybook), accessibility tests for primitives, type tests for props.
- Measure adoption: the share of UI using DS components versus hand-rolled ones, and the number of old versions still in circulation. Add a roadmap and a response SLA, because a design system is also a product. The users just happen to sit in the same company.
5. State and Data Fetching: Classify First, Pick Tools Second
This is where many candidates answer vaguely with “use Redux”. I classify the state first and choose the tool after:
| State type | Example | Fitting tool |
|---|---|---|
| Server state | Product list, profile | React Query / SWR (cache, dedupe, revalidate) |
| URL state | Filters, page, tab | Router / search params |
| Local client UI state | Open modal, input | useState, useReducer |
| Truly global client state | Theme, session, unsynced cart | Context or a small store (Zustand…) |
| Form state | Values, validation errors | A form library |
Most “global state” in a real app is server state copied into Redux. Separate the types correctly and the store gets lighter on its own.
React Query: Concepts to State Precisely
const { data } = useQuery({
queryKey: ["products", { category, page }],
queryFn: () => api.getProducts({ category, page }),
staleTime: 60_000, // for 60s the data counts as "fresh", no refetch
gcTime: 5 * 60_000, // (v5; v4 calls it cacheTime) how long to keep the cache once there are no observers
});
staleTimeis notgcTime.staleTimedecides when to refetch,gcTimedecides when to evict from memory. Mixing these two up is a common mistake.- The query key is the cache key. Designing keys hierarchically (
["products", filters]) lets you invalidate precisely. - Mutations and cache updates: after a mutation, either
invalidateQueriesto refetch, or an optimistic update with rollback on error. - Prefetch and SSR hydration: prefetch on the server, then hydrate on the client to avoid a spinner at the top of the page.
Choose the cache strategy by how fresh the data needs to be. This is a frequent follow-up:
Nearly static data (catalog) -> long staleTime, can cache at CDN/edge
Per-user data (profile) -> short staleTime, cache separately per user
Realtime data (stock, price) -> staleTime ~0, polling or WebSocket/SSE
The trap: when you cache at several layers (CDN, server, client), you need one consistent invalidation strategy, or users see stale data that the team can’t reproduce. With sensitive per-user data, be careful that the CDN doesn’t cache a personalized response by mistake.
6. Migration: Go Strangler Fig, Don’t Big-Bang Rewrite
A common one: “Old AngularJS/jQuery/CRA codebase. How do you move to a new architecture?” I don’t rewrite from scratch. Big-bang rewrites usually blow past the estimate, freeze new features, and pile all the risk onto go-live day.
Strangler fig: build the new system alongside, move over one slice (a route or a feature) at a time, and the old system shrinks away.
┌────────────── Edge / Reverse proxy ─────────────┐
Request -> │ /checkout/* ──> New app │
│ /* ──> Old app (legacy) │
└──────────────────────────────────────────────────┘
Each sprint: move one more route to the new app, measure, then switch off the old part
The steps I propose:
- Pick a first slice that’s small but has real value, enough to prove the pipeline (build, deploy, observability) works.
- Route at the edge or proxy layer so traffic can be shifted by path, rolled out by percentage (feature flag) and rolled back quickly.
- Share session and auth between the two systems so users don’t have to log in again.
- Share design tokens so the UI doesn’t “break” between old and new.
- Set completion criteria for each slice (feature parity, no increase in errors, performance no worse), and set a date to switch off the old part. Without a shutdown date, you maintain two systems forever.
One thing worth admitting: the transition period costs more, because you’re running two stacks in parallel. That cost is deliberate. You’re paying it for lower risk.
7. Frontend Observability: Where the System Is Blindest
The frontend is usually the blindest part. The backend has logs and traces, and the frontend only finds out when users complain. I split it into four layers:
- Error tracking: catch runtime errors, unhandled rejections, chunk load failures. Attach the release version and source maps so stack traces are readable, plus context (route, anonymized user id, active feature flags).
- Real User Monitoring (RUM) for performance: measure Core Web Vitals (LCP, INP, CLS) from real users, broken down by device, network and page. Lab data (Lighthouse) is only a reference; real data decides.
- Cross-layer tracing: send a trace header (for example W3C
traceparent) from the frontend to the API to connect one user action with its backend requests. - Business/funnel metrics: checkout completion rate, error rate per step. A deploy might not increase JS errors and still drop conversion.
At Lead level, the part worth adding is wiring observability into the release process:
Deploy -> canary 5% -> compare error rate & INP against baseline
-> if over threshold: automatic rollback / turn off the feature flag
-> if healthy: ramp up 25% -> 100%
Alongside that come SLOs for the frontend (for example “95% of sessions have no serious JS errors”, “p75 INP under the good threshold”) and an error budget, so you know when to stop new features and fix quality. And when you store user data, privacy has to come up: no PII in logs, masking in session replay.
8. The Trade-off Framework I Use to Close an Answer
At the end of each answer, I run through a short framework to bring out the trade-off thinking:
1. What is the real problem? (measured by which number)
2. Feasible options (at least 2, including "do nothing big")
3. For each option: benefit / cost / risk / reversibility
4. Decision + the conditions that would change my mind
5. How to measure whether the decision was right
Applied to “6 teams, 25-minute build, releases in a queue”:
- Option A: keep the monolith, add monorepo caching and affected tests. Cheap, easy to reverse, fixes the slow build but not the release queue.
- Option B: modular monolith, enforced boundaries, release via feature flags. Reduces release coordination without new infrastructure.
- Option C: route-based split for loosely related domains (for example checkout), keep the rest.
- Option D: full Module Federation. The most flexible but the most expensive.
I’d propose A, then B first, and measure again after a quarter. Only if release coordination cost is still the bottleneck do we move to C. I state the change-my-mind condition too: if teams still often wait on each other to release even with feature flags in place, I’d split further.
9. Trap Questions and Red Flags
Trap questions you’ll commonly get:
- “Your team wants micro-frontends because they heard Big Tech all use them. What do you say?” I don’t push back head-on. I ask what concrete problem they need to solve, then suggest measuring first. The interviewer wants to see you persuade with data, not with authority.
- “How do you make sure every team uses the design system correctly?” The bad answer is “enforce it with lint”. The good answer combines lint with making the right path the easiest path (docs, codemods, fast support).
- “Which technology do you pick?” If you name a tool right away without asking about context, that’s a red flag.
Red flags on the candidate side:
- Proposing a complex solution immediately, without stating its cost.
- Unable to say when your own solution would be wrong.
- No measure for success.
- Talking only about technology, not about people and process.
Red flags on the company side (worth noticing when you’re the one being interviewed):
- They want to “move to micro-frontends” but nobody can describe the concrete problem.
- Nobody owns CI/CD or the design system, but they expect the Lead to “handle it all”.
- No basic frontend observability, yet they ask about performance.
What the Lead Round Is Scoring
The frontend system design round at Lead level barely scores tool knowledge. It scores judgment: can you tell an organizational problem from a technical one, do you pick the cheapest solution that’s enough, and can you say how you’d find out you were wrong.
Three questions I ask myself before leaving the room: what number measures this problem, what’s the cheaper option, and what would make me change my mind.
Keep reading
Need a developer for a contract or freelance project?
I take on fullstack projects — Go backends, React/Next.js frontends, or both ends. Send a few lines about the scope and timeline and I'll reply within 48 hours.