← Back to list

How We Built a Scalable Real-Time Web Platform with Config-Driven Micro frontends (Control Tower)

At Zepto, quick commerce means everything moves fast — especially inside our Micro Warehouses. Orders are constantly flowing in, and…

Zepto Tech in Zepto TechXPress · 2026-04-13 06:51 · 64 claps · 11.2 min read
#ui-design #platform-engineering #micro-frontends #compilers #data-visualization
Open on Medium ↗
Wiki topics: UX · UI/UX Design VIS · Visual & Graphic Design GRW · Growth & Analytics 🌐 · Web Development

How We Built a Scalable Real-Time Web Platform with Config-Driven Micro frontends (Control Tower)

At Zepto, quick commerce means everything moves fast — especially inside our Micro Warehouses. Orders are constantly flowing in, and operations run at high speed.In this environment, there’s no shortage of data. But raw data alone isn’t useful. The real challenge is making fast, reliable decisions.

We focused on building a web platform where UI/UX and configurability do the heavy lifting. Instead of static dashboards, teams get clear, real-time views tailored to their role. What matters shows up instantly , no digging, no delays.

This makes it easier to catch issues early, respond faster, and maintain quality even at scale. In quick commerce, speed isn’t just about deliveries, it’s about how fast you can understand and act.

Operations teams don’t just need dashboards. They need:

  • Consistency: Is this view current?
  • Context: What caused this anomaly?
  • Determinism: If I click this, what exactly happens?
  • Low cognitive overhead: No context-switching across multiple tools

To solve this, we built a unified dashboard called the Control Tower — not as just another page, but as foundational infrastructure. It brings all critical data into a single place, eliminating the need to navigate across systems. Under the hood, it’s a runtime-composed, config-driven micro-frontend platform that turns declarative intent into a live operational surface. And the most interesting part isn’t the UI itself — it’s the runtime that interprets intent and orchestrates behavior.

This blog breaks down how we built it and the architectural trade-offs we made along the way.

Why We Chose a Micro-frontend (and Why Module Federation)

Control Tower lives inside a larger host platform that already ships multiple applications. Early on, we ran into two constraints:

  1. Independent release cadence — Operational dashboards evolve weekly. We couldn’t wait for unrelated apps to release.
  2. Failure isolation — Real-time orchestration has different failure modes than transactional flows. We didn’t want regressions to leak across domains.

We needed runtime isolation without losing SPA integration.

Module Federation: Runtime Composition, Not Build Magic

We chose Webpack Module Federation because it enables runtime composition. The host doesn’t “import” Control Tower at build time. It loads it dynamically at runtime.

What Happens at Runtime?

  1. The host loads remoteEntry.js of the remote app
  2. Webpack initializes the shared dependency scope
  3. Shared libraries like React are resolved (as singletons)
  4. The host requests the exposed module (./Module)
  5. The remote returns the module
  6. The host mounts it into its route tree

In practice, the host loads Control Tower like this:

const ControlTower = lazy(() => import('control-tower/Module'));
export default function ControlTower() {
  return (
    <ErrorBoundary fallback={<div>Something went wrong</div>}>
      <Suspense
        fallback={<ProgressLoading />}
      >
        <ControlTower />
      </Suspense>
    </ErrorBoundary>
  );
}
const config: ModuleFederationConfig = {
  name: 'control-tower',
  exposes: {
    './Module': './src/remote-entry.ts',
  },
  shared: (libraryName) => {
    if (libraryName === 'react' || libraryName === 'react-dom') {
      return { singleton: true, strictVersion: true, requiredVersion: false };
    }
    return {
      singleton: true,
      strictVersion: false,
      requiredVersion: false,
    };
  },
};
export default config;

Critical Rule: React Must Be a Singleton

One key rule we enforced: React must be a strict singleton.

Having multiple React instances in a federated app isn’t just a warning — it leads to hard runtime failures (hooks break, context fails, rendering crashes).

That’s why our shared config enforces:

