Skip to content
6 min read

Building Zero Crust: Distributed State Management in Electron

A deep dive into building a dual-head POS simulator with Electron. Learn about centralized state management, secure IPC patterns, Zod validation, and the Architecture Debug Window for real-time visualization.

Building Zero Crust: Distributed State Management in Electron

Point-of-sale systems present a fascinating architectural challenge: you need two displays—one for the cashier, one for the customer—showing identical information, but running on separate hardware with strict security boundaries. Get the synchronization wrong and you have customers seeing incorrect prices. Get the security wrong and you’re vulnerable to price tampering.

Zero Crust is my exploration of these patterns using Electron. It’s a reference implementation demonstrating how to build enterprise-grade distributed state management while maintaining the defense-in-depth security that desktop applications require.

The Dual-Head Challenge

In production POS deployments, the cashier terminal and customer-facing display are often separate physical devices. The cashier’s screen shows product grids, payment controls, and management functions. The customer’s screen shows only the cart—a simple, trust-nothing display.

Electron’s multi-window architecture maps perfectly to this model. Each window runs in its own renderer process, sandboxed and isolated. The main process acts as the trusted coordinator—the only process with access to payment services, persistence, and the application state.

┌─────────────────┐     ┌─────────────────┐
│  Cashier Window │     │ Customer Window │
│   (Renderer)    │     │   (Renderer)    │
│   ┌─────────┐   │     │   ┌─────────┐   │
│   │ Cart UI │   │     │   │ Cart UI │   │
│   └─────────┘   │     │   └─────────┘   │
└────────┬────────┘     └────────┬────────┘
         │                       │
         │    IPC Commands       │
         ▼                       ▼
┌──────────────────────────────────────────┐
│            Main Process                   │
│  ┌──────────┐ ┌─────────┐ ┌──────────┐  │
│  │MainStore │ │Payment  │ │Broadcast │  │
│  │ (State)  │ │Service  │ │Service   │  │
│  └──────────┘ └─────────┘ └──────────┘  │
└──────────────────────────────────────────┘

Centralized State with MainStore

The heart of Zero Crust is MainStore—a centralized state container that serves as the single source of truth. Every piece of application state lives here: the cart items, transaction history, current session, and payment status.

// MainStore.ts - Centralized state management
export class MainStore {
  private state: InternalState;
  private listeners = new Set<Listener>();

  private updateState(recipe: (draft: InternalState) => void): void {
    this.state = produce(this.state, (draft) => {
      recipe(draft);
      draft.version++;
    });
    this.notifyListeners();
  }
}

The key insight here is state versioning. Every state update increments a version number. This allows renderers to detect stale state and provides an audit trail of state changes. Combined with Immer’s structural sharing, updates are both immutable and efficient.

A polished visualization of the Command Pattern and State Broadcasting, replacing the need for the reader to mentally visualize the text-based flow.

The Command Pattern for IPC

Renderers don’t mutate state directly—they can’t. They send commands to the main process, which validates and processes them. This is the Command Pattern applied to IPC:

// ipc-types.ts - Discriminated union of commands
export type Command =
  | { type: 'ADD_ITEM'; sku: string }
  | { type: 'REMOVE_ITEM'; sku: string }
  | { type: 'UPDATE_QUANTITY'; sku: string; quantity: number }
  | { type: 'CLEAR_CART' }
  | { type: 'START_PAYMENT' }
  | { type: 'VOID_TRANSACTION' };

Notice that renderers send SKUs, not prices. The main process looks up prices from its trusted product catalog. This ID-based messaging pattern prevents a compromised renderer from sending fake prices—the worst it can do is add items that exist.

Runtime Validation with Zod

TypeScript types vanish at runtime. When an IPC message crosses the process boundary, you have no guarantee it matches your type definitions. A malicious actor could send arbitrary data. This is where Zod comes in:

// schemas.ts - Runtime validation
export const AddItemSchema = z.object({
  type: z.literal('ADD_ITEM'),
  sku: z.string().min(1).max(50),
});

export const CommandSchema = z.discriminatedUnion('type', [
  AddItemSchema,
  RemoveItemSchema,
  UpdateQuantitySchema,
  ClearCartSchema,
  StartPaymentSchema,
  VoidTransactionSchema,
]);

Every incoming command is validated before processing. Invalid commands are rejected with detailed error messages for debugging. This transforms runtime errors from mysterious crashes into clear validation failures.

Defense in Depth: Electron Security

Zero Crust implements six layers of security, each catching threats that slip past others:

Illustrates the concept of multi-layered security barriers protecting the core application state.

1. Electron Fuses — Compile-time flags that cannot be changed at runtime. Node.js integration is disabled at the binary level.

2. Context Isolation — Renderer processes run in an isolated JavaScript context. They cannot access Node.js APIs, Electron internals, or the preload script’s scope.

3. Zod Validation — Every IPC message is validated against a strict schema. Malformed or unexpected data is rejected.

4. Sender Verification — IPC handlers check event.sender against known window IDs. Commands from unknown sources are dropped.

