RLPv2 / Technical design
Overview Agent handoff

Retail Launchpad v2 · High-level design

How RLPv2 works and why

A technical walkthrough of the RLPv2 platform (rwe-core): one host app that serves a small, read-only federation surface to partner modules, embeds full apps through iframes, and keeps every contract it made with RLPv1. Each major decision comes with its reasoning, followed by an honest look at constraints and open questions.

Audience: Principal / Staff engineers ~20 min read Snapshot: Oct 28, 2026 Prod: 638 stores · 2,763 associates / week

01

System context

One host composes two kinds of content: iframe apps (full screen, from the sidebar) and federated widgets (Home cards). The host owns identity, store context, language, navigation and telemetry. Partner code never talks to those systems directly; it gets what it needs from the host. Hover any box for its relationship to the host.

Clients
Store computerBrowser, web sign-in (RAPv3) with certificate guidance
iOS appWKWebView + nikeNative bridge supplies athlete token
Platform services
RAPv3 OAuth@nike/typescript-web-login
Store config (SCS)Per-store app disable keys
storeviewsStore identity, hours, multi-store
BodegaTranslations, with a bundled fallback catalog
New RelicOne Browser Agent, initialized first; alerts as Terraform
Host (rwe-core, single package)
RLPv2 hostContainer · Sidebar · Home · Auth · i18n · Store context · Telemetry
serves retailUXShell/coreretailUXShell/observability
postMessage (allowlisted origins)
Iframe apps (5)SIMWeb · Nike Cash · SOR · Electronic Journal · Store Hours. Own domains (EJ has China endpoints), own deploys.
Module Federation (runtime import)
Federated widgets (3 live)SOR KPI (persistent) · SOR Variance · Nike Cash SOD/EOD. Partner code that imports retailUXShell/core.
Partner backends
SOR backendGraphQL (vrTillSettlements), polled by Variance widget
Nike Cash APICash mode, polled by Cash widget
Store Locations APIStore hours (cached 24 h in widget)
Hosting
S3 + CloudFront/{app} + /{app}/assets/remoteEntry.js; /shell/* → 301 /host/*
JenkinsRoot Jenkinsfile: develop → test · main → prod
HostIframe appsFederated widgets

02

Runtime composition

The host serves the shell surface to partner modules but does not consume it through federation. Its own code imports the shell by source path. Host and partners still share exactly one instance of the auth context, translations context and New Relic agent, because they end up loading the same JavaScript file.

1

import './shell/observability/init'

The first line of src/main.tsx. It starts the New Relic Browser Agent before anything else, so it hooks browser globals early. ES import order is preserved, so this stays first.

2

Render the provider tree

Shell (auth + translations) → WidgetHostProvider → BrowserRouter → UserPermissionsProvider → App. Then track('AppReady'). No async hop.

3

Partners load retailUXShell/core

A widget remote fetches /host/assets/remoteEntry.js and imports the exposed core chunk. That chunk imports the same context chunk the host entry already loaded, so the browser returns the cached module.

4

Guardrails

An ESLint no-restricted-imports rule fails the build if anything under src/ imports retailUXShell/*. scripts/check-federation-singleton.sh asserts on dist/ that AuthContext and TranslationsContext are each created in exactly one chunk, and that both the host entry and the core expose chunk import it.

How host and partners share one instance (click a path)

Host entryindex.html → index-*.jsImports @/shell/… by source path
Exposed moduleremoteEntry.js → __federation_expose_Core-*.jsBuilt from remoteSafeSelectors.ts
static import
static import
One shared chunkAuthContext TranslationsContextRollup emits a module reachable from both entry points exactly once. Browsers cache ES modules by URL.
Pick a path to see which files load.

03

Federated contract

The host publishes under the federation name host. Partners alias it as retailUXShell. Only paths with a real downstream consumer are exposed, and nothing exposed lets a partner drive the session.

PathSourceExports
retailUXShell/coresrc/shell/remoteSafeSelectors.tsShell, useAuth (read-only), useWidgetHost, hostNavigate, SuggestionWidget, trackWidgetEvent, trackWidgetCtaClick
retailUXShell/observabilitysrc/shell/observability/index.tstrack, notice, timing, withContext, setUserContext. Kept for now; no downstream consumer yet.
// retailUXShell/core: useAuth() for partner modules
interface RemoteSafeAuth {
  accessToken: string | null;
  isLoggedIn: boolean;
  userInfo: UserInfo | null;   // athlete_id, operator_id, nike_store, roles, locale, timezone…
}
// No login, logout, sessionExpiresIn, providers or token refresh.
// The host's internal useAuth (src/shell/auth/useAuth.tsx) keeps those.

Who uses it (audited)

ConsumerIntegrationImports
rcf-reporting-ui
KPI + Variance widgets
FederationShell, SuggestionWidget, hostNavigate, trackWidgetEvent, trackWidgetCtaClick, useWidgetHost, useAuth (token + user info)
rcf-cash-frontend
Cash mode widget
FederationShell, SuggestionWidget, useWidgetHost, useAuth (token). Also calls getStoreId?.(), which the host has never provided.
sim-web-ui and other sidebar appsIframe + postMessageNothing

Contract rules

  • A symbol is added to remoteSafeSelectors.ts only when a real downstream module needs it, as an explicit named export (never export *), and only as read-only session state. Changes require approval because this is the public contract.
  • Host-only helpers (initHost, getWidgetHost, trackWidgetDismiss) stay out of the surface.
  • Partner modules wrap their widgets in <Shell> to receive the host contexts. They are built against React 16 or 18, with react and react-dom shared. The contract for mismatched React versions is an open follow-up.

04

Registries & gating

Everything the host can render is declared in files that ship with the host. That keeps it simple and reviewable. The tradeoff is that adding or removing an entry needs a host deploy (see section 12).

WhatWhereKey fields
Iframe app metadatasrc/retail-launch-pad/apps.jsonslug, i18n key, scsKey, minimumRoleLevel (20–40 today), banner, per-env URLs (also the origin allowlist)
Iframe app constantssrc/appConfig.tsPer-env URLs incl. China EJ endpoints, enabled flags, disable keys, disabledInNative
Federated routed appsconfig/app-registry.tsEmpty today. The shape for future federated routed apps; keyed by name.
Widgetsconfig/widget-registry.tsKeyed by moduleName (deduped against apps); type, per-env remoteEntry, enabledInProduction, disabledInNative

Sidebar app visibility gates

Enabledconfig flag
Not disabled for storestore config key (prod)
Role ≥ minimumathlete level
Native alloweddisabledInNative
Shownin sidebar

Source: src/Components/SidebarNavigaton/index.tsx (store flags via UserPermissionsProvider). Widgets have only the environment and native gates. There is no per-store or role targeting and no kill switch.

05

Iframe integration contract

Unchanged by the restructure. Transport is window.postMessage, handled in src/retail-launch-pad/utilities/windowMessageHandling/index.js (message names in constants.ts beside it). The host replies only to origins on the allowlist (every URL in apps.json). Anything else is dropped and recorded as IframeOriginRejected. The message names are identical to RLPv1, which is why v1 apps run in v2 unmodified.

MessageDirHost behavior
requestAccessTokenapp → hostReplies { name: 'accessToken', token: { accessToken, refreshToken }, authType: 'retail', reason? }; on failure token: null, error. Telemetry: IframeTokenSent (with durationMs) / IframeTokenSendError
sendStoreapp → host{ storeNumber, country }
getLanguageapp → hostlangLocal plus a legacy langLocale copy for v1 apps: { i18nLanguageCode, ncssLanguageCode }
getUrlapp → host{ url: location.href }
setTitleapp → hostSets document.title
addressbarapp → hosthistory.replaceState(…, newurl), keeping deep links and the back button correct
closeapp → hostNavigate to host root (ignored during page unload)
logoutapp → hostHost logout
token pushhost → appFresh token pushed on refresh (IframeTokenPushed, 8,100 last week)

Iframe element

sandbox="allow-same-origin allow-scripts allow-top-navigation allow-modals allow-downloads" and, since PR #131, allow="local-network-access". Chrome's Local Network Access denies cross-origin iframes access to private-network APIs unless the embedder delegates it. SIMWeb needs this to reach sim-bff.

Iframe router: navigation that crosses the boundary

src/hooks/useIframeRouter.tsx: the app signals readiness and publishes its routes. The host renders them as sidebar sub-items (SOR → KPI, Variance Report, Till Totals…) and navigates the iframe on click. Requests are queued until the app is ready. A 10 s ready SLA emits IframeReadyTimeout (1 occurrence in 34,002 loads last week).

Telemetry hygiene: every inbound message emits IframeMessageReceived with messageType and appOrigin only. Extra attributes come from a null-prototype safelist (route ≤ 200 chars, title ≤ 100 chars), so a hostile __proto__ message can't crash the handler.

06

Identity & session

The host is the only owner of the session. Partner widgets read it through the read-only useAuth(). Iframe apps receive tokens over the allowlisted channel. Neither can log in, log out or schedule refreshes.

ContextMechanismLast 7 days
WebRAPv3 OAuth via @nike/typescript-web-login. Before every SSO redirect, a certificate guidance step tells the associate which issuer to pick (Nike ID CA 01 / 02) and how to recover from the wrong choice (AuthCertGuidanceShown).19,352 completed · 175 failed
iOSwindow.webkit.messageHandlers.nikeNative.postMessage({ action: 'getAthleteToken' | 'logout' }); responses via window.nikeNative.onAthleteToken / onUserDidChange. iOS side: NikeNativeBridgeHandler.swift34,959 tokens · 19 refresh failures · 7 login failures
Partner widgetsretailUXShell/core → useAuth(): { accessToken, isLoggedIn, userInfo }n/a
Iframe appsNever see credentials. They request tokens over postMessage, and the host pushes renewals.36,322 handoffs · 4 errors · p95 ~3 ms

Translations come from Bodega. Keys Bodega hasn't published yet (currently the certificate guidance copy) resolve from a bundled catalog, src/shell/contexts/localTranslations.ts, then from the English fallback at the call site.

07

Widget platform

Widgets are federated components rendered inside the host's React tree. The host is deliberately generic. It loads, tracks and renders widgets, but the widget decides when it should be visible.

// window.rweWidgetHost, reached by partners via useWidgetHost() (src/widgets/widgetTypes.ts)
registerWidget(entry: WidgetRegistration): void
notifyReady(id: string, payload?: unknown): void                     // "show me"
notifyHide(id: string, options?: { userDismissed?: boolean }): void  // "hide me"
getLastHiddenByUser(id: string): string | null                       // ISO timestamp
trackEvent(eventName: string, attrs?: Record<string, string | number | boolean>): void

Lifecycle (click a transition)

idleregistered, component lazy-loaded, effects running
readyrendered on Home
// Pick a transition to see what the host does.

What the host provides to widgets

  • SuggestionWidget: shared container, close button and dismiss interaction.
  • hostNavigate(route): deep-link from a widget into any app route (e.g. Variance → SOR Variance Report with date, till and athlete pre-selected).
  • trackWidgetEvent / trackWidgetCtaClick: widget-scoped telemetry without importing the full SDK.
  • WidgetErrorBoundary around every widget, plus WidgetLoadError / WidgetRenderError telemetry.
  • Dismissal memory: localStorage.widgetsLastHiddenByUser[operatorId][widgetId], written only for user dismissals and only when userInfo.operator_id is present.
No idle watchdog, by design. Polling widgets legitimately stay idle until a poll finds something to show, so "still idle after N seconds" is noise, not a failure. The host alerts on widget load and render error rates instead. The tradeoff: a widget that never signals at all isn't detected automatically.

Live widgets

WidgetShows whenFetchingDismissalLoaded → shown
Oct 25–27
SOR Variance
rcf-reporting-ui
Any till |over/short| ≥ $25: today first, else yesterday; up to 2 lines + "n more"Every 60 min in practice (code: 30 min shown / 60 min hidden; hidden in 91% of loads). ~1.6 requests per check (today, then yesterday).Host API: resurfaces only for variances newer than the dismissal13,457 → 1,162 (9%)
Nike Cash SOD/EOD
rcf-cash-frontend
SOD: mode CLOSED, after midnight, before open. EOD: mode OPEN, within 2 h of close (incl. special hours)Every 60 s measured in prod (code: 15 s shown / 60 s hidden)Own keys cashModeWidget_dismissed_{athlete}_{store}_{mode}; parses the JWT itself17,548 → 5,249 (30%)
SOR KPI
rcf-reporting-ui
PersistentDoesn't poll. ~2.4 requests each time Home mounts.n/a32,459 → 18,000

Both suggestion widgets are disabledInNative, so today they appear only on store computers. SOR KPI appears on web and iOS.

Signal from the field: Nike Cash needed mode-scoped dismissals and the store ID, so it built them itself: it parses the JWT and calls getStoreId?.(), which the host has never provided (the store is already on userInfo.nike_store). Because the surface is curated, scoped dismissals and an explicit store selector are the first candidates to add to remoteSafeSelectors.

08

Observability & alerting

  • One SDK and one New Relic Browser Agent. The host imports it from @/shell/observability: track (PageAction), notice (JS error), timing (durationMs), withContext, setUserContext. Widgets emit through trackWidgetEvent / trackWidgetCtaClick.
  • 38 named events under a <Domain><Action><Outcome?> taxonomy enforced by a branded TS type: App, Auth (10), Iframe (10), Nav, Store, Widget, Federated.
  • PII: user.givenName / user.familyName are stripped at runtime from every emitted event. Use user.athleteId. On iOS, use sessionTraceId rather than NR session.
  • Session Replay is forbidden by org policy, enforced by a pretest grep guard.

Alerts as code

newrelic/ holds Terraform for policies, conditions, the Slack channel and workflow, with one runbook per condition. Jenkins runs terraform plan on PRs and apply on develop (TEST) and main (PROD). State lives in S3 + DynamoDB with Retain policies. Everything is named APEX - RLPv2 - ….

PolicyConditions
Auth (5)login failure rate · callback latency · unknown-outcome failures · session expiry spike · token refresh failures
Iframes (4)load errors · ready-timeout rate · token send error rate · origin rejected
App (4)federated app load errors · JS errors · LCP regression · INP regression
Store (3)config fetch error rate · views fetch errors · default config returned
Widgets (1)widget error rate (baseline 0.9%, alert > 5% / 1 h, floor 50 loads)

Single tier (critical only), routed to Slack only. Promotion to PagerDuty is per condition, after 2+ clean weeks and a dedicated PR. Dashboards live in the NR UI under APEX - RLPv2/Companion/RWE. Doctrine: src/shell/observability/README.md.

09

Build, quality & deploy

AspectDetail
Repo layoutsrc/ (app; src/shell/ is the served surface plus internals) · config/ (registries, codeQuality/, cloudFormation/) · newrelic/ · scripts/ · openspec/ · root Jenkinsfile. Mirrors whisker-host.
Toolchainpnpm 10 (single package), Node 22, Vite 6, @originjs/vite-plugin-federation, React 18, React Router 7, TypeScript 5.9, Vitest 3, @nike/podium
PipelineJenkins stages: Build (with QMA: lint report, coverage, scan-at-source) → DeployCloudFormation → ApplyNewRelicConfig → InvalidateCloudFront. develop → TEST, main → PRODUCTION, other branches → build/test only.
CI guardspnpm lint:report && pnpm test:coverage (80% global gate) · check:federation-singleton after build · check:observability-init (Session Replay) · Terraform validate.sh
WorkflowTest-first: a failing test is committed before the fix. Tests are hermetic (mocked fetch, storage and shell modules; VITE_CLIENT_* pinned empty).
HostingS3 + CloudFront (config/cloudFormation/rwe-resources.yaml, stack rwe-core). Host at /host; each partner at /{app} and /{app}/assets/remoteEntry.js. No minify or modulePreload.
Environmentstest rwe-core.retail-webux-test.nikecloud.com · prod rwe-core.retail-webux-prod.nikecloud.com
Partner widgetsDeployed to the same bucket using a cop-pipeline template the platform team provides. Updates go live without a host deploy.
Public contractPermanent CloudFront 301 /shell/* → /host/* for downstream repos built before consolidation
SecretsCerberus SDB shared/rbh-eagles/retail-launchpad-v2, read by CI. Never committed.

10

Decision records

The "why", in a consistent format. Expand any record for context, alternatives, consequences and the trigger to revisit it.

D1Embed full apps with iframesKeep v1's model and message vocabulary for sidebar apps
Context
Many teams on different stacks. RLPv1 already embedded apps in iframes, and hundreds of stores needed to move to v2.
Decision
Iframes for sidebar apps, with the v1 postMessage vocabulary preserved exactly.
Alternatives
Rewrite apps as federated modules. Rejected: it forces every app team to rewrite before migrating.
Consequences
  • Zero app changes for v1 → v2 (14 → 546 daily stores in a week).
  • Strong isolation and a browser-enforced security boundary; independent deploys.
  • Cost: a document load per app (p50 0.6 s, p95 up to 3.8 s); visual consistency relies on the design system. SOR shows it can still feel native. New browser policies (e.g. Local Network Access) must be delegated by the host.
Revisit when
An app team modernizes and needs shared state or faster loads.
D2Load widgets via Module FederationNative-feeling cards, owned and deployed by partner teams
Context
Home cards must be small, feel native, and stay owned by the teams that know the data.
Decision
Teams expose a component from their existing build. The host imports it at runtime.
Consequences
No iframe overhead, consistent look, and deploys without a host release. A shared runtime means a bad widget can affect the page, which is mitigated by error boundaries and telemetry (8 failures in 63,464 loads). Widgets built on a different React version are an open contract question.
D3The host serves the shell but doesn't federate with itselfImport by source path; one instance through shared chunks
Context
Host and partner modules must share one instance of auth, translations and the New Relic agent, or a partner silently sees "logged out." @originjs/vite-plugin-federation can't dedupe user-owned modules through shared.
Decision
Host code imports @/shell/… directly. Federation is used only to serve modules. Rollup places a module reachable from both the app entry and an expose in one chunk, and browsers cache ES modules by URL, so partners get the host's instance.
Alternatives
  • Self-consuming remote (the host loads its own shell through its own remoteEntry.js behind an async bootstrap): works, but adds a boot hop, a dev/build divergence and custom guardrails.
  • Switch to @module-federation/vite: deferred; not required for this property.
Consequences
Simple boot; dev and build behave the same. The invariant is asserted on dist/ in CI rather than enforced by structure. It is verified on build output, not end to end in a browser.
Revisit when
The federation plugin or Rollup chunking strategy changes, or the singleton guard fails.
D4A curated, read-only module surfaceretailUXShell/core = remoteSafeSelectors.ts, mirroring whisker-host
Context
The host's internal shell barrel includes providers, login / logout / sessionExpiresIn and token getters. Exposing it would let partners drive the session. An audit of partner repos showed they need seven symbols.
Decision
Expose only ./core (an explicit seven-symbol file) and ./observability. useAuth() for partners returns { accessToken, isLoggedIn, userInfo } only. The path is named core because that's what partner repos import.
Alternatives
Expose the full barrel or several granular paths (Shell, useAuth, Translations, widgetHostSdk). Rejected: it widens the contract without consumers and hands partners session control.
Consequences
The host alone owns the session. Each new capability needs a real consumer and approval. Gaps partners worked around (store ID, mode-scoped dismissals) have a clear place to land.
Revisit when
Partners stop self-wrapping in <Shell> (then Shell can leave the surface), or a partner adopts observability.
D5One package, shaped like whisker-hostNo workspaces; non-source config under config/
Context
There is one deployable app. Workspaces, sample packages and split quality configs would add overhead without consumers. whisker-host is the equivalent corporate host.
Decision
A single package at the repo root, one root Jenkinsfile, one QMA config, and lint plus the 80% coverage gate enforced in CI.
Consequences
Same shape as the corporate host, so patterns and people transfer between them.
D6Widgets own their display logicThe host stays generic; owners decide what's worth surfacing
Context
Each widget relies on a different backend and different rules. Centralizing them in one backend or repo puts many teams in one codebase and duplicates logic from their own apps.
Decision
Widgets evaluate their own conditions and call notifyReady / notifyHide.
Alternatives
  • Central rules engine: high coordination cost and a single team bottleneck.
  • Backend per widget: forces simple widgets to run infrastructure that monitors every store and user. Disproportionate effort.
Consequences
Simple and low effort, and code stays with its owners. The platform team doesn't control when a partner reveals a widget, so it relies on trust plus guardrails. A widget kill switch is still missing.
D7Polling for MVP, not event-drivenMost conditions require polling somewhere; frontend was the cheapest place to start
Context
Event-driven display needs partners far upstream to detect conditions and emit events for every store and user. Some conditions are inherently time-based (SOD/EOD), so a poll or cron must exist somewhere either way.
Decision
Widgets poll their own APIs, only while a device in that store has RLPv2 open. Frequency is set per widget. A 5–30 minute delay is acceptable for most notifications.
Consequences
  • No new infrastructure; shipped fast.
  • Polling only runs while Home is open, about 1.5 h per store per day in production. Total widget traffic is ~1.9 M requests/month, under 1% of the original worst-case model.
  • Redundant polling: every device in a store fetches the same store-level data, and each widget picks its own interval (Nike Cash: 60 s).
  • The host contract doesn't change when a widget moves to events, so migration can happen one widget at a time.
Revisit when
Widget count grows past a handful, or a source system can emit events (e.g. variance recorded in Nike Cash). See the cost model in section 11.
D8Dismissals in localStorage for MVPShip the framework first; centralize state later
Decision
No backend or database for visibility state in the MVP. Effort went into the framework, auth, widget infrastructure and partner utilities instead.
Consequences
Dismissals are per device, not shared across a store, and lost when browser data is cleared. Accepted for MVP; centralizing it depends on the Home direction (see open questions).
D9Observability as a platform standard, alerts as codeOne SDK, one taxonomy, no PII names, no Session Replay
Decision
One agent and SDK with branded event names and required attributes. Names are stripped in favor of user.athleteId. Alerts, routing and runbooks are versioned as Terraform and ship single-tier to Slack.
Consequences
Cross-team dashboards and reviewable alerts; rollout health was measurable during the v1 → v2 migration. Signals that production data shows are noise are dropped rather than tuned.
D10Defer the future-state backend until Home is definedAvoid building AWS infrastructure we'd throw away
Context
Product and Design are iterating twice weekly on what Home should be: a notifications feed or a daily to-do list. The two share almost nothing on the backend.
Decision
Don't build the notification backend until requirements are set. With AI-assisted development, the hard part is a precise plan, not the implementation.
Meanwhile
Discovery and POCs for known widgets, widget localization, corporate view and store switching, and developer experience.

11

Polling cost model

The original worst-case model assumed 1,500 stores × 3 devices × 50 widgets, each polling every 20 minutes for 12 hours a day: 243 M requests a month ≈ $731 on a modeled API Gateway + Lambda + DynamoDB stack (≈ $3.01 per million). That model assumes every device sits on Home all day. It doesn't. Widgets are rendered inside Home.tsx, so they fetch only while an associate has the Home page open. The real cost driver is how often and how long associates are on Home, so we measured it.

What production shows

389
web stores active per day
13.2
Home visits per store per day
1.5 h
Home open with polling running, per store per day (2.35 h open in total)
74%
of Home visits last under a minute: associates pass through to an app
How these numbers were derived (New Relic, prod, Oct 26–27, 2026 UTC)
  1. Home visits. NavHomeViewed PageActions on web (client.isNative = false): 10,254 over two days across 389 stores/day (avg of 391 and 387) = 13.2 per store per day.
  2. Time on Home. For each sessionTraceId, a visit starts at NavHomeViewed and ends at the next NavRouteChanged away from /, or, if the user never left, at the session's last event (usually a widget poll). Result: median visit 6 s, mean 10.7 min, p90 34 min. Visits over 30 min are 11% of visits but 85% of Home time. Total 914 Home-open hours/day (2.35 h per store).
  3. Time with polling actually running. Open time overstates polling: browsers suspend timers while a machine sleeps or locks. Nike Cash fires once per mount and then every 60 s, so (39,772 requests/day − 5,127 mounts) × 60 s = 577 polling hours/day = 1.48 h per store. The calculator uses this figure.
  4. Fetch behavior per widget. AjaxRequest events from the host page, split by URL and GraphQL operationName, with gaps between consecutive requests per session (Oct 27, 14:00–17:00 UTC, 10,325 requests):
    • Nike Cash (cash_mode): 87% of gaps ≈ 60 s. 1 request per check.
    • Variance (vrTillSettlements): checks about hourly, with 1–2 requests a second apart (today, then yesterday). Calibrated to 1.6 requests per check.
    • KPI (storeKpiReport): bursts on mount, no repeating interval. 2.4 requests per web Home visit.
  5. Check against reality. With those inputs the model gives 61.2 k requests/day on web; New Relic measured 61.3 k (Cash 39.8 k, Variance 9.1 k, KPI 12.4 k). iOS adds ~2.1 k KPI requests/day, for ~1.9 M requests/month in total.

Requests from inside iframe apps are a separate document and aren't counted. 90% of Cash requests carry pageUrl = /; the rest are polls that landed just as the user navigated away.

Usage

Widgets

Nike Cash1 request per check
SOR Variance1.6 requests per check
SOR KPI2.4 requests per Home visit
Additional widgets1 request per check each

requests/day = stores × Σwidgets ( visits × requests per mount + polling hours × 3600 ÷ interval × requests per check )

–
requests per month, all landing on partner backends
bar scale: original worst case (243 M) = 100%
–
≈ / month at the modeled $3.01 per million
–
average requests / second (24 h)
–
of traffic comes from Home mounting, not from timers
–
requests per store per day
Measured in prod: 1.84 M / month on web (+0.06 M iOS KPI). The "Today" preset reproduces it.
What the data changes about the original analysis:
  • Exposure is far lower than modeled. The model assumed 36 device-hours on Home per store per day (3 devices × 12 h). Production shows about 1.5. Most visits are a pass-through to an app.
  • One widget dominates. Nike Cash polls every 60 s, 20× faster than the model's 20 minutes, and makes up nearly two-thirds of all widget traffic. At 10 minutes it would drop from ~40 k to ~8.6 k requests/day.
  • Remounts matter. About 40% of requests come from widgets re-fetching each time Home mounts, not from timers. Variance and KPI traffic is almost entirely remount-driven. A short-lived shared cache in the SDK would cut this without touching intervals.
  • Net today: ~1.9 M requests/month, under 1% of the modeled 243 M (≈ $6 on the cost proxy). Frontend polling lands on partner backends, not a platform backend, so the dollar figure is a proxy, not our bill. The risks to watch are fast-polling widgets and redundant per-store requests as widgets multiply and Home becomes a place people stay.

12

Known constraints & tech debt

ItemImpactSeverity
No widget kill switchDisabling a misbehaving widget requires a prod deploy. Iframe apps already have per-store disable keys.High
Static widget registryAdding, removing or configuring a widget needs a host deploy, and there's no per-store, geo or role targeting.Medium
localStorage dismissalsPer device, not store-wide, and lost on clear. A notification can reappear for a colleague or on a new device.Medium
No polling guardrailsEach widget picks its own interval. Nike Cash polls every 60 s and generates nearly two-thirds of widget traffic. Every Home mount re-fetches (~40% of requests). No SDK wrapper for rate limits, shared caching or per-store dedupe.Medium
SDK gaps → divergent widgetsNike Cash keeps its own dismissal keys, parses the JWT itself and calls a getStoreId that doesn't exist.Medium
Dismissals keyed by operator_idNot persisted when operator_id is missing, which telemetry shows is common on iOS. Latent today because both suggestion widgets are web-only; key on athlete_id before shipping them to iOS.Medium
React version contract for widgetsPartners build against React 16 or 18 and self-wrap in <Shell>. What shared guarantees across versions is undefined.Medium
Single instance not browser-verifiedThe guard checks dist/. No end-to-end test loads a real partner widget against the built host.Medium
No remote MFE loader / branch buildsPartners can't try a widget in test or staging without a registry change and deploy.Medium
Silent widgets undetectedThere is no idle watchdog, so a widget that never calls notifyReady / notifyHide raises nothing.Low
Spec and doc driftThe align proposal and design D3 say "exposes unchanged + ./remoteSafeSelectors"; the code exposes only ./core. The header of remoteSafeSelectors.ts still says retailUXShell/remoteSafeSelectors.Low
observability exposed without a consumerEither partners adopt it, or it leaves the surface.Low
Widget type name driftHost 'suggestion' vs SDK 'notification'Low
Split iframe app configapps.json and appConfig.ts, next to an empty app-registry.tsLow
Store config fetch errors1.3% of fetches (436 / 33,885). Fallback behavior needs review.Low
Iframe p95 loadUp to 3.8 s (Store Hours) and 2.6 s (EJ). Owned by the app teams; the platform could add preconnect.Low
Mixed JS/TS in hostMessage handling and the iframe component are untyped JS.Low
Widget sizes and patterns not standardizedInconsistent Home layout as widgets grow. Needs Design.Low

13

Open questions

  1. Home direction: notifications feed, to-do list, or a hybrid? This blocks the backend design.
  2. Dismissal scope: per athlete, per role group in a store, or the whole store?
  3. Should polling-driven notifications (SOD/EOD) be centralized, or stay in the widget with an SDK polling wrapper?
  4. Widget registration: self-service for partner teams, or approved by the platform team?
  5. Should the kill switch also cover iframe apps, beyond today's per-store disable keys?
  6. What does shared guarantee for widgets built on a different React version, and do we require React 18?
  7. Once partners stop self-wrapping in <Shell>, does Shell leave the surface? Does observability stay?
  8. Is there a path for iframe apps to become federated modules, and which app would go first?
  9. What latency is acceptable per notification type? (Current guidance: 5–30 minutes.)
  10. Corporate view: what does a non-store user see, and how much should RLPv2 share with whisker-host?

Sources: rwe-core (AGENTS.md, README.md, vite.config.ts, src/, newrelic/, openspec/changes/align-rwe-core-with-whisker-host), rbh-retail-launchpad-ui, Confluence space COM (Architecture – Current vs Future State; Future State: Requirements; Future State Dependencies; SOR Variance Widget; Nike Cash Widget), New Relic production (rwe-core-host), Oct 21–27, 2026 unless noted.