if (libraryName === 'react' || libraryName === 'react-dom') {
  return { singleton: true, strictVersion: true, requiredVersion: false };
}

What This Gave Us

Once everything was wired correctly, we achieved:

  • Independent deployments
  • A shared React runtime
  • Seamless integration into the host shell

This gave us isolation. Now, the next challenge was flexibility.

Press enter or click to view image in full size

The Core Principle: UI Expresses Intent, Not Implementation

Most traditional React dashboards follow a familiar pattern: components don’t just render UI, they own behavior.

A typical widgets :

  • Each widget fetches its own data
  • Transforms it locally
  • Handles its own click logic
  • Decides navigation or state updates

At this point, components stop being “UI pieces” and start becoming mini orchestration engines. Instead of embedding behavior inside components, we made Control Tower config-driven. Configuration became the source of truth for everything :

  • Structure — what renders and where
  • Data — how data is fetched
  • Behavior — what happens on user interaction
  • Storage semantics — where data lives and how it updates

The React layer became a rendering and execution runtime.

The Renderer: A Recursive Config — UI Compiler

At runtime, Control Tower receives layout configuration in JSON . That configuration is not just data it’s effectively a blueprint for the UI. We “compile” this blueprint into a React component tree.

There are two primary layers:

  • SectionRenderer — Responsible for layout (grids, rows, sections)
  • ConfigRenderer — Responsible for rendering widgets recursively

This separation keeps layout concerns independent from widget behavior. The core renderer looks conceptually like this:

const ConfigRenderer = ({ config }: ConfigRendererProps) => {
  const Component = rendererRegistry[config.type];
  const handleClick = useActionConfigHandler();
  if (!Component) return null;
  if (!isConfigEnabled(config)) return null;
  return (
    <div onClick={config.clickConfig ? (e) => handleClick(config.clickConfig, e) : undefined}>
      <Suspense fallback={<LoadingFallback />}>
        <Component config={config}>
          {config.children?.map((child) => (
            <ConfigRenderer key={child.id} config={child} />
          ))}
        </Component>
      </Suspense>
    </div>
  );
};

Each node in the configuration:

  • Resolves to a component via a registry
  • Passes down its configuration
  • Recursively renders its children

This allows us to construct deeply nested, dynamic UIs with a single generic renderer.

What the Renderer Does Not Do:

  • No data fetching
  • No data transformation
  • No direct store mutations
  • No embedded business rules

It doesn’t own logic, it just interprets configuration and delegates execution. That boundary is what keeps the renderer from evolving into a monolith.

Code Splitting the Renderer Registry

A naive config-driven dashboard takes a straightforward approach: Bundle every widget renderer upfront. It works… but only up to a point.

As the number of widgets grows, this quickly turns into a scalability problem:

  • Larger bundle sizes
  • Slower initial load times
  • Unnecessary code shipped to users

The Shift: Lazy-Loaded Renderers. To avoid this, we made the renderer registry lazy-loaded:

const RENDERERS: Record<string, ComponentType<T>> = {
  [RENDERER_TYPES.TEXT]: lazy(() => import('./text-renderer')),
  [RENDERER_TYPES.TAB]: lazy(() => import('./tab-renderer')),
  [RENDERER_TYPES.TABLE]: lazy(() => import('./table-renderer')),
};

Now, widgets are loaded only when they are actually referenced in the configuration.

Declarative Interactions: Clicks as Action Pipelines

Rendering UI turned out to be the easy part. Behavior was not. The Problem with Component-Owned Logic. If every widget owns its own click logic, things break down quickly:

  • Orchestration code gets duplicated across components.
  • Error handling becomes inconsistent.
  • Components get tightly coupled to APIs.
  • Race conditions start creeping in.

Over time, simple interactions evolve into complex workflows scattered across the UI. Widgets don’t execute behavior they declare it. Each widget defines a clickConfig, and a central action engine is responsible for executing it.

An example action pipeline:

