VC.
ArogyaSync Platform logo
BackendMobileFrontendDevOpsBlockchain

ArogyaSync Platform

From bedside vitals to blockchain ledger — clinical data sync at production scale.

0%

API Uptime

< 0ms

API Latency (p95)

0+

Integrated Services

Tech Stack

GoFlutterReactPostgreSQLPythonAWSPolygon

The Challenge

Rural and semi-urban clinics were generating patient vitals data on paper, leading to transcription errors, sync delays of 12–24 hours, and complete data loss during network outages. The platform needed to handle concurrent writes from hundreds of field devices while guaranteeing delivery — even when offline. Insurance verification required cross-referencing live patient data against insurer CSV registries, with an immutable audit trail. The breadth of the problem demanded a multi-service architecture: mobile app, real-time API, IoT edge runtime, blockchain anchor, and insurance portal — all operating as a coherent system.

Architecture & System Design

ArogyaSync Platform system architecture
Full system schematic available upon request

The platform is built as a set of independently deployable services running on AWS EC2 (Mumbai, ap-south-1). The Go/Gin REST API handles clinical CRUD, real-time WebSocket streaming, and authentication. A Python Flask blockchain service anchors record hashes to Polygon Amoy testnet for tamper-proof audit trails. A separate Python Flask insurance service processes insurer CSV files and performs eligibility cross-checks with scheduled APScheduler jobs. The Flutter mobile app uses an offline-first sync queue (WorkManager equivalent + SQLite) to survive connectivity gaps. Three React dashboards (hospital, device management, insurance portal) provide real-time visualisation via WebSocket and REST polling.

Code Walkthrough

