Engineering

Real-Time Pharmacy Inventory Systems with WebSocket Architecture

Apr 28, 2026 14 min read ShaniCloud Engineering
Real-Time Pharmacy Inventory Architecture

When a pharmacist dispenses the last unit of a critical medication, every stakeholder — the dispensing pharmacist, the inventory manager, the automated reorder system, and the regional supervisor — needs to know within seconds, not minutes. Traditional request-response architectures introduce latency that can lead to stockouts, expired medication, and regulatory violations. We built a real-time inventory system for a 200-location pharmacy chain that processes 50,000 inventory events per hour with sub-second propagation.

This post details the technical decisions, trade-offs, and implementation patterns that made this system work at scale — from WebSocket connection management to conflict resolution across geographically distributed pharmacies.

Why WebSockets for Pharmacy Inventory?

Pharmacy inventory management has unique requirements that make it a strong fit for WebSocket-based real-time communication. The system must propagate stock changes instantly across locations, handle concurrent updates from multiple terminals, and provide reliable delivery guarantees. Let's examine why WebSockets were the right choice over alternatives.

Real-Time Bidirectional Communication

Unlike HTTP polling or Server-Sent Events (SSE), WebSockets maintain a persistent, full-duplex connection between client and server. This means the server can push inventory updates to pharmacy terminals the instant they occur, without the client having to ask. In a pharmacy context, when a controlled substance is dispensed, the update must reach the DEA reporting system, the automated reorder engine, and every terminal within the same region — all within milliseconds.

Event-Driven Architecture Matches Inventory Operations

Every pharmacy operation — dispensing, receiving, returns, adjustments — is naturally an event. An event-driven architecture with WebSockets means we can model the entire inventory lifecycle as a stream of immutable events, each broadcast to interested consumers in real time. This alignment between domain model and transport layer simplifies both the implementation and the reasoning about system behavior.

Reduced Server Load vs. Polling

With 200 pharmacy locations, each running 5-10 terminals, a polling approach would require maintaining thousands of open HTTP connections with repeated round-trips. WebSockets reduce this to a single long-lived connection per client, dramatically cutting CPU usage, memory consumption, and network overhead. Our load testing showed a 94% reduction in server resource utilization compared to a 2-second polling interval.

Offline Support with Event Queue

Pharmacies in rural areas sometimes experience intermittent connectivity. Our WebSocket implementation includes a client-side event queue that buffers outbound events during disconnection. When the connection is restored, queued events are replayed in order, ensuring no inventory operation is lost. The server acknowledges each event, allowing the client to clear its queue incrementally.

Protocol Comparison

The table below summarizes the trade-offs we evaluated when selecting the transport layer for our inventory system:

Feature HTTP Polling SSE WebSockets gRPC Streaming
Direction Client → Server Server → Client Bidirectional Bidirectional
Latency Poll interval dependent Low Lowest Lowest
Browser Support Universal Wide (no IE) Universal Requires gRPC-Web
Connection Overhead High (repeated) Low Low (single) Low (single)
Firewall Friendliness Excellent Good Good (WSS) Moderate (HTTP/2)
Reconnection Automatic Built-in Manual impl. Manual impl.

WebSockets won on bidirectional communication, universal browser support, and lowest latency — all critical for inventory operations where every second counts. gRPC streaming was a close second but introduced browser compatibility concerns with our existing terminal software.

Architecture Overview

The system follows an event-sourced architecture with CQRS (Command Query Responsibility Segregation), using WebSockets as the transport layer for real-time event propagation. Here's the high-level architecture:

[Pharmacy Terminal] <--WebSocket--> [Gateway] <---> [Event Store] ---> [Read Model]
[Mobile App]       <--WebSocket--> [Gateway]              |
[Admin Dashboard]  <--WebSocket--> [Gateway]             v
                                                     [Notification Service]

WebSocket Gateway

The Gateway is the entry point for all WebSocket connections. It handles connection lifecycle management, JWT authentication during the WebSocket handshake, and room-based routing. Each pharmacy location is assigned a "room" keyed by pharmacy_id. When an inventory event occurs at a location, it is broadcast only to clients subscribed to that room — reducing unnecessary traffic and ensuring data locality.

The Gateway also manages connection heartbeats (every 30 seconds), detects stale connections, and gracefully removes dead clients. It maintains an in-memory map of active connections grouped by room, backed by Redis for horizontal scaling.

