---
name: connection-architecture
domain: perps
description: Connection lifecycle, WebSocket management, stream channels, cache clearing
---

# Perps Connection Architecture

## Overview

The Perps connection system uses a layered architecture where each layer has clear responsibilities and ownership boundaries.

Connection lifecycle is managed by a single top-level `PerpsAlwaysOnProvider` mounted at the wallet root. Per-section `PerpsConnectionProvider` instances provide React context to consumers but do **not** manage connect/disconnect — that responsibility belongs exclusively to `PerpsAlwaysOnProvider`.

## Layer Stack

```mermaid
graph TD
    AOProv[PerpsAlwaysOnProvider<br/>Wallet root - always on] -->|calls connect/disconnect| Manager[PerpsConnectionManager]
    UI[UI Components] -->|uses| Hook[usePerpsConnection]
    Hook -->|reads context from| Provider[PerpsConnectionProvider<br/>manageLifecycle=false]
    Provider -.polls state from.-> Manager
    Manager -->|orchestrates| Controller[PerpsController]
    Controller -->|manages| HP[HyperLiquidProvider]
    HP -->|REST API| API[HyperLiquid API]
    HP -->|WebSocket| WS[WebSocket Subscriptions]

    Controller -.stores data in.-> Redux[Redux State]
```

## Layer Responsibilities

### Hook Layer: usePerpsConnection

**What it is**: React hook that provides access to connection state and methods for UI components

**Owns**:

- Nothing - it's a pure accessor hook

**Responsibilities**:

- Read connection context from PerpsConnectionProvider
- Provide type-safe access to connection state and methods
- Throw error if used outside of Provider

**Does NOT**:

- Store any state
- Perform any logic
- Know about Manager, Controller, or Provider implementation

**Key API**: Returns `{ isConnected, isConnecting, isInitialized, error, connect, disconnect, reconnectWithNewContext, resetError }`

**Usage**: Primary API for all UI components to interact with the connection system

---

### Lifecycle Layer: PerpsAlwaysOnProvider

**What it is**: Top-level React component mounted once at the wallet root that owns the entire WebSocket connection lifecycle

**File**: `app/components/UI/Perps/providers/PerpsAlwaysOnProvider.tsx`

**Mounted at**: `app/components/Views/Wallet/index.tsx` — wraps `ErrorBoundary` and all wallet content

**Owns**:

- Single `AppState` listener for foreground/background transitions
- The only caller of `PerpsConnectionManager.connect()` and `PerpsConnectionManager.disconnect()`

**Responsibilities**:

- Call `connect()` on mount (when `isPerpsEnabled`)
- Call `disconnect()` when app goes to background (triggers 20s grace period in Manager)
- Call `ensureConnected()` when app returns to foreground (forces disconnect + fresh reconnect after stabilization delay)
- Call `disconnect()` on unmount

**Does NOT**:

- Provide React context (no `createContext`)
- Know about individual screens or tab visibility
- Manage stream subscriptions

**Result**: `connectionRefCount` in `PerpsConnectionManager` stays exactly 1 throughout the app lifetime, eliminating all reference-count edge cases from multiple simultaneous providers.

---

### UI Layer: PerpsConnectionProvider

**What it is**: React Context provider that exposes connection state and methods to UI components

**Owns**:

- Local React state (polled from Manager every 100ms)
- Polling interval for state synchronization
- UI-level error handling decisions (show error screen vs content)
- Retry attempt tracking

**Responsibilities**:

- Translate singleton Manager state into React state
- Provide stable callback functions to UI (`connect`, `disconnect`, `reconnectWithNewContext`, `resetError`)
- Decide when to show error screen vs content
- Handle E2E mode with mock state

**Does NOT**:

- Manage connection lifecycle when `manageLifecycle={false}` (the default for all section-level instances)
- Know about app state or background/foreground transitions
- Handle race conditions or reconnection logic

**Exposes**: Connection context via `PerpsConnectionContext` that `usePerpsConnection` hook reads from

**`manageLifecycle` prop**:

- `true` (default, used only by the perps stack navigator internally for historical reasons): passes `isVisible` to `usePerpsConnectionLifecycle` — **not used in practice since `PerpsAlwaysOnProvider` is the single lifecycle owner**
- `false`: suppresses all connect/disconnect calls; provider acts as context source only

All current `PerpsConnectionProvider` instances use `manageLifecycle={false}` — lifecycle is owned exclusively by `PerpsAlwaysOnProvider`.

---

### Manager Layer: PerpsConnectionManager (Singleton)

**What it is**: Orchestrator that coordinates connection lifecycle and manages connection state