3-step walk-through of the production implementation — file paths and intent shown above each block.

  1. Step 1 of 3

    Real-time vitals fan-out

    clinical-api/internal/handlers/ws.go

    Dashboards used to poll the DB every few seconds. Now each one holds a single WebSocket subscription to its sensor, the vitals handler publishes once, and fan-out happens in-memory — zero extra DB reads. The subtle part is concurrency: gorilla/websocket forbids overlapping writes to the same connection, and the per-sensor client map has to tolerate concurrent subscribe/broadcast/unsubscribe without racing.

    go
    var (
        sensorClients   = make(map[uint]map[*websocket.Conn]bool)
        sensorClientsMu sync.RWMutex
        // gorilla/websocket requires writes to a single conn be serialised.
        connWriteMu sync.Map // *websocket.Conn → *sync.Mutex
    )
    
    // VitalsBroadcast is the wire format for live vitals.
    // PatientID and MACAddress are deliberately excluded so the channel
    // cannot be used to re-identify patients — PHI stays server-side.
    type VitalsBroadcast struct {
        HeartRate   *int      `json:"heartRate"`
        SpO2        *float64  `json:"spo2"`
        Temperature *float64  `json:"temperature"`
        RecordTime  time.Time `json:"recordedTimeStamp"`
        DeviceID    string    `json:"deviceId"`
    }
    
    func BroadcastToSensorClients(sensorID uint, payload []byte) {
        // Snapshot the inner map under the read lock, then release it before
        // writing. Without this copy, a slow client could block new subscribers
        // and a concurrent delete would race the iteration.
        sensorClientsMu.RLock()
        inner, ok := sensorClients[sensorID]
        if !ok {
            sensorClientsMu.RUnlock()
            return
        }
        snapshot := make([]*websocket.Conn, 0, len(inner))
        for c := range inner {
            snapshot = append(snapshot, c)
        }
        sensorClientsMu.RUnlock()
    
        for _, conn := range snapshot {
            if err := writeToConn(conn, websocket.TextMessage, payload); err != nil {
                evictClient(sensorID, conn)
            }
        }
    }
    
    // writeToConn acquires a per-connection mutex before writing. Without this,
    // a concurrent pinger + broadcast can corrupt the frame stream.
    func writeToConn(conn *websocket.Conn, msgType int, data []byte) error {
        mu, _ := connWriteMu.LoadOrStore(conn, &sync.Mutex{})
        mu.(*sync.Mutex).Lock()
        defer mu.(*sync.Mutex).Unlock()
        conn.SetWriteDeadline(time.Now().Add(writeWait))
        return conn.WriteMessage(msgType, data)
    }
    Takeaway

    Fan-out is two snapshots — one of the client set, one of each connection's write lock — so the read path never blocks new subscribers and no write ever overlaps with the pinger.

  2. Step 2 of 3

    Offline-first sync queue with tempId rewiring

    clinical_mobile_app/lib/data/services/offline_sync_service.dart

    Field health workers operate in areas with unreliable network. Writes have to feel instant, so they go into a local cache first and the UI never waits. The subtlety is what happens when a worker creates a patient offline and then adds vitals for that patient — both actions sit in the queue, the patient has no real ID yet, and when connectivity returns we replay in order, rewriting the downstream references as the server hands out real IDs.

    dart
    class OfflineSyncService {
      final CacheService _cache;
      final http.Client _client;
      final LocalStorageRepository _localStorage;
      StreamSubscription<List<ConnectivityResult>>? _sub;
    
      void start() {
        _sub = Connectivity().onConnectivityChanged.listen((results) {
          if (results.isNotEmpty && !results.contains(ConnectivityResult.none)) {
            syncPendingActions();
          }
        });
      }
    
      Future<void> syncPendingActions() async {
        final pending = _cache.getPendingActions();
        if (pending.isEmpty) return;
    
        final remaining = <Map<String, dynamic>>[];
        final idMapping = <String, String>{};
    
        for (final action in pending) {
          try {
            // Rewrite any tempId references with previously-mapped real IDs.
            final resolved = _applyIdMappings(action, idMapping);
            final result   = await _processAction(resolved);
    
            if (result.clientError is AuthFailure) {
              // Token invalid — clear the queue and surface upstream.
              await _cache.updatePendingActions(const []);
              throw result.clientError!;
            }
            if (result.success && result.newId != null && action['tempId'] != null) {
              idMapping[action['tempId']] = result.newId!;
            } else if (!result.success) {
              remaining.add(action);
            }
          } catch (_) {
            remaining.add(action); // transient — retry next cycle
          }
        }
        await _cache.updatePendingActions(remaining);
      }
    
      Future<SyncResult> _processAction(Map<String, dynamic> action) async {
        // Never replay with the token captured at enqueue time — it may be expired.
        // Re-fetch a fresh token for every action, every replay.
        final freshToken = await _localStorage.getToken();
        if (freshToken == null) return SyncResult(success: false);
    
        final headers = Map<String, String>.from(action['headers'] as Map? ?? {});
        headers['Authorization'] = 'Bearer $freshToken';
        return _dispatch(action, headers);
      }
    }
    Takeaway

    Offline queues are subtle — you have to rewrite tempIds across queued actions, always re-fetch auth tokens at replay time, and treat 401/403 as a terminal 'clear the queue' event rather than a retryable error.

  3. Step 3 of 3

    Hashing evidence and anchoring on Polygon

    blockchain_anchor_service/blockchain_client.py

    For the insurance audit trail we needed immutable proof that the day's vital records existed and hadn't been altered, without putting any patient data on-chain. Each hourly CSV gets SHA-256'd, and only the 32-byte digest is posted to anchorBatch(timestamp, hash) on Polygon Amoy — so storage costs stay flat and the raw records stay in Postgres.

    python
    def hash_file(file_path: str) -> str:
        """Streaming SHA-256 so large evidence files don't blow up RAM."""
        sha256 = hashlib.sha256()
        with open(file_path, "rb") as f:
            for chunk in iter(lambda: f.read(4096), b""):
                sha256.update(chunk)
        return sha256.hexdigest()
    
    
    def anchor_to_contract(timestamp: str, file_hash: str) -> str:
        """Post a single hash to the on-chain anchor contract. Returns tx hash."""
        w3      = get_w3()
        account = Account.from_key(get_private_key())
    
        batch_bytes32 = Web3.to_bytes(hexstr=file_hash.removeprefix("0x"))
        if len(batch_bytes32) != 32:
            raise ValueError("hash must be exactly 32 bytes")
    
        # Nonce management: read under a lock using the 'pending' tx count so
        # two concurrent anchor jobs don't both grab the same nonce and collide.
        with nonce_lock:
            nonce = w3.eth.get_transaction_count(account.address, "pending")
    
            tx = get_contract().functions.anchorBatch(timestamp, batch_bytes32).build_transaction({
                "chainId":              POLYGON_CHAIN_ID,
                "from":                 account.address,
                "nonce":                nonce,
                "maxFeePerGas":         w3.to_wei(MAX_FEE_GWEI, "gwei"),
                "maxPriorityFeePerGas": w3.to_wei(MAX_PRIORITY_FEE_GWEI, "gwei"),
                "gas":                  200_000,
            })
    
            signed  = w3.eth.account.sign_transaction(tx, get_private_key())
            tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
    
        # Polygon Amoy sometimes delays the receipt — retry up to 3 times.
        for attempt in range(3):
            try:
                receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
                break
            except Exception:
                if attempt == 2:
                    raise
                time.sleep(5 * (attempt + 1))
    
        if receipt.status != 1:
            raise RuntimeError(f"anchor tx reverted: {tx_hash.hex()}")
    
        return tx_hash.hex()
    Takeaway

    Three things that are easy to miss with web3.py — read the nonce from 'pending' (not 'latest') under a lock, handle receipt-fetch timeouts explicitly, and always check receipt.status != 1 to catch silent reverts.

Results

ArogyaSync is live across a pilot network of field health workers. The API sustains 99.9% uptime with p95 latency under 150ms. The blockchain anchor service has recorded thousands of immutable medical events on Polygon. The insurance portal handles bulk CSV verification jobs, cutting manual eligibility checks from days to minutes. The hospital dashboard provides real-time multi-patient vitals monitoring with historical chart exports.

Gallery & Demos

ArogyaSync Platform screenshot
ArogyaSync Platform screenshot
Demo video 1
Click to expand
Demo video 2
Click to expand

Click any image or video to expand · ← → keys navigate

Interested in this work?

Full architecture walkthrough and code review available during interviews.