5. Navigation Control — All navigation is blocked except to file:// URLs. No external websites can be loaded into windows.

6. Permission Denial — All permission requests (camera, microphone, geolocation) are denied by default.

// SecurityHandlers.ts - Sender validation
function validateSender(event: IpcMainInvokeEvent): boolean {
  const webContents = event.sender;
  const knownIds = windowManager.getKnownWebContentsIds();
  return knownIds.includes(webContents.id);
}

The BroadcastService Pattern

State synchronization is notoriously tricky. Delta updates, conflict resolution, eventual consistency—these are PhD-level distributed systems problems. Zero Crust sidesteps the complexity with a brutally simple approach: broadcast the entire state on every change.

// BroadcastService.ts - Full-state sync
export class BroadcastService {
  constructor(mainStore: MainStore, windowManager: WindowManager) {
    mainStore.subscribe((state) => {
      windowManager.broadcastState(state);
    });
  }
}

When the cart changes, every renderer gets a complete snapshot of the new state. No diffing, no patches, no merge conflicts. Renderers simply replace their local state with whatever arrives.

This pattern eliminates entire categories of bugs:

  • No stale state — Renderers always have the latest version
  • No synchronization drift — State is identical across all windows by construction
  • No ordering issues — Each broadcast is a complete snapshot
  • Trivial debugging — Log any state snapshot and you see exactly what all renderers see

The performance cost? Negligible. A typical POS cart has maybe 20 items. Serializing and deserializing that with structuredClone takes microseconds.

The Architecture Debug Window

Debugging distributed systems is hard. You can’t set a breakpoint across process boundaries. You can’t easily trace the flow of messages between windows. That’s why Zero Crust includes a real-time Architecture Debug Window.

Architecture Debug Window

The debug window shows:

  • Event Timeline — Every IPC message, state update, and trace event in chronological order
  • Architecture Graph — Visual representation of windows and message flow with animated edges
  • State Inspector — JSON tree view with diff highlighting showing exactly what changed
  • Live Statistics — Events per second, average latency, state version

The implementation uses a circular buffer to store trace events without unbounded memory growth:

// TraceService.ts - Event collection
export class TraceService {
  private events: TraceEvent[] = [];
  private maxEvents = 1000;

  record(event: Omit<TraceEvent, 'id' | 'timestamp'>): void {
    const fullEvent = {
      ...event,
      id: this.nextId++,
      timestamp: Date.now(),
    };
    this.events.push(fullEvent);
    if (this.events.length > this.maxEvents) {
      this.events.shift();
    }
    this.broadcast(fullEvent);
  }
}

Critically, the debug window is lazy activated. TraceService only collects events when the debug window is open. No overhead when you don’t need it.

Integer Math for Currency

Here’s a bug that bankrupts companies:

0.1 + 0.2 === 0.3  // false! It's 0.30000000000000004

Floating-point arithmetic is fundamentally incompatible with financial calculations. The IEEE 754 standard cannot precisely represent most decimal values. Zero Crust solves this by storing all monetary values as integers representing cents:

// currency.ts - Integer-only currency
export type Cents = number & { __brand: 'Cents' };

export function toCents(dollars: number): Cents {
  return Math.round(dollars * 100) as Cents;
}

export function formatCurrency(cents: Cents): string {
  return `$${(cents / 100).toFixed(2)}`;
}

The branded type Cents makes it impossible to accidentally mix cents and dollars. TypeScript will error if you pass a regular number where Cents is expected.

This pattern extends to all calculations:

  • Tax is computed as (subtotal * taxRate) / 100, rounded
  • Discounts are stored and applied as cent values
  • Totals are summed, never multiplied by fractional amounts

Screenshots

Here’s Zero Crust in action:

Cashier Window The cashier window with product grid and cart sidebar

Customer Display Customer display showing the synchronized cart state

Transaction History Transaction history with completed orders

Lessons Learned

Building Zero Crust reinforced several principles:

Simplicity beats cleverness. Full-state broadcast is “inefficient” but eliminates entire bug categories. The debuggability alone is worth it.

Security is layers. No single security measure is sufficient. Context isolation protects against XSS. Zod validation catches malformed data. Sender verification stops spoofed messages. Each layer catches what others miss.

TypeScript needs runtime backup. Types are erased at runtime. Process boundaries need runtime validation. Zod provides this beautifully.

Debug tools pay dividends. The Architecture Debug Window took significant effort to build. It’s saved ten times that in debugging time.

Model real hardware. Designing for dual-head from the start forced clean separation. The constraints improved the architecture.

Try It Yourself

Zero Crust is open source at github.com/cameronrye/zero-crust. Clone it, run pnpm dev, and explore the architecture. The debug window (View > Architecture or Cmd+Shift+A) is the best way to understand the message flow.

Whether you’re building a POS system, a multi-window desktop app, or just curious about Electron patterns, I hope this implementation provides useful reference patterns.

Was this helpful?

Have questions about this article?

Ask can help explain concepts, provide context, or point you to related content.