Event Store

The Event Store is an append-only log of all inventory events. Every state change — dispensation, receipt, adjustment, return — is recorded as an immutable event with a monotonically increasing sequence number. This provides a complete audit trail, which is essential for DEA reporting and regulatory compliance.

We use a partitioned event store with events sharded by pharmacy_id. This ensures that events from the same location are processed in order, while allowing parallel processing across locations. The store retains events indefinitely for regulatory compliance, with a separate archival tier for events older than 7 years.

Read Model

The Read Model materializes events into query-optimized views. Different consumers need different views of the data: the pharmacy terminal needs current stock levels, the reorder system needs items approaching zero, the compliance officer needs dispensation history, and the regional supervisor needs aggregate dashboards. Each view is updated asynchronously as events flow through the system.

Notification Service

The Notification Service listens for critical events — low stock alerts, expiration warnings, controlled substance dispensations — and pushes notifications to relevant stakeholders via WebSocket, push notifications, and email. It implements a deduplication layer to prevent notification storms when multiple events trigger alerts for the same medication.

Event Schema Design

The event schema is the foundation of the system. Every event must be self-describing, versioned, and idempotent. Here are the core event types we defined:

  • InventoryAdjusted — Manual stock count correction with reason code
  • StockLevelChanged — Automatic update from dispensation or receipt
  • ExpirationAlert — Medication approaching or past expiration date
  • ReorderTriggered — Automated reorder initiated below threshold
  • DispensationRecorded — Complete dispensation with prescription linkage

Here's the TypeScript definition with Zod validation schemas:

import { z } from "zod";

// Base event schema — all events share these fields
const BaseEventSchema = z.object({
  eventId: z.string().uuid(),
  eventType: z.string(),
  version: z.number().int().min(1),
  timestamp: z.string().datetime(),
  pharmacyId: z.string().uuid(),
  userId: z.string().uuid(),
  idempotencyKey: z.string().uuid(),
  metadata: z.record(z.unknown()).optional(),
});

// Inventory adjustment event
const InventoryAdjustedSchema = BaseEventSchema.extend({
  eventType: z.literal("InventoryAdjusted"),
  payload: z.object({
    medicationId: z.string().uuid(),
    previousQuantity: z.number().int().min(0),
    newQuantity: z.number().int().min(0),
    reasonCode: z.enum([
      "PHYSICAL_COUNT",
      "DAMAGED",
      "EXPIRED",
      "RECALLED",
      "TRANSFER_IN",
      "TRANSFER_OUT",
    ]),
    notes: z.string().max(500).optional(),
  }),
});

// Stock level change event
const StockLevelChangedSchema = BaseEventSchema.extend({
  eventType: z.literal("StockLevelChanged"),
  payload: z.object({
    medicationId: z.string().uuid(),
    previousQuantity: z.number().int().min(0),
    newQuantity: z.number().int().min(0),
    changeType: z.enum(["DISPENSED", "RECEIVED", "RETURNED", "ADJUSTED"]),
    referenceId: z.string().uuid().optional(),
  }),
});

// Expiration alert event
const ExpirationAlertSchema = BaseEventSchema.extend({
  eventType: z.literal("ExpirationAlert"),
  payload: z.object({
    medicationId: z.string().uuid(),
    batchNumber: z.string(),
    expirationDate: z.string().date(),
    quantity: z.number().int().min(0),
    alertLevel: z.enum(["WARNING", "CRITICAL", "EXPIRED"]),
  }),
});

// Dispensation recorded event
const DispensationRecordedSchema = BaseEventSchema.extend({
  eventType: z.literal("DispensationRecorded"),
  payload: z.object({
    medicationId: z.string().uuid(),
    prescriptionId: z.string().uuid(),
    quantityDispensed: z.number().int().min(1),
    remainingQuantity: z.number().int().min(0),
    isControlled: z.boolean(),
    deaSchedule: z.enum(["I", "II", "III", "IV", "V"]).optional(),
    patientHash: z.string(), // PII-safe hash
  }),
});

// Union type of all events
const InventoryEventSchema = z.discriminatedUnion("eventType", [
  InventoryAdjustedSchema,
  StockLevelChangedSchema,
  ExpirationAlertSchema,
  DispensationRecordedSchema,
]);