{
  "type": "sequence",
  "path": [
    {
      "type": "storeUpdate",
      "updateList": [
        {
          "key": "store1.filters.range_from",
          "value": "<state.payload.range_from>"
        }
      ]
    },
    {
      "id": "store1LineGraphDataSource",
      "type": "rest",
      "url": "https://test.com/api/v1/widgets/data",
      "method": "POST",
      "transform": [
        {
          "type": "baseTransformer"
        }
      ],
      "payload": {
        "widgets": [
          {
            "name": "widget1",
            "filters": {
              "header": "Test Header",
              "metric_name": "<store.store1.filters.metric>",
              "range_from": "<state.payload.range_from>",
              "range_to": "<state.payload.range_to>",
              "unit": "<store.store1.filters.dateTab>"
            }
          }
        ]
      }
    },
    {
      "type": "storeUpdate",
      "updateList": [
        {
          "key": "widget1.store1.data.series",
          "value": "<state.store1LineGraphDataSource.data.series>"
        }
      ]
    }
  ]
}

The execution engine:

const configHandler = async (
  config: ClickConfig,
  e?: React.MouseEvent<HTMLButtonElement | HTMLDivElement>,
  state?: Record<string, any>
): Promise<any> => {
  if (!config) {
    return null;
  }
  if (
    config.type === 'link' ||
    !('propogate' in config && config.propogate)
  ) {
    e?.stopPropagation();
  }
  switch (config.type) {
    case 'link': {
      const windowObj = window as any;
      let url = config.to;
      if (config.params) {
        const searchParams = new URLSearchParams();
        const mergedState = { ...(state ?? {}) };
        for (const [key, value] of Object.entries(config.params)) {
          const modifiedValue = processStringValue(value, mergedState);
          searchParams.set(key, modifiedValue);
        }
        url = `${url}?${searchParams.toString()}`;
      }
      if ('mfeNavigate' in windowObj) {
        windowObj.mfeNavigate?.(url.toString());
      } else {
        window.location.href = url.toString();
      }
      break;
    }
    case 'drawer': {
      openDrawer(config);
      break;
    }
    case 'closeDrawer': {
      closeDrawer();
      break;
    }
    case 'modal': {
      openModal(config);
      break;
    }
    case 'configModifier': {
      handleConfigModifier(config);
      break;
    }
    case 'rest': {
      return await createPromiseFetcher(config);
    }
    case 'storeUpdate': {
      for (const item of config.updateList) {
        const value = state ? processStringValue(item.value, state) : item.value;
        setData(item.key, value, item.shouldMerge);
      }
      break;
    }
    case 'executor': {
      const transformer = createTransformer(config.transform);
      const { data } = transformer(state);
      return data;
    }
    case 'sequence': {
      let currentState = state ?? {};
      for (const itemConfig of config.path) {
        const modifiedConfig = mergeStateInConfig(itemConfig as DataSourceConfig, currentState);
        const result = await configHandler(modifiedConfig, e, currentState);
        if (itemConfig.id) {
          currentState[itemConfig.id] = result;
        }
      }
      break;
    }
    case 'updateLoadingState': {
      const { updateList, value } = config;
      setLoading(updateList, value);
      break;
    }
    default: {
      console.warn('Unsupported click config:', config);
    }
  }
};

This approach gives us:

  • Deterministic execution — no hidden side effects
  • Explicit sequencing — workflows are easy to reason about
  • State threading — data flows cleanly between steps
  • Reusability — the same action logic works across widgets

We learned this the hard way: Pub/Sub works well for data. It is dangerous for user actions.

Event-driven systems can introduce non-determinism, which makes debugging and reasoning extremely difficult.

Press enter or click to view image in full size

The Data Plane: Protocol-Specific Fetchers

Real-time dashboards don’t behave like typical UI state problems. Data doesn’t wait for you it arrives on its own schedule. This makes it fundamentally different from something like Redux-driven flows, where updates are usually triggered by user actions. We had to deal with multiple kinds of data sources:

  • Request-response APIs
  • Streaming updates
  • Intermittent failures and retries