**Owns**:

- Connection state (`isConnected`, `isConnecting`, `isInitialized`, `error`)
- Race condition guards (`initPromise`, `pendingReconnectPromise`)
- Grace period timer (`CONNECTION_GRACE_PERIOD_MS` = 20s delay before disconnect)
- Connection timeout management (30s limit for connection attempts)
- Reference counting (tracks active provider instances)
- Stream manager caches (via PerpsStreamManager singleton - separate channels for prices, orders, positions, account state; provides instant cached data to subscribers; supports pause/resume for race condition prevention)
- Redux store subscription for account/network change monitoring

**Responsibilities**:

- Coordinate connect/disconnect based on provider reference counting
- Monitor Redux for account and network changes, trigger reconnection automatically
- Handle force flag logic (cancel pending operations vs wait, including timeout timers)
- Implement grace period (20s) to prevent flickering disconnects
- Enforce connection timeout (30s) to prevent indefinite hanging
- Clear stream caches during reconnection
- Delegate actual provider initialization to Controller
- Validate connection with WebSocket health checks via `provider.ping()` (replaces blocking HTTP calls)

**Does NOT**:

- Create or manage provider instances
- Know about specific exchange protocols
- Update Redux state
- Handle WebSocket connections directly

**Key Methods**:

- `connect()` - Initialize connection if first provider instance
- `disconnect()` - Disconnect if last provider instance (after grace period)
- `reconnectWithNewContext(options?: ReconnectOptions)` - Coordinate full reconnection with optional force flag

---

## Subscription Warmup Process

After controller initialization, the ConnectionManager pre-warms WebSocket subscriptions to enable instant UI rendering.

### Warmup Sequence

```
PerpsConnectionManager.connect()
    └── preloadSubscriptions()
            ├── positions.prewarm() → webData3 subscription
            ├── orders.prewarm() → webData3 subscription
            ├── account.prewarm() → webData3 subscription
            ├── marketData.prewarm() → assetCtxs subscription
            ├── oiCaps.prewarm() → OI caps data
            ├── fills.prewarm() → Fill notifications
            └── prices.prewarm() → allMids for all markets (async)
```

### Channels Pre-warmed

| Channel    | Data                     | Subscription       |
| ---------- | ------------------------ | ------------------ |
| positions  | User positions           | webData3           |
| orders     | Open orders              | webData3           |
| account    | Account balance          | webData3           |
| marketData | Funding, OI, mark prices | assetCtxs per DEX  |
| oiCaps     | Open interest caps       | OI caps            |
| fills      | Trade fills              | Fill notifications |
| prices     | All market prices        | allMids per DEX    |

### Benefits

- **Instant data**: UI components receive cached data immediately on mount
- **Single subscription**: Components share pre-warmed subscriptions via reference counting
- **No throttle**: Pre-warm uses throttleMs=0 for fastest cache population

### Cleanup

Pre-warm subscriptions are cleaned up during:

- `disconnect()` - When last provider unmounts
- `reconnectWithNewContext()` - Before reinitializing
- Cache clearing - When account/network changes

### Stream Channel Initialization Guards

Stream channel `connect()` methods include safety guards to prevent subscribing before the controller is ready. Each user-data channel (orders, positions, account, fills, OI caps) checks two conditions before calling the controller's subscribe method:

1. **`isCurrentlyReinitializing()`** — If the controller is mid-reinitialization (e.g., account switch), defer with `ReconnectionCleanupDelayMs` (500ms) timeout
2. **`getConnectionState().isInitialized`** — If the controller hasn't completed `init()` yet, defer with 200ms timeout and retry

This prevents `CLIENT_NOT_INITIALIZED` errors that would occur if channels subscribe before the controller has an active provider. Without this guard, the controller returns a no-op unsubscribe function, the channel stores it as its active subscription, and no WebSocket callback ever fires — causing `isInitialLoading` to stay `true` forever (infinite skeleton).

```
connect() flow:
  ┌─ wsSubscription exists? → return (already connected)
  ├─ isCurrentlyReinitializing? → deferConnect(500ms)
  ├─ !isInitialized? → deferConnect(200ms)
  └─ reset retry counter, proceed with subscription
```

**Safety**: Deferred retries abort if subscribers become empty (component unmounted) or after 150 attempts (~30s). This prevents orphaned retry loops.

**Channels with this guard**: `OrderStreamChannel`, `PositionStreamChannel`, `AccountStreamChannel`, `FillStreamChannel`, `OICapStreamChannel`

---

## Background Data Preloading

