Modeling Frontend State as a Distributed System
Modeling Frontend State as a Distributed System
When Your Browser Becomes a Node in a Distributed System
Modern frontend applications maintain local state that must stay synchronized with server state, handle concurrent updates from multiple sources, and provide meaningful user experiences during network partitions. These are the same problems that distributed systems have grappled with for decades.
This article applies distributed systems thinking to frontend state management—examining consistency models, conflict resolution strategies, and reconciliation patterns that enable robust applications in an inherently unreliable environment.
The Frontend Distributed System Model
┌─────────────────────────────────────────────────────────────────────────────┐
│ Frontend as a Distributed System Node │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Source of Truth Hierarchy │
│ ───────────────────────── │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ DATABASE │ │
│ │ (Authoritative Truth) │ │
│ │ │ │
│ │ - ACID transactions │ │
│ │ - Schema enforcement │ │
│ │ - Durability guarantees │ │
│ └──────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ │ Replication │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ SERVER CACHE │ │
│ │ (Redis, Memcached) │ │
│ │ │ │
│ │ - Derived from DB │ │
│ │ - TTL-based invalidation │ │
│ │ - Eventually consistent with DB │ │
│ └──────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ │ HTTP/WebSocket │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ CLIENT CACHE │ │
│ │ (React Query, Apollo, IndexedDB) │ │
│ │ │ │
│ │ - Local replica of server state │ │
│ │ - Optimistic updates │ │
│ │ - May diverge during partition │ │
│ └──────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ │ Subscription │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ COMPONENT STATE │ │
│ │ (React useState, stores) │ │
│ │ │ │
│ │ - Derived from client cache │ │
│ │ - UI-specific transformations │ │
│ │ - Ephemeral (lost on unmount) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Consistency Models for Frontend State
Understanding Consistency Levels
┌─────────────────────────────────────────────────────────────────────────────┐
│ Frontend Consistency Spectrum │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Strong Eventual │
│ Consistency Consistency │
│ ◄─────────────────────────────────────────────────────────────────────► │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │
│ │ Lineariza- │ │ Causal │ │ Session │ │ Eventual │ │
│ │ ble │ │Consistency │ │Consistency │ │ Consistency │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────────────┘ │
│ │
│ Every read Respects Your writes Eventually all │
│ sees latest cause-effect visible to replicas converge │
│ write ordering your reads │
│ │
│ Use for: Use for: Use for: Use for: │
│ - Payments - Comments - User prefs - Analytics │
│ - Inventory - Chat - Cart - Feed items │
│ - Auth - Collab - Form state - Recommendations │
│ │
│ Cost: High Cost: Medium Cost: Low Cost: Minimal │
│ latency, complexity, simple impl, may show stale │
│ complexity some latency good UX data temporarily │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Implementing Session Consistency
// src/state/session-consistency.ts
interface VersionedState<T> {
data: T;
version: number;
timestamp: number;
source: 'local' | 'server' | 'optimistic';
}
interface SessionConsistencyConfig {
// Minimum version we'll accept from server
minimumAcceptableVersion: number;
// How long to wait for server confirmation
confirmationTimeout: number;
// Whether to reject stale server responses
rejectStaleResponses: boolean;
}
class SessionConsistentStore<T> {
private state: VersionedState<T>;
private pendingWrites: Map<string, {
version: number;
data: Partial<T>;
timestamp: number;
}> = new Map();
private subscribers: Set<(state: T) => void> = new Set();
constructor(
initialState: T,
private config: SessionConsistencyConfig
) {
this.state = {
data: initialState,
version: 0,
timestamp: Date.now(),
source: 'local',
};
}
// Read with session guarantee
read(): T {
// Always return the latest state we've seen
// (either our writes or confirmed server state)
return this.state.data;
}
// Write with version tracking
async write(
writeId: string,
updater: (current: T) => T
): Promise<{ success: boolean; version: number }> {
const newVersion = this.state.version + 1;
const newData = updater(this.state.data);
// Optimistic update
this.state = {
data: newData,
version: newVersion,
timestamp: Date.now(),
source: 'optimistic',
};
// Track pending write
this.pendingWrites.set(writeId, {
version: newVersion,
data: newData as Partial<T>,
timestamp: Date.now(),
});
this.notify();
// Send to server with our version
try {
const result = await this.sendToServer(writeId, newData, newVersion);
if (result.success) {
// Server confirmed, update to server version
this.state = {
data: result.data,
version: result.version,
timestamp: Date.now(),
source: 'server',
};
this.pendingWrites.delete(writeId);
this.notify();
return { success: true, version: result.version };
} else {
// Server rejected, handle conflict
return this.handleWriteRejection(writeId, result);
}
} catch (error) {
// Network error, keep optimistic state
// Will reconcile when connection restored
return { success: false, version: newVersion };
}
}
// Receive server update
receiveServerUpdate(serverState: VersionedState<T>): void {
// Session consistency check: reject if server version
// is older than our minimum acceptable version
if (serverState.version < this.config.minimumAcceptableVersion) {
if (this.config.rejectStaleResponses) {
console.warn('Rejecting stale server response', {
serverVersion: serverState.version,
minimumAcceptable: this.config.minimumAcceptableVersion,
});
return;
}
}
// Check if we have pending writes that should take precedence
const hasPendingWrites = this.pendingWrites.size > 0;
const oldestPendingVersion = hasPendingWrites
? Math.min(...Array.from(this.pendingWrites.values()).map(w => w.version))
: Infinity;
if (serverState.version >= oldestPendingVersion) {
// Server has seen our writes, safe to apply
this.state = serverState;
this.clearConfirmedPendingWrites(serverState.version);
this.notify();
} else if (!hasPendingWrites) {
// No pending writes, accept server state
this.state = serverState;
this.notify();
}
// Otherwise, keep our optimistic state until server catches up
}
private clearConfirmedPendingWrites(confirmedVersion: number): void {
for (const [id, write] of this.pendingWrites) {
if (write.version <= confirmedVersion) {
this.pendingWrites.delete(id);
}
}
}
private async sendToServer(
writeId: string,
data: T,
version: number
): Promise<{
success: boolean;
data: T;
version: number;
conflict?: boolean;
}> {
const response = await fetch('/api/state', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Client-Version': version.toString(),
'X-Write-Id': writeId,
},
body: JSON.stringify(data),
});
const result = await response.json();
if (response.status === 409) {
return { success: false, conflict: true, ...result };
}
return { success: response.ok, ...result };
}
private handleWriteRejection(
writeId: string,
serverResult: { data: T; version: number }
): { success: boolean; version: number } {
const pendingWrite = this.pendingWrites.get(writeId);
if (pendingWrite) {
// Rebase our write on top of server state
// This is application-specific merge logic
this.state = {
data: serverResult.data,
version: serverResult.version,
timestamp: Date.now(),
source: 'server',
};
this.pendingWrites.delete(writeId);
this.notify();
}
return { success: false, version: serverResult.version };
}
subscribe(callback: (state: T) => void): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
private notify(): void {
for (const subscriber of this.subscribers) {
subscriber(this.state.data);
}
}
// Get consistency status
getConsistencyStatus(): {
isConsistent: boolean;
pendingWriteCount: number;
currentVersion: number;
source: string;
} {
return {
isConsistent: this.pendingWrites.size === 0 && this.state.source === 'server',
pendingWriteCount: this.pendingWrites.size,
currentVersion: this.state.version,
source: this.state.source,
};
}
}
export { SessionConsistentStore, VersionedState };
Conflict Detection and Resolution
Vector Clock Implementation
// src/state/vector-clock.ts
type NodeId = string;
type VectorClock = Map<NodeId, number>;
// Create a new vector clock
function createClock(nodeId: NodeId): VectorClock {
const clock = new Map<NodeId, number>();
clock.set(nodeId, 0);
return clock;
}
// Increment the clock for a node
function increment(clock: VectorClock, nodeId: NodeId): VectorClock {
const newClock = new Map(clock);
newClock.set(nodeId, (newClock.get(nodeId) || 0) + 1);
return newClock;
}
// Merge two clocks (take max of each component)
function merge(a: VectorClock, b: VectorClock): VectorClock {
const merged = new Map(a);
for (const [node, time] of b) {
merged.set(node, Math.max(merged.get(node) || 0, time));
}
return merged;
}
// Compare two clocks
type ClockComparison = 'equal' | 'before' | 'after' | 'concurrent';
function compare(a: VectorClock, b: VectorClock): ClockComparison {
let aGreater = false;
let bGreater = false;
const allNodes = new Set([...a.keys(), ...b.keys()]);
for (const node of allNodes) {
const aTime = a.get(node) || 0;
const bTime = b.get(node) || 0;
if (aTime > bTime) aGreater = true;
if (bTime > aTime) bGreater = true;
}
if (!aGreater && !bGreater) return 'equal';
if (aGreater && !bGreater) return 'after';
if (!aGreater && bGreater) return 'before';
return 'concurrent';
}
// Check if a happens-before b
function happensBefore(a: VectorClock, b: VectorClock): boolean {
return compare(a, b) === 'before';
}
// Serialize/deserialize for transmission
function serialize(clock: VectorClock): string {
return JSON.stringify(Array.from(clock.entries()));
}
function deserialize(str: string): VectorClock {
return new Map(JSON.parse(str));
}
export {
VectorClock,
NodeId,
createClock,
increment,
merge,
compare,
happensBefore,
serialize,
deserialize,
};
Conflict Resolution Strategies
// src/state/conflict-resolution.ts
import { VectorClock, compare } from './vector-clock';
interface ConflictingVersions<T> {
local: { data: T; clock: VectorClock; timestamp: number };
remote: { data: T; clock: VectorClock; timestamp: number };
}
type MergeFunction<T> = (local: T, remote: T, base?: T) => T;
type ConflictStrategy = 'local-wins' | 'remote-wins' | 'lww' | 'merge' | 'manual';
interface ConflictResolutionConfig<T> {
strategy: ConflictStrategy;
merge?: MergeFunction<T>;
onManualResolution?: (conflict: ConflictingVersions<T>) => Promise<T>;
}
class ConflictResolver<T> {
constructor(private config: ConflictResolutionConfig<T>) {}
async resolve(conflict: ConflictingVersions<T>, base?: T): Promise<T> {
const { local, remote } = conflict;
const clockComparison = compare(local.clock, remote.clock);
// No conflict if one clearly happened before the other
if (clockComparison === 'before') {
return remote.data;
}
if (clockComparison === 'after') {
return local.data;
}
if (clockComparison === 'equal') {
return local.data; // Identical, doesn't matter
}
// Concurrent updates - apply resolution strategy
switch (this.config.strategy) {
case 'local-wins':
return local.data;
case 'remote-wins':
return remote.data;
case 'lww':
// Last-Write-Wins based on timestamp
return local.timestamp > remote.timestamp ? local.data : remote.data;
case 'merge':
if (!this.config.merge) {
throw new Error('Merge strategy requires merge function');
}
return this.config.merge(local.data, remote.data, base);
case 'manual':
if (!this.config.onManualResolution) {
throw new Error('Manual strategy requires onManualResolution handler');
}
return this.config.onManualResolution(conflict);
default:
throw new Error(`Unknown strategy: ${this.config.strategy}`);
}
}
}
// Common merge functions
// Object field-level merge
function fieldMerge<T extends Record<string, unknown>>(
local: T,
remote: T,
base?: T
): T {
const result = { ...remote }; // Start with remote
for (const key of Object.keys(local) as (keyof T)[]) {
const localValue = local[key];
const remoteValue = remote[key];
const baseValue = base?.[key];
// If local changed from base but remote didn't, use local
if (baseValue !== undefined) {
const localChanged = localValue !== baseValue;
const remoteChanged = remoteValue !== baseValue;
if (localChanged && !remoteChanged) {
result[key] = localValue;
} else if (!localChanged && remoteChanged) {
result[key] = remoteValue;
} else if (localChanged && remoteChanged) {
// Both changed - LWW or keep remote
result[key] = remoteValue; // Default to remote for conflicts
}
} else {
// No base, use remote
result[key] = remoteValue;
}
}
return result as T;
}
// Array merge (append both, dedupe)
function arrayMerge<T extends { id: string }>(
local: T[],
remote: T[],
base?: T[]
): T[] {
const resultMap = new Map<string, T>();
// Add base items
for (const item of base || []) {
resultMap.set(item.id, item);
}
// Apply remote changes
for (const item of remote) {
resultMap.set(item.id, item);
}
// Apply local changes (local wins on conflict)
for (const item of local) {
const existing = resultMap.get(item.id);
const baseItem = base?.find(b => b.id === item.id);
// If item changed locally from base, use local
if (!baseItem || JSON.stringify(item) !== JSON.stringify(baseItem)) {
resultMap.set(item.id, item);
}
}
return Array.from(resultMap.values());
}
// Text merge using operational transform (simplified)
function textMerge(local: string, remote: string, base?: string): string {
if (!base) {
// No base, concatenate with separator
return local.length > remote.length ? local : remote;
}
// Simple line-based merge
const baseLines = base.split('\n');
const localLines = local.split('\n');
const remoteLines = remote.split('\n');
const result: string[] = [];
const maxLength = Math.max(baseLines.length, localLines.length, remoteLines.length);
for (let i = 0; i < maxLength; i++) {
const baseLine = baseLines[i] || '';
const localLine = localLines[i] || '';
const remoteLine = remoteLines[i] || '';
if (localLine === baseLine) {
result.push(remoteLine);
} else if (remoteLine === baseLine) {
result.push(localLine);
} else {
// Both changed, mark conflict
result.push(`<<<<<<< LOCAL\n${localLine}\n=======\n${remoteLine}\n>>>>>>> REMOTE`);
}
}
return result.join('\n');
}
export {
ConflictResolver,
ConflictingVersions,
ConflictStrategy,
MergeFunction,
fieldMerge,
arrayMerge,
textMerge,
};
Eventual Consistency with Reconciliation
Reconciliation Engine
// src/state/reconciliation.ts
import { VectorClock, merge as mergeClock, compare } from './vector-clock';
import { ConflictResolver, ConflictingVersions } from './conflict-resolution';
interface ReconciledState<T> {
data: T;
clock: VectorClock;
version: number;
lastReconciled: number;
}
interface ReconciliationEvent<T> {
type: 'update' | 'conflict' | 'resolved';
localVersion: number;
remoteVersion: number;
data: T;
}
type ReconciliationListener<T> = (event: ReconciliationEvent<T>) => void;
class ReconciliationEngine<T> {
private state: ReconciledState<T>;
private pendingOps: Array<{
id: string;
op: (data: T) => T;
clock: VectorClock;
timestamp: number;
}> = [];
private listeners: Set<ReconciliationListener<T>> = new Set();
constructor(
private nodeId: string,
initialState: T,
private resolver: ConflictResolver<T>
) {
this.state = {
data: initialState,
clock: new Map([[nodeId, 0]]),
version: 0,
lastReconciled: Date.now(),
};
}
// Apply local operation
applyLocal(opId: string, operation: (data: T) => T): T {
// Increment local clock
const newClock = new Map(this.state.clock);
newClock.set(this.nodeId, (newClock.get(this.nodeId) || 0) + 1);
// Apply operation
const newData = operation(this.state.data);
// Track pending operation
this.pendingOps.push({
id: opId,
op: operation,
clock: newClock,
timestamp: Date.now(),
});
// Update state
this.state = {
data: newData,
clock: newClock,
version: this.state.version + 1,
lastReconciled: this.state.lastReconciled,
};
return newData;
}
// Receive remote state
async receiveRemote(
remoteData: T,
remoteClock: VectorClock,
remoteVersion: number
): Promise<T> {
const comparison = compare(this.state.clock, remoteClock);
if (comparison === 'before') {
// Remote is ahead, accept it
this.state = {
data: remoteData,
clock: remoteClock,
version: remoteVersion,
lastReconciled: Date.now(),
};
this.clearAcknowledgedOps(remoteClock);
this.emit({ type: 'update', localVersion: this.state.version, remoteVersion, data: remoteData });
return remoteData;
}
if (comparison === 'after') {
// We're ahead, ignore (our state will be pushed to server)
return this.state.data;
}
if (comparison === 'concurrent') {
// Conflict! Need resolution
const conflict: ConflictingVersions<T> = {
local: {
data: this.state.data,
clock: this.state.clock,
timestamp: Date.now(),
},
remote: {
data: remoteData,
clock: remoteClock,
timestamp: Date.now(),
},
};
this.emit({ type: 'conflict', localVersion: this.state.version, remoteVersion, data: remoteData });
const resolvedData = await this.resolver.resolve(conflict);
// Merge clocks
const mergedClock = mergeClock(this.state.clock, remoteClock);
// Replay pending ops on resolved state
let finalData = resolvedData;
for (const op of this.pendingOps) {
if (compare(op.clock, remoteClock) === 'after') {
// This op happened after remote, replay it
finalData = op.op(finalData);
}
}
this.state = {
data: finalData,
clock: mergedClock,
version: Math.max(this.state.version, remoteVersion) + 1,
lastReconciled: Date.now(),
};
this.emit({ type: 'resolved', localVersion: this.state.version, remoteVersion, data: finalData });
return finalData;
}
// Equal clocks, states should be identical
return this.state.data;
}
// Mark operations as acknowledged by server
acknowledgeOps(ackedOpIds: string[]): void {
this.pendingOps = this.pendingOps.filter(
op => !ackedOpIds.includes(op.id)
);
}
private clearAcknowledgedOps(serverClock: VectorClock): void {
this.pendingOps = this.pendingOps.filter(op => {
// Keep ops that happened after server's knowledge
return compare(op.clock, serverClock) === 'after';
});
}
// Get operations to send to server
getPendingOps(): Array<{
id: string;
clock: VectorClock;
timestamp: number;
}> {
return this.pendingOps.map(({ id, clock, timestamp }) => ({
id,
clock,
timestamp,
}));
}
getState(): ReconciledState<T> {
return { ...this.state };
}
subscribe(listener: ReconciliationListener<T>): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private emit(event: ReconciliationEvent<T>): void {
for (const listener of this.listeners) {
listener(event);
}
}
}
export { ReconciliationEngine, ReconciledState, ReconciliationEvent };
Replay and Event Sourcing
Client-Side Event Log
// src/state/event-sourcing.ts
interface DomainEvent<T = unknown> {
id: string;
type: string;
payload: T;
timestamp: number;
version: number;
causedBy?: string; // ID of event that caused this
correlationId: string; // Groups related events
}
interface EventStore<TState, TEvent extends DomainEvent> {
append(event: TEvent): Promise<void>;
getEvents(fromVersion?: number): Promise<TEvent[]>;
getState(): TState;
replay(events: TEvent[]): TState;
}
type EventHandler<TState, TEvent extends DomainEvent> = (
state: TState,
event: TEvent
) => TState;
class ClientEventStore<TState, TEvent extends DomainEvent>
implements EventStore<TState, TEvent>
{
private events: TEvent[] = [];
private state: TState;
private version = 0;
private snapshots: Map<number, TState> = new Map();
private snapshotInterval = 100;
constructor(
private initialState: TState,
private reducer: EventHandler<TState, TEvent>,
private persistence?: {
save: (events: TEvent[]) => Promise<void>;
load: () => Promise<TEvent[]>;
}
) {
this.state = initialState;
this.loadPersistedEvents();
}
private async loadPersistedEvents(): Promise<void> {
if (this.persistence) {
const events = await this.persistence.load();
this.replay(events);
}
}
async append(event: TEvent): Promise<void> {
// Assign version
this.version++;
const versionedEvent = {
...event,
version: this.version,
};
// Apply to state
this.state = this.reducer(this.state, versionedEvent);
// Store event
this.events.push(versionedEvent);
// Create snapshot periodically
if (this.version % this.snapshotInterval === 0) {
this.snapshots.set(this.version, { ...this.state });
}
// Persist
if (this.persistence) {
await this.persistence.save([versionedEvent]);
}
}
async getEvents(fromVersion = 0): Promise<TEvent[]> {
return this.events.filter(e => e.version > fromVersion);
}
getState(): TState {
return this.state;
}
replay(events: TEvent[]): TState {
// Find nearest snapshot
const eventVersions = events.map(e => e.version);
const minVersion = Math.min(...eventVersions);
let startState = this.initialState;
let startVersion = 0;
for (const [version, snapshot] of this.snapshots) {
if (version < minVersion && version > startVersion) {
startState = snapshot;
startVersion = version;
}
}
// Replay events from snapshot
let state = startState;
const sortedEvents = [...events].sort((a, b) => a.version - b.version);
for (const event of sortedEvents) {
if (event.version > startVersion) {
state = this.reducer(state, event);
}
}
this.state = state;
this.events = sortedEvents;
this.version = Math.max(...eventVersions, this.version);
return state;
}
// Compact events (aggregate into snapshot)
compact(upToVersion: number): void {
const snapshot = this.computeStateAtVersion(upToVersion);
this.snapshots.set(upToVersion, snapshot);
// Remove old events
this.events = this.events.filter(e => e.version > upToVersion);
// Remove old snapshots
for (const version of this.snapshots.keys()) {
if (version < upToVersion) {
this.snapshots.delete(version);
}
}
}
private computeStateAtVersion(version: number): TState {
let state = this.initialState;
for (const event of this.events) {
if (event.version <= version) {
state = this.reducer(state, event);
}
}
return state;
}
// Time travel debugging
getStateAtVersion(version: number): TState {
return this.computeStateAtVersion(version);
}
// Get causal chain for debugging
getCausalChain(eventId: string): TEvent[] {
const chain: TEvent[] = [];
const event = this.events.find(e => e.id === eventId);
if (!event) return chain;
chain.push(event);
// Walk backwards through causation
let current = event;
while (current.causedBy) {
const cause = this.events.find(e => e.id === current.causedBy);
if (cause) {
chain.unshift(cause);
current = cause;
} else {
break;
}
}
return chain;
}
}
// Example usage with todo app
interface Todo {
id: string;
text: string;
completed: boolean;
}
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
type TodoEvent =
| DomainEvent<{ id: string; text: string }> & { type: 'TODO_ADDED' }
| DomainEvent<{ id: string }> & { type: 'TODO_COMPLETED' }
| DomainEvent<{ id: string }> & { type: 'TODO_DELETED' }
| DomainEvent<{ filter: TodoState['filter'] }> & { type: 'FILTER_CHANGED' };
const todoReducer: EventHandler<TodoState, TodoEvent> = (state, event) => {
switch (event.type) {
case 'TODO_ADDED':
return {
...state,
todos: [
...state.todos,
{ id: event.payload.id, text: event.payload.text, completed: false },
],
};
case 'TODO_COMPLETED':
return {
...state,
todos: state.todos.map(todo =>
todo.id === event.payload.id
? { ...todo, completed: true }
: todo
),
};
case 'TODO_DELETED':
return {
...state,
todos: state.todos.filter(todo => todo.id !== event.payload.id),
};
case 'FILTER_CHANGED':
return {
...state,
filter: event.payload.filter,
};
default:
return state;
}
};
export { ClientEventStore, DomainEvent, EventHandler, EventStore };
Network Partition Handling
Partition-Aware State Management
// src/state/partition-handling.ts
type PartitionState = 'connected' | 'degraded' | 'partitioned';
interface PartitionConfig {
healthCheckInterval: number;
healthCheckTimeout: number;
degradedThreshold: number; // Latency ms to consider degraded
partitionThreshold: number; // Failed checks before partition
}
interface PartitionAwareState<T> {
data: T;
partitionState: PartitionState;
lastSync: number;
pendingMutations: number;
staleness: 'fresh' | 'stale' | 'very-stale';
}
class PartitionAwareStore<T> {
private state: T;
private partitionState: PartitionState = 'connected';
private lastSuccessfulSync = Date.now();
private failedHealthChecks = 0;
private pendingMutations: Array<{
id: string;
mutation: (data: T) => T;
timestamp: number;
}> = [];
private healthCheckTimer: number | null = null;
constructor(
initialState: T,
private config: PartitionConfig,
private syncFn: (data: T) => Promise<T>
) {
this.state = initialState;
this.startHealthChecks();
this.setupConnectivityListeners();
}
private setupConnectivityListeners(): void {
window.addEventListener('online', () => {
this.failedHealthChecks = 0;
this.attemptRecovery();
});
window.addEventListener('offline', () => {
this.setPartitionState('partitioned');
});
}
private startHealthChecks(): void {
this.healthCheckTimer = window.setInterval(
() => this.performHealthCheck(),
this.config.healthCheckInterval
);
}
private async performHealthCheck(): Promise<void> {
if (!navigator.onLine) {
this.setPartitionState('partitioned');
return;
}
const start = Date.now();
try {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
this.config.healthCheckTimeout
);
await fetch('/api/health', { signal: controller.signal });
clearTimeout(timeout);
const latency = Date.now() - start;
if (latency > this.config.degradedThreshold) {
this.setPartitionState('degraded');
} else {
this.failedHealthChecks = 0;
this.setPartitionState('connected');
}
} catch (error) {
this.failedHealthChecks++;
if (this.failedHealthChecks >= this.config.partitionThreshold) {
this.setPartitionState('partitioned');
} else {
this.setPartitionState('degraded');
}
}
}
private setPartitionState(state: PartitionState): void {
if (this.partitionState !== state) {
console.log(`Partition state: ${this.partitionState} → ${state}`);
this.partitionState = state;
if (state === 'connected' && this.pendingMutations.length > 0) {
this.flushPendingMutations();
}
}
}
// Mutation with partition awareness
async mutate(
mutationId: string,
mutation: (data: T) => T
): Promise<{ success: boolean; synced: boolean }> {
// Always apply optimistically
this.state = mutation(this.state);
if (this.partitionState === 'partitioned') {
// Queue for later sync
this.pendingMutations.push({
id: mutationId,
mutation,
timestamp: Date.now(),
});
return { success: true, synced: false };
}
if (this.partitionState === 'degraded') {
// Try to sync but don't block
this.pendingMutations.push({
id: mutationId,
mutation,
timestamp: Date.now(),
});
this.attemptSyncInBackground();
return { success: true, synced: false };
}
// Connected - sync immediately
try {
const syncedState = await this.syncFn(this.state);
this.state = syncedState;
this.lastSuccessfulSync = Date.now();
return { success: true, synced: true };
} catch (error) {
// Failed to sync, queue it
this.pendingMutations.push({
id: mutationId,
mutation,
timestamp: Date.now(),
});
this.setPartitionState('degraded');
return { success: true, synced: false };
}
}
private async attemptSyncInBackground(): Promise<void> {
try {
const syncedState = await this.syncFn(this.state);
this.state = syncedState;
this.lastSuccessfulSync = Date.now();
this.pendingMutations = [];
} catch (error) {
// Will retry on next mutation or health check
}
}
private async flushPendingMutations(): Promise<void> {
if (this.pendingMutations.length === 0) return;
try {
const syncedState = await this.syncFn(this.state);
this.state = syncedState;
this.lastSuccessfulSync = Date.now();
this.pendingMutations = [];
} catch (error) {
console.error('Failed to flush pending mutations:', error);
}
}
private async attemptRecovery(): Promise<void> {
await this.performHealthCheck();
if (this.partitionState === 'connected') {
await this.flushPendingMutations();
}
}
getState(): PartitionAwareState<T> {
const timeSinceSync = Date.now() - this.lastSuccessfulSync;
let staleness: 'fresh' | 'stale' | 'very-stale';
if (timeSinceSync < 60000) {
staleness = 'fresh';
} else if (timeSinceSync < 300000) {
staleness = 'stale';
} else {
staleness = 'very-stale';
}
return {
data: this.state,
partitionState: this.partitionState,
lastSync: this.lastSuccessfulSync,
pendingMutations: this.pendingMutations.length,
staleness,
};
}
destroy(): void {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
}
}
export { PartitionAwareStore, PartitionState, PartitionAwareState };
React Integration
Distributed State Hooks
// src/state/hooks.ts
import { useState, useEffect, useCallback, useMemo, useSyncExternalStore } from 'react';
import { SessionConsistentStore } from './session-consistency';
import { ReconciliationEngine } from './reconciliation';
import { PartitionAwareStore, PartitionAwareState } from './partition-handling';
// Hook for session-consistent state
function useSessionConsistentState<T>(
store: SessionConsistentStore<T>
) {
const [state, setState] = useState(store.read());
const [status, setStatus] = useState(store.getConsistencyStatus());
useEffect(() => {
return store.subscribe((newState) => {
setState(newState);
setStatus(store.getConsistencyStatus());
});
}, [store]);
const write = useCallback(async (
writeId: string,
updater: (current: T) => T
) => {
return store.write(writeId, updater);
}, [store]);
return {
state,
write,
isConsistent: status.isConsistent,
pendingWriteCount: status.pendingWriteCount,
currentVersion: status.currentVersion,
};
}
// Hook for partition-aware state
function usePartitionAwareState<T>(
store: PartitionAwareStore<T>
): PartitionAwareState<T> & {
mutate: (id: string, mutation: (data: T) => T) => Promise<{ success: boolean; synced: boolean }>;
} {
const [state, setState] = useState(store.getState());
useEffect(() => {
const interval = setInterval(() => {
setState(store.getState());
}, 1000);
return () => clearInterval(interval);
}, [store]);
const mutate = useCallback(async (
id: string,
mutation: (data: T) => T
) => {
const result = await store.mutate(id, mutation);
setState(store.getState());
return result;
}, [store]);
return {
...state,
mutate,
};
}
// Staleness indicator component
function StalenessIndicator({ staleness }: { staleness: 'fresh' | 'stale' | 'very-stale' }) {
if (staleness === 'fresh') return null;
return (
<div className={`staleness-indicator staleness-${staleness}`}>
{staleness === 'stale' && 'Data may be outdated'}
{staleness === 'very-stale' && 'Data is significantly outdated - some information may have changed'}
</div>
);
}
// Sync status component
function SyncStatus({
partitionState,
pendingMutations,
lastSync,
}: {
partitionState: string;
pendingMutations: number;
lastSync: number;
}) {
const formatLastSync = () => {
const seconds = Math.floor((Date.now() - lastSync) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
return `${Math.floor(seconds / 3600)}h ago`;
};
return (
<div className="sync-status">
<span className={`partition-indicator partition-${partitionState}`}>
{partitionState === 'connected' && '🟢 Connected'}
{partitionState === 'degraded' && '🟡 Slow connection'}
{partitionState === 'partitioned' && '🔴 Offline'}
</span>
{pendingMutations > 0 && (
<span className="pending-mutations">
{pendingMutations} change{pendingMutations > 1 ? 's' : ''} pending
</span>
)}
<span className="last-sync">
Last synced: {formatLastSync()}
</span>
</div>
);
}
// Conflict resolution dialog
function ConflictResolutionDialog<T>({
conflict,
onResolve,
renderDiff,
}: {
conflict: { local: T; remote: T };
onResolve: (resolution: 'local' | 'remote' | 'merge') => void;
renderDiff: (local: T, remote: T) => React.ReactNode;
}) {
return (
<div className="conflict-dialog" role="dialog" aria-modal="true">
<h2>Sync Conflict</h2>
<p>
Your changes conflict with changes made elsewhere.
Choose which version to keep.
</p>
<div className="conflict-diff">
{renderDiff(conflict.local, conflict.remote)}
</div>
<div className="conflict-actions">
<button onClick={() => onResolve('local')}>
Keep My Changes
</button>
<button onClick={() => onResolve('remote')}>
Use Remote Version
</button>
<button onClick={() => onResolve('merge')}>
Merge Both
</button>
</div>
</div>
);
}
export {
useSessionConsistentState,
usePartitionAwareState,
StalenessIndicator,
SyncStatus,
ConflictResolutionDialog,
};
Key Takeaways
-
Browser is a distributed system node: Treat client state as a replica that may diverge from server truth
-
Choose consistency level per use case: Financial transactions need strong consistency; feed items can be eventually consistent
-
Session consistency often suffices: Users seeing their own writes is usually enough for good UX
-
Vector clocks detect true conflicts: Timestamps alone can't distinguish concurrent updates from sequential ones
-
Conflict resolution must be explicit: Define merge strategies upfront—LWW, field-merge, or manual resolution
-
Optimistic updates require rollback: Any local change might be rejected by the server
-
Event sourcing enables replay: Storing events instead of state allows recovery and debugging
-
Network partitions are inevitable: Design for offline-first, sync when possible
-
Staleness indicators build trust: Users should know when data might be outdated
-
Pending mutation counts matter: Show users how much work is waiting to sync
Frontend state management is distributed systems in miniature—the same principles apply, just with different trade-offs.
What did you think?