Drive the conversation in a fixed order: clarify requirements, sketch the client architecture, define the data model, agree on the API, then spend the remaining time on optimizations. This order is often called RADIO (Requirements, Architecture, Data model, Interface, Optimizations).
Requirements (about 5 minutes): what the user does, the non-functional goals (devices, network, scale, accessibility, languages) and what is out of scope. Write them down; asking is part of the score.
Architecture: a box diagram of the client: views, a store or cache layer, the network layer, the server or BFF. Say who owns which state.
Data model: the entities the client holds, server cache versus UI state, and where each lives (memory, URL, IndexedDB).
Interface: the API contract (endpoints, pagination, error shape, the real-time channel) and the props of the key components.
Optimizations: performance, network, accessibility, offline, security, monitoring. Go deep on the two or three that matter for this product instead of listing everything.
Say the trade-offs out loud: "polling is simpler, but at this update rate a WebSocket wins" scores more than the right choice made silently.
ReactSenior
Frontend System Design
Designing a feed, autocomplete or chat, rendering strategy, API and state design, caching, offline, real-time updates, micro-frontends, build tooling
Questions closed
0 / 21
- 0Review
- 10Asked often
Questions
Offset pagination asks for items by position ("skip 40, take 20"); cursor pagination asks for "the next 20 after this item" with an opaque token from the previous page. A feed that changes while the user scrolls needs cursors, because offsets repeat or skip items when posts are added or removed.
Offset (?offset=40&limit=20 or ?page=3): simple, lets you jump to page 17 and show a total count. Good for admin tables and search results with numbered pages.
Why offsets break a feed: if 5 posts arrive at the top while the user reads the first page, the request for offset 20 returns 5 posts they already saw. A deletion skips items the same way.
Cursor (?after=eyJpZCI6...&limit=20): the server encodes the sort key of the last item (for example createdAt plus id as a tie-breaker) and continues from it, so inserts above do not shift the window. It is also cheaper for the database than a large OFFSET.
Contract: the response carries items and nextCursor (null at the end); the client treats the cursor as opaque and never builds one itself.
New posts at the top come from a separate "since" request or a real-time event and wait behind a "5 new posts" banner.
Decide per route by two questions: how fresh the content must be, and whether it has to be indexed and paint fast on a cold visit. Rarely changing pages are SSG or ISR, personalized pages that matter for SEO are SSR with streaming, and app screens behind a login can stay CSR.
SSG: HTML built at deploy time and served from a CDN. The fastest TTFB and the cheapest to run. Docs, marketing pages, a blog. Stale until the next build.
ISR (revalidation): SSG plus regeneration in the background after a time window or an on-demand revalidatePath / revalidateTag call in Next.js. A product catalogue: fresh enough and still cached.
SSR: HTML rendered per request, so it can use cookies and personal data and is still indexable. Costs server CPU and TTFB; streaming with Suspense sends the shell first. With the App Router, Server Components keep non-interactive parts out of the JS bundle.
CSR: an empty shell plus a bundle that fetches data. Easy to host and fine for dashboards behind auth, but LCP waits for JS and data, and crawlers see little.
Mix them: one product usually uses all four. Partial Prerendering (a static shell with dynamic holes) blurs the line further.
Hydration is the hidden cost of SSR and SSG: the page looks ready before it responds to input, so keep client components small.
A performance budget is a set of hard limits that a change may not exceed, agreed before the work starts: user-facing metrics such as LCP and INP, plus the quantities that drive them, such as JavaScript size and the number of requests. It only works when CI fails the build on a breach.
User metrics (Core Web Vitals at the 75th percentile): LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1. INP replaced FID in 2024.
Resource budgets: the entry chunk (for example ≤ 100 KB gzip), total JS before first paint, image weight on the landing page, the number of third-party scripts and web fonts.
Lab enforcement: size-limit or a bundler size check on every PR, and Lighthouse CI against a throttled mobile profile with assertions on LCP and TBT.
Field monitoring: real-user data through the web-vitals library or CrUX, split by device class and country. Lab numbers from a fast laptop hide the median Android phone.
Ownership: a breach blocks the merge, and raising a limit is a reviewed decision with a reason. A dashboard that nothing gates on drifts within months.
Budget per route, not per app: a rich editor can afford more than the landing page.
From the user outward: the in-memory query cache, persisted storage (IndexedDB), the service worker cache, the browser HTTP cache, the CDN and the server's own caches. Each layer needs its own answer to "when is this stale", or users see old data with no way to refresh it.
Client
Query cache (TanStack Query, RTK Query, Apollo): deduplicates requests and shows cached data while revalidating in the background. Tune staleTime per query and invalidate on mutations.
IndexedDB: data that must survive a reload or work offline (drafts, the last feed page, an outbox). localStorage only for small synchronous settings.
Service worker: Cache Storage with a strategy per route: cache-first for hashed assets, network-first for HTML, stale-while-revalidate for API data that may be slightly old.
HTTP and CDN
Hashed static assets (app.3f9c1.js): Cache-Control: public, max-age=31536000, immutable. A new build changes the file name, so no invalidation is ever needed.
HTML and API responses: no-cache with an ETag, or a short max-age plus stale-while-revalidate. Never cache HTML for long: it names the assets of one specific build.
CDN: caches public responses near the user; personalized responses are Cache-Control: private or carry the right Vary, so one user's page never reaches another.
Invalidation
Prefer versioned URLs over purging. Invalidate data by event (a mutation, a socket message) rather than by timer alone, and give users pull-to-refresh as the escape hatch.
Put a sentinel element after the last item and watch it with an IntersectionObserver; when it comes within a margin of the viewport, fetch the next page by cursor. The real work is everything around it: virtualization, scroll restoration, errors and accessibility.
Trigger: IntersectionObserver with a rootMargin of a screen or two, not a scroll listener. It needs no throttling and does not run code on every scroll event.
No duplicate requests: ignore the trigger while a page is in flight (isFetchingNextPage) and stop when nextCursor is null.
Virtualization: after a few hundred rows the DOM itself is the bottleneck; render only the visible window (TanStack Virtual, react-window) and measure rows of different heights.
Scroll restoration: opening a post and going back must land on the same item. Keep the pages in a cache that outlives the route and restore by item id, not by pixel offset.
Layout shift: reserve space for images with width/height or aspect-ratio, or rows jump as they load.
Errors and the end: an inline "Retry" row instead of a silent stop, a visible end state, and a "Load more" button where users need to reach the footer.
Accessibility: role="feed" with aria-busy while loading and an article per item.
Build it on native horizontal scrolling with CSS scroll snap, load only the current slide and its neighbours, and treat accessibility and autoplay rules as requirements, not extras. The API takes slides as children and exposes the active index as optional controlled state.
Mechanics: overflow-x: auto with scroll-snap-type: x mandatory gives touch, trackpad and momentum for free; an IntersectionObserver on the slides reports the active index. Moving slides with JS transforms is only for effects scrolling cannot do.
Images: srcset and sizes for the right resolution, a fixed aspect-ratio against layout shift, loading="lazy" for distant slides, and fetchpriority="high" on the first one when it is the LCP element.
API: index with onIndexChange (controlled) or defaultIndex, plus loop and autoplay; slides as children so callers can put anything inside.
Accessibility: a labelled region with aria-roledescription="carousel", slides labelled "3 of 8", real button elements for previous and next, arrow keys, and hidden slides made inert.
Autoplay: off by default; when on, it pauses on hover and focus, has a visible pause control (WCAG 2.2.2) and respects prefers-reduced-motion.
Infinite loop: cloned edge slides are the main source of bugs, so offer looping only when the product needs it.
Debounce keystrokes, cancel stale requests so an old response never overwrites a newer one, cache results per query, and render the list as an accessible combobox. The suggestion endpoint itself must answer in tens of milliseconds, or no client trick hides the delay.
Requests: debounce around 200 to 300 ms and require a minimum length; cancel the previous request with AbortController. Without it, responses arriving out of order show results for "rea" while the input says "react".
Cache: a small LRU keyed by the normalized query, so backspacing to an earlier query is instant. Popular prefixes can be prefetched, or a broader cached result filtered locally.
Accessibility: the WAI-ARIA combobox pattern: role="combobox" with aria-expanded, aria-controls and aria-activedescendant on the input, role="listbox" and role="option" on the list; arrows move, Enter selects, Escape closes.
UX: highlight the matched part, show recent searches on focus, and keep loading, empty and error states distinct.
Rendering: when filtering a large local list is expensive, useDeferredValue keeps typing responsive.
Mobile: inputmode="search" and enterkeyhint="search", and a list that the soft keyboard does not cover.
Long polling holds an ordinary request open until the server has news; SSE is one long HTTP response streaming events from server to client; WebSocket upgrades the connection to a two-way message channel. SSE suits server-to-client streams, WebSocket frequent traffic both ways.
Long polling: works through any proxy and server, but each batch costs a full request with headers, and a cursor is needed so events between two requests are not lost.
SSE (EventSource): one text/event-stream response, text only, one direction. The browser reconnects by itself and sends Last-Event-ID, so the server can resume. Limits: no custom headers (authenticate with a cookie), and over HTTP/1.1 only six connections per domain across all tabs.
WebSocket: starts as HTTP, switches with 101 Switching Protocols, then carries text or binary messages both ways. Reconnects, heartbeats, resuming after a gap and auth are yours to build.
Infrastructure: SSE is plain HTTP, as long as no proxy buffers the response; WebSocket needs proxies that allow the upgrade and long idle timeouts. Both need a pub/sub layer (Redis, NATS) to reach users connected to another server instance.
Choosing: notifications, feeds, job progress, a streamed LLM answer: SSE. Chat, multiplayer, collaborative editing: WebSocket. Rare updates or a strict corporate network: polling or long polling.
Render the first page on the server for a fast LCP, then load more with cursor pagination into a normalized client cache, virtualize the list, and announce new posts with a banner instead of shifting the content. Likes and comments update optimistically.
Requirements and API
Scroll a personalized feed of mixed post types (text, images, video), like, comment, see new posts. Non-functional: fast first paint on mobile, smooth scrolling, tolerance of flaky networks.
GET /feed?after=<cursor>&limit=20 returns { items, nextCursor }, with authors and media embedded or in a side map, so the client makes no request per post.
Client data
Normalized cache: posts and users by id, so a like updates every place the post appears; the feed is a list of ids per page.
Optimistic mutations: a like changes the count at once and rolls back on error (useOptimistic, or the query library's onMutate).
New content: poll a "since" endpoint or listen on a socket, show "12 new posts" and insert them only on tap, so the reading position never jumps.
Rendering and performance
Stream the first page with SSR, hydrate, then fetch further pages on the client. Virtualize after a few hundred items and restore the position when the user comes back from a post.
Post types as a registry (type to component), with rare types such as polls or live video loaded lazily.
Media: srcset, a reserved aspect ratio, lazy loading below the fold, and video that autoplays muted only while visible.
Quality
role="feed" with an article per post and keyboard-reachable actions. Impressions are tracked with IntersectionObserver and sent in batches with navigator.sendBeacon.
Make the URL the single source of truth for filters, sort and page, render the first page on the server from it and cache that HTML per normalized URL, then fetch later changes on the client while the old results stay on screen. SEO decides which filter combinations deserve to be indexed.
URL as state
/catalog/phones?brand=apple,samsung&price=100-500&sort=price_asc&page=2 can be shared, survives the back button and can be rendered by the server. Normalize it (sorted keys and values, no defaults) so one filter set has one URL, one cache entry and one canonical.
Rendering and data
SSR or ISR for the first page: crawlers and the LCP image need real HTML. Personal parts (cart count, favourites, personal prices) load on the client, so the HTML stays cacheable for every anonymous visitor.
One request returns the items, the total and the facet counts ("Samsung (124)") from a search engine such as Elasticsearch or OpenSearch.
While new results load, keep the old grid dimmed (placeholderData: keepPreviousData or a transition) instead of flashing a skeleton.
Pagination and SEO
Crawlers do not scroll: numbered pages get real <a href="?page=2"> links and a self-referencing canonical each (Google no longer uses rel="next"). A "Show more" button can append results for people and still update ?page=.
Index the category and chosen landing combinations (category plus brand) with their own title and heading; give arbitrary multi-filter and sorted URLs noindex or a canonical to the parent, or crawlers spend their budget on near-duplicates.
Speed and UX
Images with srcset, a fixed aspect ratio, fetchpriority="high" on the one or two images above the fold and lazy loading for the rest.
On a phone, filters open in a sheet with an apply button ("Show 1,234 items") instead of a request per tap.
One WebSocket per client for events in both directions, messages sent optimistically with a client-generated id that the server echoes back, ordering by a server sequence number, and a resync from the last seen sequence after every reconnect. History is a bottom-anchored, virtualized list.
Transport
WebSocket for low-latency two-way traffic; SSE plus plain POST requests is a simpler option when sends are rare. History pages and attachments still go over HTTP.
Reconnect with exponential backoff and jitter; heartbeats detect a dead connection that never fired close.
After a reconnect, request everything after the last seq per conversation, so nothing sent meanwhile is lost.
Messages
Optimistic send: the message appears as "sending" with a clientId; the server's ack carries the real id and seq. The clientId is also the idempotency key, so a retried send never creates a duplicate.
Ordering: sort by the server seq, not the device clock, which can be wrong; pending messages stay at the bottom.
Failures: a failed message stays with a "Retry" action, and unsent ones live in an IndexedDB outbox so they survive a reload.
UI
Older history loads upward by cursor; the list stays anchored to the bottom and keeps its position when messages are prepended.
Unread counters and read receipts are separate light events; typing indicators are throttled to one event every few seconds.
Several tabs: share one connection through a SharedWorker or a BroadcastChannel leader, or accept a socket per tab.
Shape the API around what screens need, not around database tables. Plain REST is the default; GraphQL pays off when many clients need different slices of a rich data graph; a Backend for Frontend (BFF) aggregates several services into one screen-shaped response owned by the frontend team.
Options
REST: simple, cacheable by URL, easy to monitor. The risk is over-fetching and request waterfalls (/user, then /user/posts, then /posts/:id/comments).
GraphQL: the client asks for exactly the fields it needs in one round trip, the schema is typed, and a normalized cache (Apollo, Relay, urql) comes with it. Costs: no URL caching without persisted queries, query cost limits, and server-side N+1 that needs DataLoader.
BFF: a thin server per client type (web, mobile) that calls the services and returns screen-shaped JSON. With Next.js the BFF often lives in the same repo as route handlers and Server Components.
Contract details
Cursor pagination, one error shape (code, message, field errors) and idempotency keys on writes the client may retry.
Types generated from the schema (OpenAPI or GraphQL codegen), so a breaking change fails the frontend build instead of production.
Evolve by adding fields and deprecating old ones, never by breaking them: mobile clients stay on old versions for months.
Partial failure: one slow service degrades one widget, not the whole page.
Decouple the update rate from the render rate: keep incoming data in a store outside React, coalesce updates per key, and render at most once per frame (or a few times per second), only for the widgets whose data changed. Charts with many points draw on canvas, not SVG.
Data in
Transport: SSE for server-to-client streams (automatic reconnect, plain HTTP), WebSocket if the client also changes subscriptions often, polling for data that changes once a minute.
Subscribe narrowly: the client tells the server which widgets and time ranges are visible, and the server aggregates or downsamples instead of sending every raw tick.
Rendering
Collect messages in a buffer and flush once per requestAnimationFrame; nobody reads 500 changes a second anyway, and keeping only the latest value per key bounds memory.
An external store read through useSyncExternalStore with narrow selectors, so a tick for one symbol re-renders one row, not the whole grid.
Canvas or WebGL charts for thousands of points, virtualized tables, and no CSS transition on every value change.
Resilience
Slow down or pause while the tab is hidden (visibilitychange), load a fresh snapshot after a reconnect, and show the data age ("updated 3 s ago") so stale numbers are visible as stale.
Serve the app shell from a service worker cache, make IndexedDB the store the UI reads from, and queue writes in an outbox that syncs when the network returns. The hard part is conflict resolution, which has to be decided per entity together with the backend.
App shell
A service worker (Workbox) precaches the hashed build and serves HTML network-first with a cached fallback.
Plan updates: a new worker waits until every tab closes, so the app shows "New version available, reload" instead of mixing two builds.
Data
The UI reads from a local store (IndexedDB through Dexie, or a query cache persisted to IndexedDB) and the network only refreshes it, so a screen renders the same with or without a connection.
Ask for durable storage with navigator.storage.persist(); Safari may evict script-written data after 7 days without a visit.
Writes
Outbox: each mutation goes into an IndexedDB queue with a client-generated id, is applied optimistically and is sent in order once online. The id makes retries idempotent on the server.
Replay on the online event and on app start; Background Sync exists only in Chromium, so it cannot be the only trigger.
Conflicts
Last write wins per field is fine for settings. Documents need versions (If-Match with an ETag, a 409 shown to the user) or a CRDT (Yjs, Automerge) for collaborative editing.
Show the sync state honestly: "Saved on this device", "Syncing", "Conflict".
The core problem is merging concurrent edits so all copies converge: Operational Transformation on a central server, as in Google Docs, or a CRDT such as Yjs that merges in any order and works offline. Around it: a WebSocket sync server, an update log, presence and per-user undo.
Merging edits
Why naive sync fails: Alice inserts at position 0 while Bob inserts at position 3; applied as sent, Bob's edit lands in the wrong place on Alice's screen and the copies diverge. Last write wins on the whole document loses work.
OT: edits are operations such as insert(3, "s"); the server orders them and transforms each against the concurrent ones already applied, shifting positions. It needs a central server, and correct transforms for rich text are hard to write.
CRDT: every character gets a unique id (client id plus counter) and a position relative to its neighbours, so updates commute and copies converge without a coordinator, offline or peer to peer. The cost is metadata and tombstones for deleted text; Yjs, Automerge and Loro keep it small.
Architecture
The editor (ProseMirror or Tiptap, Lexical, CodeMirror) is bound to the shared document, applies local edits at once and sends small binary updates over a WebSocket.
A sync server per document (y-websocket, Hocuspocus, or a service such as Liveblocks) relays updates; all clients of one document reach the same instance or share a pub/sub channel. It checks permissions on every update, since a read-only client can still send one.
Storage appends updates to a log and compacts them into snapshots; IndexedDB on the client gives offline editing and instant reopening.
Beyond the text
Presence (cursors, selections, who is here) is ephemeral: it travels separately (Yjs awareness), is throttled and is never saved in the document.
Undo reverts only your own changes, not a colleague's typing.
Upload straight to object storage through presigned URLs, split the file into parts that upload in parallel and retry on their own, report progress per part with XMLHttpRequest, and remember finished parts so a dropped connection or a reload resumes instead of starting over.
Flow
POST /uploads starts a multipart upload in S3 (or GCS, R2) and returns an uploadId; the backend signs a short-lived URL per part, so file bytes never pass through your app servers.
The client cuts the File with file.slice(), which copies nothing, into parts of, say, 8 MiB (S3 needs at least 5 MiB per part except the last and allows 10,000 parts) and sends three or four at a time.
Every part's response carries an ETag; the client sends the list to POST /uploads/:id/complete and storage assembles the file. The bucket's CORS rules must expose ETag, or JavaScript cannot read it.
Progress, cancel, retry, resume
Progress: fetch reports no upload progress, so parts go through XMLHttpRequest and upload.onprogress; the total is the sum across parts.
Retry a failed part with backoff, never the whole file, and re-sign a URL that has expired.
Cancel: abort the requests in flight, tell the backend to abort the upload, and keep a bucket lifecycle rule that deletes unfinished uploads after a day.
Resume: keep the uploadId and a fingerprint of the file (name, size, lastModified) in IndexedDB. After a reload the browser cannot reopen the file on its own, so the user selects it again; the client matches the fingerprint, asks which parts exist and sends the rest. The tus protocol (tus-js-client, Uppy) packages the same idea.
Around it
An upload manager outside any route component (a store or a worker) keeps uploads alive while the user navigates, shows a queue with a state per file and warns with beforeunload while something is in flight.
Start from design tokens as the single source of colours, spacing and type, build accessible primitives on top, and ship them as a versioned package with documentation, visual tests and a clear contribution path. Adoption, not the component count, decides whether it succeeds.
Layers
Tokens: defined once (JSON in the W3C design tokens format, built with Style Dictionary) and compiled to CSS custom properties, so a theme or dark mode is a variable swap, not a rebuild.
Primitives: headless, accessible behaviour (focus, keyboard, ARIA) from Radix, React Aria or your own code, styled with tokens.
Components and patterns: Button, Dialog, Combobox, then page-level patterns; composition through children and slots instead of dozens of boolean props.
Distribution
A package in a monorepo with semver and Changesets; tree-shakeable ESM, sideEffects declared in package.json, and CSS that does not leak global selectors.
Breaking changes come with codemods and a deprecation period, so product teams upgrade on their own schedule.
Quality
Storybook as documentation and test bed, visual regression tests (Chromatic or Playwright screenshots) and axe accessibility checks in CI.
Lint rules that ban raw colours and sizes in product code, so using the system is the easy path.
Governance
A small core team, an RFC for each new component, and adoption tracked per team. A component used by one team stays in that team until a second team needs it.
Keep strings out of code behind stable keys in ICU MessageFormat, format numbers and dates with Intl, load only the active locale's messages per route, and use logical CSS properties so right-to-left works by switching dir. A translation pipeline, not developers, moves the text.
Messages
ICU messages handle plurals, gender and select: {count, plural, one {# file} other {# files}}. Russian needs one, few, many and other, so concatenating strings never works.
Keys describe meaning (cart.checkout.button), not the English text, and translators get context and a screenshot.
react-intl (FormatJS) or i18next with ICU; keys are extracted automatically and CI fails on missing or unused ones.
Formatting and layout
Intl.NumberFormat, Intl.DateTimeFormat, Intl.RelativeTimeFormat and Intl.PluralRules instead of hand-written formats, with the user's time zone, not the server's.
Right-to-left: <html dir="rtl">, margin-inline-start instead of margin-left, and mirrored directional icons. German text runs about 30% longer, so no fixed-width buttons.
Delivery
One JSON chunk per locale and route, loaded with a dynamic import(); the default locale never downloads the other forty.
The locale lives in the URL (/de/...) for SEO and shareable links, with hreflang alternates; a stored preference only chooses the redirect.
Pseudo-localization (accented, padded strings) in development catches hard-coded text and truncation before translators do.
Evaluate flags before the first render, on the server or at the edge, and ship the values with the HTML so the client hydrates with the same variant. Assignment is deterministic and sticky, exposure is logged when the variant is seen, and every flag has an owner and a removal date.
Why the naive version flickers
A client SDK that fetches flags after the bundle starts renders the default first and then switches: the user sees both variants, the layout shifts and the experiment data is polluted. Snippets that hide the page until flags arrive trade the flash for a worse LCP.
Evaluating early
SSR or edge: the server reads the user id or an anonymous id cookie and evaluates the rules from the SDK's local copy, in microseconds rather than a network call, then renders the variant and embeds the values for hydration.
Static pages on a CDN: edge middleware assigns the bucket, sets the cookie and rewrites to a prebuilt variant (/pricing to /pricing-b). The cache key must include the variant, or one variant is served to everyone.
A pure SPA: start from the values cached on the last visit, refresh in the background and apply changes on the next navigation; only a first visit waits, and only the experimental area shows a skeleton.
Assignment and measurement
Bucket by a hash of the experiment key and the user id: the same user gets the same variant on every server and device, and experiments stay independent of each other.
Log exposure when the variant renders, not at assignment, or users who never reached the checkout dilute the result.
Hygiene
Keep one typed registry of flags with an owner and an expiry date, and let CI report expired ones: an old flag is a dead branch nobody dares to delete.
Send the browser evaluated values, not the targeting rules, which can contain user lists and the names of unreleased features.
Micro-frontends pay off when many independent teams must ship parts of one product on their own release cycles; for a single team they add cost with no benefit. Split by business domain or route, integrate per route or at runtime with Module Federation, and share as little as possible.
When
Worth it: five or more teams, independent deploys as a hard requirement, or a legacy app replaced piece by piece (the strangler pattern).
Not worth it: one team, or a problem that a monorepo with enforced module boundaries already solves.
How to split and integrate
By route (the simplest): each app owns its URLs and a thin shell or the edge proxy routes between them. Moving between apps is a full page load unless a shell such as single-spa orchestrates it.
By widget at runtime: Module Federation (webpack 5, Rspack or @module-federation/vite) loads a component that a remote app exposes from its own deployment.
Build-time packages: versioned npm packages are simpler, but every change needs a release of the host, so deploys are no longer independent.
Costs to plan for
Shared dependencies: React must be a singleton (shared: { react: { singleton: true } }), or hooks break with two copies; version drift between teams is the ongoing tax.
Consistency: one design system and token set, or the product looks stitched together.
Communication: the URL, custom DOM events or a tiny event bus; no shared global store.
Performance and failures: duplicated libraries and remote entry waterfalls need a budget per app, and an error boundary around each remote keeps another team's bad deploy from blanking the page.