type InventoryEvent = z.infer<typeof InventoryEventSchema>;

Event Versioning Strategy

Events are versioned using a simple integer counter. When the schema evolves, the version number increments. Consumers are required to handle unknown fields gracefully (forward compatibility), and the Event Store retains the original event payload without transformation. When a breaking change occurs, we deploy a migration function that transforms old events into the new format for new consumers, while preserving the originals.

Idempotency Keys

Every event includes a client-generated idempotencyKey (UUIDv4). The Event Store checks for duplicate keys before appending, preventing the same operation from being recorded twice. This is critical in a WebSocket environment where network issues can cause the client to retry sending an event. The server responds with a 409 Conflict if a duplicate is detected, and the client safely discards the retry.

Conflict Resolution with CRDTs

One of the most challenging problems in distributed pharmacy inventory is handling concurrent updates to the same medication. Consider this scenario: two pharmacies in the same region dispense the last unit of a shared medication from a regional buffer stock. Both dispense simultaneously, and both report remainingQuantity: 0. Without conflict resolution, the system could accept both updates, leaving the regional buffer with a phantom unit that doesn't exist.

G-Counter CRDT Implementation

We solved this using Conflict-free Replicated Data Types (CRDTs), specifically a Grow-Only Counter (G-Counter) for stock quantities. A G-Counter maintains a counter per node (pharmacy location) and computes the total as the sum of all node counters. This means concurrent increments from different nodes never conflict — they simply update different slots in the counter.

/**
 * G-Counter CRDT for distributed stock counting.
 * Each pharmacy location maintains its own counter slot.
 * The total is the sum of all slots — concurrent increments never conflict.
 */
class GCounter {
  private counters: Map<string, number>;
  private nodeId: string;

  constructor(nodeId: string, initialState?: Map<string, number>) {
    this.nodeId = nodeId;
    this.counters = initialState
      ? new Map(initialState)
      : new Map();
  }

  /** Increment this node's counter slot */
  increment(amount: number = 1): void {
    if (amount < 0) throw new Error("G-Counter only supports increment");
    const current = this.counters.get(this.nodeId) || 0;
    this.counters.set(this.nodeId, current + amount);
  }

  /** Decrement is not supported by G-Counter — use PN-Counter for that */
  decrement(): never {
    throw new Error("G-Counter does not support decrement. Use PN-Counter.");
  }

  /** Get the current total across all nodes */
  value(): number {
    let total = 0;
    for (const count of this.counters.values()) {
      total += count;
    }
    return total;
  }

  /** Merge with another G-Counter — takes the max of each slot */
  merge(other: GCounter): void {
    for (const [node, count] of other.counters.entries()) {
      const current = this.counters.get(node) || 0;
      this.counters.set(node, Math.max(current, count));
    }
  }

  /** Serialize for storage/transmission */
  toJSON(): Record<string, number> {
    return Object.fromEntries(this.counters);
  }

  /** Deserialize from storage */
  static fromJSON(
    nodeId: string,
    data: Record<string, number>
  ): GCounter {
    return new GCounter(nodeId, new Map(Object.entries(data)));
  }
}

Merge Strategy for Concurrent Updates

When two pharmacies dispatch events that affect the same medication, the merge function compares each node's counter slot and takes the maximum. This ensures that the merged state is always at least as large as either individual state — we never lose increments. For decrement operations (dispensations), we use a PN-Counter (Positive-Negative Counter) that maintains separate G-Counters for increments and decrements, computing the value as increments.sum() - decrements.sum().

When CRDTs Aren't Enough

CRDTs work perfectly for quantity tracking, but some operations — like medication transfers or reassignment of batch numbers — require stronger consistency guarantees. For these cases, we fall back to optimistic concurrency control with a last-write-wins strategy and a complete audit trail. Every conflict resolution is logged with both competing values, the resolution chosen, and the timestamp, allowing administrators to review and manually correct any issues.

WebSocket Connection Management

Managing thousands of persistent WebSocket connections requires careful attention to authentication, connection lifecycle, and fault tolerance.

Authentication Flow

Authentication happens during the WebSocket handshake using a short-lived JWT token. The client includes the token as a query parameter or in the first message after connection. The Gateway validates the token, extracts the user's pharmacy assignment and role, and adds the connection to the appropriate room. If the token is invalid or expired, the connection is immediately closed with a 4001 status code.