Trying to handle all of this inside components or a single abstraction quickly becomes messy.

Fetchers publish results via a narrow Pub/Sub contract. This ensures:

  • Loose coupling between data sources and UI
  • Flexibility to add new protocols without breaking existing flows
  • Clear boundaries in the system

REST Fetcher with Cancellation

Race conditions showed up early and they were painful. Slow responses were overwriting fresh data. We solved this using AbortController keyed by datasource id:

const controllerMap = new Map < string, AbortController> ();
const fetchData = async (): Promise<void> => {
  try {
    if (config.id && controllerMap.has(config.id)) {
      const prevController = controllerMap.get(config.id);
      prevController?.abort();
    }
    const controller = new AbortController();
    if (config.id) {
      controllerMap.set(config.id, controller);
    }
    const response = await axios.request < T > ({
      url,
      method: config.method || 'GET',
      data: config.payload,
      headers: config.headers,
      signal: controller.signal
    });
    pubsub.publish(response.data);
    if (config.id) {
      controllerMap.delete(config.id);
    }
    scheduleNextPoll();
  } catch (error) {
    if (config.id) {
      controllerMap.delete(config.id);
    }
    if (isCancel(error)) {
      return;
    }
    pubsub.publishError(
      error instanceof Error ? error : new Error(String(error))
    );
    scheduleNextPoll();
  }
};

Not all APIs behave the same and treating them the same is a mistake. We made this distinction explicit:

  • Snapshot APIs — replace the existing data completely Snapshot APIs return the full state of data at a given moment. This means every response should be treated as the latest and most accurate version, and the previous data should be completely replaced. There’s no need to merge or preserve old values because each response already contains everything you need.
  • Polling APIs — keep updating the data over time Polling APIs are designed to be called repeatedly to track changes. Instead of giving a full picture every time, they provide updates that need to be applied on top of the existing data. Here, you don’t replace everything — you update, merge, or append to maintain a continuously evolving state.

The fetcher doesn’t just fetch, it enforces data semantics.

WebSocket Fetcher: Stateful Streams

WebSockets are a completely different beast compared to REST. They’re not requests, they’re long-lived data streams. Unlike simple request-response APIs, WebSockets introduce new responsibilities:

  • Managing connection state
  • Sending heartbeats to keep the connection alive
  • Handling reconnects on failure
  • Merging incremental updates into existing state

This isn’t just “fetching data” it’s maintaining a live channel. This is where the mental model changes:

  • REST APIs — return snapshots — overwrite state
  • WebSockets — send updates — merge into state

Streams don’t replace data, they evolve it. Instead of hiding this complexity, we made it config-driven. The behavior is declared using mergeData:

  • mergeData: false — treat data as a snapshot (overwrite).
  • mergeData: true — treat data as a stream (incremental merge).

Storage Semantics: storePath Is a Contract

One of the most subtle and dangerous bugs in config-driven systems comes from implicit storage. At first, it feels convenient to derive storage keys from layout or component structure. But that convenience doesn’t last. When storage is tied to layout:

  • Refactoring UI structure breaks data consumers
  • Renaming components silently changes state shape
  • Debugging becomes unpredictable
{
  "storePath": "chart.instorespeed",
  "mergeData": false
}

What This Enforces

  • Stable state shape independent of UI structure
  • Clear ownership of where data lives
  • Predictable read/write behavior across the system

We started treating the state shape as a public contract:

  • Layout can change
  • Renderer types can change
  • Component hierarchy can evolve

But: storePath must remain stable.

Placeholder Resolution: Dynamic, but Controlled

Dashboards rarely work with static payloads. Every request depends on context:

  • Date ranges
  • User-selected filters
  • Entity IDs
  • Tenant-specific metadata

Hardcoding these inside components quickly becomes unmanageable.

The Approach: Declarative Placeholders