The Perps system preloads market data and user data in the background before the user navigates to the Perps tab, enabling instant rendering of market lists, positions, orders, and account state without loading skeletons.

### How It Works

1. **`startMarketDataPreload()`** is started by `PerpsAlwaysOnProvider` at the wallet root to fetch market data via REST and cache it in the controller state
2. **`performUserDataPreload()`** is called after market data preload completes, fetching positions, open orders, and account state via lightweight standalone REST calls
3. **`MarketDataChannel`** in the stream manager reads cached market data on mount, providing instant market data to subscribers
4. **`OrderStreamChannel`**, **`PositionStreamChannel`**, and **`AccountStreamChannel`** read cached user data on `connect()`, providing instant display before WebSocket data arrives
5. **PerpsHomeView renders immediately** — markets, positions, orders, and account state populate from the cache, then update with live WebSocket data

> **Two-tier cache**: Stream channels check their WebSocket cache first (populated by `prewarm()`). If empty, hooks fall back to the controller's preloaded REST cache via `getPreloadedData()`. Data is available on first render even before WebSocket connection is attempted.

### Cached User Data

The controller stores preloaded user data in transient (non-persisted) state fields:

| Field                       | Type                        | Description                                                                                                       |
| --------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `cachedMarketData`          | `PerpsMarketData[] \| null` | Preloaded market data from REST                                                                                   |
| `cachedMarketDataTimestamp` | `number`                    | Timestamp of last market data preload                                                                             |
| `cachedUserDataByProvider`  | `Record<string, {...}>`     | Per-provider cached user data (positions, orders, accountState, timestamp, address) keyed by `providerId:network` |

User data cache is automatically cleared when:

- The selected account changes (all entries cleared since all provider data is stale for new account)

Network and testnet toggle do NOT clear the cache — different network keys prevent collisions.

### Hook Behavior When Not Connected

The stream hooks used by PerpsHomeView gracefully handle the not-yet-connected state:

| Hook                    | Behavior                                 | Cache Mechanism                                        | Impact                                                 |
| ----------------------- | ---------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ |
| `usePerpsLivePositions` | Reads controller cache, then WebSocket   | `getPreloadedData('cachedPositions')` in `useState`    | Positions visible immediately                          |
| `usePerpsLiveOrders`    | Reads controller cache, then WebSocket   | `getPreloadedData('cachedOrders')` in `useState`       | Orders visible immediately                             |
| `usePerpsLiveFills`     | Stays in `isInitialLoading: true`        | None (starts as `[]`)                                  | Skeleton shown until WS data arrives                   |
| `usePerpsLiveAccount`   | Reads controller cache, then WebSocket   | `getPreloadedData('cachedAccountState')` in `useState` | Balance visible immediately                            |
| `usePerpsMarkets`       | Reads controller cache                   | `hasPreloadedData('cachedMarketData')` in `useState`   | Renders immediately with real cached data              |
| `usePerpsPrices`        | Skips subscription when `!isInitialized` | None                                                   | Static cached prices shown, live prices once connected |