Room-Based Routing

Each pharmacy location is a "room." When a user connects, they're added to their pharmacy's room. All inventory events for that pharmacy are broadcast to every client in the room. Regional supervisors can subscribe to multiple rooms. The room abstraction is backed by Redis Pub/Sub for horizontal scaling — when multiple Gateway instances are running, each maintains its local connection map and subscribes to Redis channels for rooms it has active connections in.

Connection Heartbeat and Reconnection

The server sends a ping every 30 seconds. The client must respond with a pong within 10 seconds, or the connection is considered dead and closed. On the client side, we implement exponential backoff reconnection: starting at 1 second, doubling each attempt, with a maximum of 30 seconds. The client also tracks the last received event sequence number and requests a catch-up from the server on reconnection, ensuring no events are missed.

Graceful Degradation to Polling

For environments where WebSockets are blocked (corporate firewalls, legacy terminal software), the client falls back to long-polling. The same authentication and room-based routing applies, but events are delivered as HTTP responses to repeated GET requests. The fallback is transparent to the backend — the same Gateway handles both protocols.

Connection Manager Implementation

/**
 * WebSocket Connection Manager
 * Handles connection lifecycle, authentication, reconnection, and fallback.
 */
class ConnectionManager {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectDelay = 30000;
  private heartbeatInterval: NodeJS.Timeout | null = null;
  private eventQueue: InventoryEvent[] = [];
  private lastSequenceNumber = 0;
  private listeners: Map<string, Set<Function>> = new Map();

  constructor(
    private url: string,
    private token: string,
    private pharmacyId: string
  ) {}

  /** Establish WebSocket connection with auth token */
  connect(): void {
    const wsUrl = `${this.url}?token=${this.token}&pharmacy=${this.pharmacyId}`;
    this.ws = new WebSocket(wsUrl);

    this.ws.onopen = () => {
      console.log("WebSocket connected");
      this.reconnectAttempts = 0;
      this.startHeartbeat();
      this.flushEventQueue();
    };

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);

      if (data.type === "pong") return; // heartbeat response
      if (data.type === "event") {
        this.lastSequenceNumber = data.sequenceNumber;
        this.emit(data.eventType, data.payload);
      }
      if (data.type === "catchup") {
        data.events.forEach((e: InventoryEvent) => {
          this.emit(e.eventType, e.payload);
        });
      }
    };

    this.ws.onclose = (event) => {
      this.stopHeartbeat();
      if (event.code !== 1000) {
        // Abnormal close — attempt reconnection
        this.reconnect();
      }
    };

    this.ws.onerror = () => {
      this.ws?.close();
    };
  }

  /** Exponential backoff reconnection */
  private reconnect(): void {
    const delay = Math.min(
      1000 * Math.pow(2, this.reconnectAttempts),
      this.maxReconnectDelay
    );
    this.reconnectAttempts++;

    setTimeout(() => {
      this.connect();
      // Request catch-up for events missed during disconnection
      this.requestCatchup();
    }, delay);
  }

  /** Request events since last known sequence number */
  private requestCatchup(): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({
        type: "catchup_request",
        lastSequence: this.lastSequenceNumber,
      }));
    }
  }

  /** Send ping every 30 seconds */
  private startHeartbeat(): void {
    this.heartbeatInterval = setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: "ping" }));
      }
    }, 30000);
  }

  private stopHeartbeat(): void {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
      this.heartbeatInterval = null;
    }
  }

  /** Queue outbound events when disconnected */
  sendEvent(event: InventoryEvent): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(event));
    } else {
      this.eventQueue.push(event);
    }
  }

  /** Flush queued events after reconnection */
  private flushEventQueue(): void {
    while (this.eventQueue.length > 0) {
      const event = this.eventQueue.shift()!;
      this.sendEvent(event);
    }
  }

  /** Event listener management */
  on(eventType: string, callback: Function): void {
    if (!this.listeners.has(eventType)) {
      this.listeners.set(eventType, new Set());
    }
    this.listeners.get(eventType)!.add(callback);
  }

  private emit(eventType: string, payload: unknown): void {
    this.listeners.get(eventType)?.forEach((cb) => cb(payload));
  }
}

Scaling the System