We introduced a minimal placeholder system to make payloads dynamic and config-driven:

  • <date> — system-generated values (e.g. current date, ranges)
  • <state.*> — values from the current execution state
  • <store.*> — values from the global store
  • <appMeta.*> — application-level context (tenant, environment, etc.)

This allows payloads to stay declarative, while still being fully dynamic.

export const processStringValue = (
  value: string,
  pageState: Record<string, T>
): T => {
  // First, replace date placeholders like <date> or <date - 1>
  let processedValue = replaceDatePlaceholders(value);
  // If the result is not a string (e.g., became a number after date replacement), return it
  if (typeof processedValue !== 'string') {
    return processedValue;
  }
  // Then, check for state placeholders
  if (!PLACEHOLDER_REGEX.test(processedValue)) {
    return processedValue;
  }
  const parsed = parsePlaceholder(processedValue);
  if (!parsed) {
    return processedValue;
  }
  return resolveValue(parsed.prefix, parsed.path, pageState);
};
const resolveValue = (
  prefix: string,
  path: string,
  pageState: Record<string, any>
): any => {
  if (prefix === 'state') {
    return getNestedValue(pageState, path);
  }
  if (prefix === 'store') {
    // Access the dataSourceStore to get values
    const storeData = dataSourceStore.getState().data;
    return getValueFromPath(storeData, path);
  }
  if (prefix === 'appMeta') {
    // Access the appMetaStore to get app metadata values
    const appMetaData = appMetaStore.getState().appMetaData;
    return getValueFromPath(appMetaData, path);
  }
  return null;
};

We intentionally kept it simple. Once placeholder systems become expression engines, debugging becomes archaeology.

Separating Loading from Data

A subtle UX trap in config-driven systems is coupling loading state with data storage. At first glance, tying loading directly to storePath feels natural. But in practice, it creates confusing behavior. When loading is derived from storePath:

  • Unrelated widgets end up sharing the same loading state
  • Streaming data sources create ambiguous or flickering loaders

The UI loses clarity because data and UX are tightly coupled. We introduced a clear separation:

  • storePath — defines where data lives
  • loadingKey — defines what controls the loading UI

That decoupling made complex screens predictable.

Observability in a Distributed Frontend Runtime

Once your frontend becomes a distributed system, observability matters.

We built:

  • Action execution logs
  • Datasource IDs for tracing
  • Store write tracing
  • Runtime config inspection tools

Without observability, config-driven systems become black boxes. With it, they become debuggable platforms.

The Real Outcome

Control Tower stopped being “just a dashboard” the moment behavior became declarative. It evolved into something more fundamental:

  • A rendering engine
  • An execution engine
  • A data orchestration runtime

Config-driven systems don’t eliminate complexity. They force you to decide where complexity is allowed to live. That decision is what defines whether the system scales or collapses.

Where Complexity Lives

In Control Tower, complexity is not hidden, it’s explicitly layered:

  • Rendering — how UI is constructed
  • Actions — how behavior is executed
  • Fetching — how data is retrieved
  • Transformation — how data is shaped
  • Storage — where data lives and how it evolves

Each layer has a clear responsibility. Nothing leaks across boundaries.

Why This Works

Because these boundaries are explicit:

  • The system remains predictable
  • Features don’t turn into special cases
  • New capabilities can be added without breaking existing ones

Scale comes from structure, not from removing complexity. In one line, we didn’t reduce complexity, we contained it.


메타데이터
post_id
e43dc09262eb
slug
how-we-built-a-scalable-real-time-web-platform-with-config-driven-micro-frontends-control-tower-e43dc09262eb
url
https://blog.zepto.com/how-we-built-a-scalable-real-time-web-platform-with-config-driven-micro-frontends-control-tower-e43dc09262eb
canonical_url
https://blog.zepto.com/how-we-built-a-scalable-real-time-web-platform-with-config-driven-micro-frontends-control-tower-e43dc09262eb
author_url
https://medium.com/@tech.culture
status
ok
fetched_at
2026-06-11 05:11:55