> **How this works**: Stream channels defer their WebSocket subscriptions until `PerpsConnectionManager.getConnectionState().isInitialized` is `true`, retrying every 200ms (max 150 attempts). This prevents doomed subscriptions that would permanently block data delivery. See [Stream Channel Initialization Guards](#stream-channel-initialization-guards) above.

---

### Controller Layer: PerpsController (Redux)

**What it is**: Redux controller that manages provider instances and exposes data methods

**Owns**:

- Provider instances (`Map<string, Provider>`)
- Redux state (account state, orders, positions, market data)
- Initialization flags (`isInitialized`, `isReinitializing`)
- Network configuration (testnet vs mainnet)

**Responsibilities**:

- Create and destroy provider instances
- Disconnect old providers and create new ones during reinitialization
- Expose data access methods (`getAccountState`, `placeOrder`, etc.)
- Update Redux state based on provider data
- Handle provider-level errors and log to Sentry

**Does NOT**:

- Handle reconnection orchestration (Manager's job)
- Know about force flags or pending operations
- Manage UI state or React lifecycle
- Implement grace periods or reference counting

**Key Methods**:

- `initializeProviders()` - Disconnect old providers, create new ones
- `disconnect()` - Disconnect provider and reset initialization state
- `getAccountState()`, `placeOrder()`, etc. - Data access methods

---

### Provider Layer: HyperLiquidProvider

**What it is**: Exchange-specific implementation of the provider interface

**Owns**:

- REST API clients (InfoClient for queries, ExchangeClient for trading)
- WebSocket connection for real-time subscriptions
- Exchange-specific API request formatting
- Protocol-specific message handling
- Subscription management

**Responsibilities**:

- Make REST API calls for trading operations (place/cancel/edit orders)
- Make REST API calls for data queries (account state, positions, market info)
- Establish and maintain WebSocket connection for real-time subscriptions
- Provide `ping()` for WebSocket health checks (5s timeout) - used by Manager for connection validation
- Format requests according to exchange protocol
- Parse responses and normalize data
- Handle exchange-specific errors
- Manage subscriptions (prices, order fills, position updates)

**Does NOT**:

- Know about Redux or React
- Handle reconnection logic
- Implement grace periods or timeouts
- Manage multiple provider instances

**Communication Methods**:

- **REST API**: Account queries, order placement/cancellation, balance checks, market data
- **WebSocket**: Real-time price updates, order fill notifications, position changes, health checks

**Key Methods**:

- `connect()` - Initialize REST clients and WebSocket connection
- `disconnect()` - Close WebSocket and cleanup clients
- `ping()` - WebSocket health check to validate connection responsiveness
- `placeOrder()`, `cancelOrder()` - Trading via REST API
- `getAccountState()`, `getPositions()` - Data queries via REST API
- `subscribeToPrices()`, `subscribeToOrders()` - Real-time updates via WebSocket

---

## Design Principles

- **Manager Orchestrates, Controller Provides Primitives**: Manager coordinates "when" and "why" to reconnect; Controller provides "how" to initialize/disconnect providers
- **Provider is Exchange-Agnostic Interface**: Controller doesn't know about HyperLiquid specifics; easy to add new providers
- **UI Layer is Presentation Only**: Provider (React) polls state from Manager (singleton); no business logic in React components
- **Clear Ownership Boundaries**: Each layer owns specific concerns; no cross-layer state management; dependencies flow downward only

---

## Key Methods by Layer

### Manager Layer Methods

| Method                      | Signature                                       | Purpose                                               |
| --------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
| `connect()`                 | `() => Promise<void>`                           | Initialize connection if first provider               |
| `disconnect()`              | `() => Promise<void>`                           | Disconnect if last provider (with grace period)       |
| `ensureConnected()`         | `() => Promise<void>`                           | Foreground return: force disconnect + fresh reconnect |
| `reconnectWithNewContext()` | `(options?: ReconnectOptions) => Promise<void>` | Coordinate full reconnection                          |
| `getConnectionState()`      | `() => ConnectionState`                         | Get current connection state (for polling)            |
| `resetError()`              | `() => void`                                    | Clear error state                                     |

### Controller Layer Methods

| Method                  | Signature                     | Purpose                              |
| ----------------------- | ----------------------------- | ------------------------------------ |
| `initializeProviders()` | `() => Promise<void>`         | Disconnect old, create new providers |
| `disconnect()`          | `() => Promise<void>`         | Disconnect provider, reset flags     |
| `getAccountState()`     | `() => Promise<AccountState>` | Fetch account data                   |
| `placeOrder()`          | `(order) => Promise<void>`    | Submit order                         |

### Provider Layer Methods (React)

| Method                      | Signature                     | Purpose              |
| --------------------------- | ----------------------------- | -------------------- |
| `connect()`                 | `() => Promise<void>`         | Delegates to Manager |
| `disconnect()`              | `() => Promise<void>`         | Delegates to Manager |
| `reconnectWithNewContext()` | `(options?) => Promise<void>` | Delegates to Manager |
| `resetError()`              | `() => void`                  | Delegates to Manager |

---

## ReconnectOptions

```typescript
interface ReconnectOptions {
  force?: boolean; // default: false
}
```

**Only used at Manager layer** - Controls pending operation handling:

- `force: false` (default): Waits for pending operations → safe for automatic reconnects
- `force: true`: Cancels pending operations AND clears connection timeout timer → user-initiated retry

**Why Controller doesn't need it**: Manager calls `initializeProviders()` directly which always does full reinitialization with provider disconnect/recreate.

**Additional Effects of force: true**:

- Cancels grace period immediately
- Clears connection timeout timer
- Clears all pending promises: `initPromise`, `pendingReconnectPromise`

---

## Flow Scenarios

### User Retry (force: true)

**Why each layer is involved**:

- **UI**: User clicks retry button → Provider.reconnectWithNewContext({ force: true })
- **Provider (React)**: Delegates to Manager singleton
- **Manager**: Cancels pending promises, clears caches, calls Controller.initializeProviders()
- **Controller**: Disconnects old provider, creates new one
- **Provider (Exchange)**: Closes WebSocket, establishes new connection

```mermaid
sequenceDiagram
    participant UI as UI Component
    participant RP as React Provider
    participant M as Manager
    participant C as Controller
    participant P as Exchange Provider
    participant W as WebSocket

    UI->>RP: onClick retry
    RP->>M: reconnectWithNewContext({force: true})
    M->>M: Cancel pending promises
    M->>M: Clear stream caches
    M->>C: initializeProviders()
    C->>P: disconnect()
    P->>W: close()
    C->>C: Create new provider
    C->>P: (implicit connect via getAccountState)
    P->>W: establish connection
    W-->>UI: Connected
```

### Account Switch (force: false)

**Why each layer is involved**:

- **UI**: Account changed → Lifecycle hook calls reconnect
- **Provider (React)**: Delegates to Manager
- **Manager**: Waits for pending operations, then reinitializes
- **Controller**: Disconnects old provider (old account), creates new one
- **Provider (Exchange)**: Closes WebSocket, establishes new connection with new account context

```mermaid
sequenceDiagram
    participant UI as UI Component
    participant RP as React Provider
    participant M as Manager
    participant C as Controller
    participant P as Exchange Provider

    UI->>RP: Account changed
    RP->>M: reconnectWithNewContext()
    M->>M: Wait for pending operations
    M->>M: Clear stream caches
    M->>C: initializeProviders()
    C->>P: disconnect old provider
    C->>C: create new provider
    P-->>UI: Connected with new account
```

---

## Race Condition Guards

Each layer protects against its own concurrency concerns:

| Guard                     | Location   | Purpose                           | Why This Layer                     |
| ------------------------- | ---------- | --------------------------------- | ---------------------------------- |
| `initPromise`             | Manager    | Prevents concurrent connect()     | Manager owns connection lifecycle  |
| `pendingReconnectPromise` | Manager    | Prevents concurrent reconnects    | Manager coordinates reconnection   |
| `isReinitializing`        | Controller | Prevents concurrent provider init | Controller owns provider instances |

### Rapid Account Switch Protection

**Scenario**: User rapidly switches Account A → B → C

The Manager's `pendingReconnectPromise` ensures only one reconnection happens at a time:

1. **A → B**: Triggers `reconnectWithNewContext()` → creates `pendingReconnectPromise`
2. **B → C** (during B reconnection): Calls `reconnectWithNewContext()` again
3. **Manager**: Returns existing `pendingReconnectPromise` (doesn't start new reconnection)
4. **When B completes**: Promise is cleared, state is updated
5. **C change detected**: New reconnection starts with fresh promise

**Result**: The final account (C) will be correctly connected because:

- Each reconnection fetches account address fresh at execution time
- Redux subscription in Manager detects every account change and queues reconnection
- `pendingReconnectPromise` serializes reconnections to prevent race conditions
- The last account change always triggers a reconnection after previous one completes
- Stream caches are cleared immediately on account change to prevent old data from showing

---

## When to Use What

| Scenario          | Method                      | Options           | Which Layer Decides                        | Notes                                         |
| ----------------- | --------------------------- | ----------------- | ------------------------------------------ | --------------------------------------------- |
| Initial load      | `connect()`                 | -                 | Manager (via Provider hook)                | Uses WebSocket ping for validation            |
| User retry button | `reconnectWithNewContext()` | `{ force: true }` | UI → Provider → Manager                    | Cancels pending operations + timeout timer    |
| Account switch    | `reconnectWithNewContext()` | default           | Manager (automatic via Redux subscription) | Clears caches immediately before reconnection |
| Network switch    | `reconnectWithNewContext()` | default           | Manager (automatic via Redux subscription) | Same as account switch                        |
| App background    | `disconnect()`              | -                 | PerpsAlwaysOnProvider → Manager            | Grace period (20s) before actual disconnect   |
| App foreground    | `ensureConnected()`         | -                 | PerpsAlwaysOnProvider → Manager            | Forces disconnect + reconnect after delay     |

---

## Error Handling by Layer

Each layer handles errors at its own level:

1. **Provider Layer (Exchange)**:
   - Catches WebSocket errors, logs to Sentry
   - Returns error state to Controller

2. **Controller Layer**:
   - Catches provider errors, logs to Sentry
   - Updates Redux error state
   - Throws to Manager

3. **Manager Layer**:
   - Catches Controller errors, logs to DevLogger
   - Sets local error state
   - Does NOT throw (prevents crash)

4. **Provider Layer (React)**:
   - Catches Manager errors, logs to Sentry
   - Polls error state from Manager
   - Decides UI presentation (error screen vs retry button)

**All layers update state regardless of error to keep UI in sync.**