A 200-location pharmacy chain generates significant WebSocket traffic. Here's how we scaled the system to handle 50,000 events per hour with sub-200ms p99 latency.

Horizontal Scaling with Redis Pub/Sub

Each Gateway instance maintains its local connection map, but inventory events are published to Redis channels keyed by pharmacy_id. Every Gateway subscribes to the channels for rooms where it has active connections. This allows us to add or remove Gateway instances freely — the event stream follows the connections, not the servers.

Connection Pooling and Load Balancing

We use a sticky session load balancer that routes WebSocket upgrade requests to the same Gateway instance for a given client IP. This avoids cross-instance message routing for the common case. For clients behind NAT (multiple terminals sharing one IP), the load balancer hashes on the JWT's user ID for more even distribution.

Event Partitioning by Pharmacy Region

The Event Store is partitioned by pharmacy region (Northeast, Southeast, Midwest, West). Each region has its own Event Store cluster, and cross-region events are replicated asynchronously. This keeps read latency low for local queries while allowing regional supervisors to query across their territory.

Monitoring

We track three critical metrics in real time:

  • Connection count — active WebSocket connections per Gateway instance, with alerts if any instance exceeds 5,000 connections
  • Message latency — time from event creation to delivery at the client, with p50, p95, and p99 percentiles
  • Error rate — failed deliveries, authentication failures, and protocol errors, with automatic alerting above 0.1% threshold

Load Testing Results

In load testing with simulated pharmacy terminals:

  • Throughput: 50,000 events/hour sustained, with burst capacity to 120,000 events/hour
  • P99 latency: 187ms (event creation to client delivery)
  • Connection reliability: 99.97% uptime over 30-day test period
  • Server resources: 4 Gateway instances (2 vCPU, 4GB RAM each) handling the full load with 40% CPU headroom

Regulatory Considerations

Pharmacy inventory systems operate under strict regulatory requirements. Our architecture was designed with compliance as a first-class concern, not an afterthought.

DEA Reporting Requirements

The Drug Enforcement Administration requires pharmacies to report all controlled substance transactions. Our Event Store provides an immutable, append-only log of every controlled substance dispensation, with the event schema including deaSchedule, prescriptionId, and a patientHash (a one-way hash of patient identifiers for privacy). Reports are generated by replaying the event stream, ensuring consistency with the source of truth.

State-Specific Controlled Substance Tracking

Many states maintain their own prescription drug monitoring programs (PDMPs). Our Notification Service can subscribe to DispensationRecorded events for controlled substances and forward them to state PDMPs in real time via their APIs. The event schema includes a state code field for routing, and the Notification Service maintains a registry of PDMP endpoints per state.

Audit Trail Integrity

The Event Store implements cryptographic chaining — each event includes a hash of the previous event, creating a tamper-evident chain. Any modification to historical events breaks the chain, making tampering immediately detectable. We also maintain an independent audit copy of the event stream in a separate storage system with restricted write access.

Data Retention Requirements

Federal and state regulations require retention of controlled substance records for 2-7 years depending on jurisdiction. Our Event Store retains all events indefinitely, with a cold storage tier for events older than 7 years. The cold storage maintains the same cryptographic chain integrity, ensuring that even archived records can be verified.

Conclusion

Real-time pharmacy inventory isn't just a nice-to-have — it's a regulatory requirement that directly impacts patient safety. WebSockets combined with event sourcing give us the low latency and auditability that modern pharmacy operations demand.

The key architectural decisions that made this system successful:

  • Event sourcing as the source of truth — every state change is recorded as an immutable event, providing both real-time propagation and complete audit trails
  • WebSockets for transport — bidirectional, low-latency communication with offline support via event queues
  • CRDTs for conflict resolution — conflict-free concurrent updates across geographically distributed pharmacies
  • Room-based routing — efficient message delivery that scales horizontally with Redis Pub/Sub
  • Regulatory-first design — DEA reporting, PDMP integration, and tamper-evident audit trails built into the architecture from day one

If you're building a system where real-time data consistency, auditability, and regulatory compliance are non-negotiable, the patterns we've described here — event sourcing, CRDTs, and WebSocket-based real-time communication — provide a proven foundation.

Building a real-time system?

We've shipped WebSocket architectures for healthcare, fintech, and logistics. Let's talk about yours.