Audio Protection & Error Recovery
Invox Medical's streaming transcription client implements enterprise-grade audio protection mechanisms to ensure zero data loss even during network failures, browser crashes, or service disruptions. This guide explains the technical architecture that makes audio transcription reliable and resilient.
Overview
The transcription client employs multiple layers of protection:
- Persistent Storage using IndexedDB for browser-native durability
- Circuit Breaker Pattern to prevent cascading failures
- Intelligent Retry Logic with exponential backoff
- Dual Acknowledgment System for efficient confirmation
- Automatic Recovery from network interruptions
- Queue Management with flow control
- Session Restoration after crashes or page refreshes
Storage Mechanisms
IndexedDB Integration
The client uses IndexedDB through the idb-keyval library for persistent storage that survives page refreshes and browser restarts.
What Gets Stored
Every audio chunk is immediately persisted with the following structure:
interface StoredAudioChunk {
id: string; // Unique identifier (timestamp_sequence)
sessionId: string; // UUID session identifier
timestamp: number; // Creation timestamp
sent: boolean; // Has been sent to server
acknowledged: boolean; // Server confirmed receipt
retryCount: number; // Number of retry attempts (max: 3)
sequenceNumber: number; // Monotonic sequence for ordering
bytesB64: string; // Base64-encoded PCM audio (16-bit, 16kHz)
durationMs: number; // Audio duration in milliseconds
}
Storage Keys
// Audio chunks: audio_chunk_{sessionId}_{chunkId}
const AUDIO_STORAGE_PREFIX = "audio_chunk_";
// Session metadata: transcription_session_{sessionId}
const SESSION_STORAGE_KEY = "transcription_session_";
// Maximum chunks before cleanup
const MAX_STORED_CHUNKS = 1000;
When Storage Occurs
- Immediate Persistence - When audio chunk is created (before sending):
// Audio buffer reaches threshold (0.6 seconds)
await this.storeAudioChunk(audioChunk); // Persist first
this.enqueueAndProcess(audioChunk); // Then enqueue
- Update on Send - When chunk is transmitted:
await this.markChunkAsSent(chunkId); // Updates sent: true
- Update on Acknowledgment - When server confirms receipt:
await this.markChunkAsAcknowledged(chunkId); // Updates acknowledged: true
Storage Cleanup
To prevent unlimited storage growth, the client automatically cleans old chunks:
// Cleanup triggers when storage exceeds MAX_STORED_CHUNKS (1000)
private async cleanOldChunks() {
// Priority: Delete acknowledged chunks first
// Then delete oldest chunks by timestamp
// Keeps 100 buffer slots available
}
Cleanup Strategy:
- Prioritizes deletion of acknowledged chunks (already processed)
- Preserves unacknowledged chunks (not yet confirmed)
- Maintains 100-slot buffer for new chunks
- Sorted by acknowledgment status, then by timestamp
Why IndexedDB?
Advantages over localStorage:
- Larger capacity: 50MB+ vs 5-10MB
- Asynchronous: Non-blocking operations
- Native objects: No JSON serialization overhead
- Transactional: Consistent state guarantees
- Persistent: Survives crashes and restarts
Error Handling Strategies
Circuit Breaker Pattern
The client implements a circuit breaker to prevent cascading failures during service outages.
Configuration
const circuitBreaker = new CircuitBreaker({
failureThreshold: 5, // Open after 5 consecutive failures
timeout: 60000, // Wait 60s before retry
name: "TranscriptionClient",
});
Circuit States
1. CLOSED (Normal Operation)
- All operations allowed
- Failures are counted
- Transitions to OPEN after 5 failures
2. OPEN (Service Degraded)
- Operations immediately rejected
- Transcription paused automatically
- User notified: "Service temporarily unavailable"
- After 60 seconds, transitions to HALF_OPEN
3. HALF_OPEN (Testing Recovery)
- Single test operation allowed
- Success → CLOSED (full recovery)
- Failure → OPEN (back to degraded)
Protected Operations
The circuit breaker wraps critical operations:
// WebSocket connection
await circuitBreaker.execute("connect", async () => {
// Connection logic
});
// Audio chunk transmission
await circuitBreaker.execute("sendChunk", async () => {
// Send logic
});
// Transcription retrieval
await circuitBreaker.execute("getTranscription", async () => {
// Fetch logic
});
Connection Error Handling
WebSocket Failures:
// Tracks failure patterns
interface ConnectionMetrics {
consecutiveFailures: number;
lastFailureTime: number;
networkLatency: number;
throughput: number;
}
// On each failure
this.connectionMetrics.consecutiveFailures++;
this.connectionMetrics.lastFailureTime = Date.now();
Poor Network Adaptation:
// Automatically adjusts heartbeat interval
private handlePoorNetworkConditions() {
// Doubles heartbeat interval (max: 30s)
this.heartbeatMs = Math.min(this.heartbeatMs * 2, 30000)
}
Recovery Processes
Session Recovery on Reconnection
When the WebSocket reconnects, the client automatically recovers unsent audio:
private async recoverStoredChunks() {
// 1. Load all chunks from IndexedDB for current session
const chunks = await loadChunksFromStorage(this.sessionId)
// 2. Filter chunks that need sending
const pendingChunks = chunks.filter(chunk =>
!chunk.sent ||
(!chunk.acknowledged && chunk.retryCount < MAX_RETRY_ATTEMPTS)
)
// 3. Sort by sequence number to maintain order
pendingChunks.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
// 4. Re-enqueue all pending chunks
for (const chunk of pendingChunks) {
this.enqueue(chunk)
}
// 5. Resume processing
this.processQueueIfReady()
}
Recovery Triggers:
- After successful WebSocket connection
- Automatically restores unsent chunks
- Resumes unacknowledged chunks (within retry limit)
Resending Pending Chunks
After network recovery, in-flight chunks are automatically resent:
private async resendPendingChunks() {
let recoveredDuration = 0
// Move all in-flight chunks back to queue
for (const [chunkId, inFlightData] of this.inFlight) {
this.clearAcknowledmentTimeout(chunkId)
recoveredDuration += inFlightData.chunk.durationMs
this.sendQueue.unshift(inFlightData.chunk) // Priority: front
}
// Reset in-flight tracking
this.inFlight.clear()
this.inFlightAudioDurationMs -= recoveredDuration
// Resume sending
this.processQueueIfReady()
}
Triggered By:
- Network reconnection (
onlineevent) - WebSocket state change to connected
- Recovery from disconnected/error state
Exponential Backoff Reconnection
Failed connections retry with increasing delays:
private async reconnectLoop() {
// Add jittered delay (prevents thundering herd)
await sleep(jitter(this.backoffMs))
// Exponential backoff: 500ms → 1s → 2s → 4s → ... → 30s
this.backoffMs = Math.min(maxBackoffMs, this.backoffMs * 2)
return this.connect()
}
// Jitter adds randomness (±20%) to prevent synchronized retries
function jitter(ms: number) {
const r = Math.random() * 0.4 + 0.8 // Range: 0.8-1.2
return Math.floor(ms * r)
}
Backoff Schedule:
- Initial: 500ms ± 20%
- Maximum: 30,000ms (30 seconds)
- Multiplier: 2x per attempt
- Jitter: ±20% randomization
Network Resilience Features
Online/Offline Detection
The client monitors browser network status in real-time:
// Setup network monitoring
window.addEventListener("online", () => {
this.isOnline = true;
this.handleNetworkReconnection(); // Immediate reconnection
});
window.addEventListener("offline", () => {
this.isOnline = false;
this.handleNetworkDisconnection(); // Pause operations
});
Benefits:
- Prevents sending when offline
- Automatic reconnection when online
- User feedback on network status
- Preserves audio during outages
WebSocket State Management
The client maintains detailed connection states:
enum ClientStates {
IDLE = "idle", // Not yet connected
CONNECTING = "connecting", // Establishing connection
CONNECTED = "connected", // Active connection
RECONNECTING = "reconnecting", // Attempting reconnection
DISCONNECTED = "disconnected", // Intentionally disconnected
ERROR = "error", // Error state
PAUSED = "PAUSED", // Circuit breaker open or manual pause
}
State Transitions:
IDLE → CONNECTING → CONNECTED
↓ ↓
ERROR ← → RECONNECTING
↓
DISCONNECTED
Connection Health Checks
Heartbeat Mechanism:
// Send periodic ping to detect silent failures
private startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
this.sendJson({ type: 'ping', t: Date.now() })
}, this.heartbeatMs) // Default: 10,000ms (10 seconds)
}
Adaptive Heartbeat:
- Normal conditions: 10 seconds
- Poor network: Doubles to 20s, then 30s (max)
- Detects silent connection failures
- Prevents idle connection timeouts
Automatic Reconnection
The client automatically reconnects on unexpected closures:
private async onCloseWebSocket(event?: CloseEvent) {
console.log('WebSocket closed:', event?.code, event?.reason)
this.stopHeartbeat()
// Skip reconnection if intentionally ended
if (this.isEndingTranscription) {
this.updateState(ClientStates.DISCONNECTED)
return
}
this.updateState(ClientStates.DISCONNECTED)
// Auto-reconnect unless in ERROR or PAUSED
if (this.state !== ClientStates.ERROR && !this.isPaused) {
await this.reconnectLoop()
}
}
Acknowledgment & Retry Logic
Dual Acknowledgment System
The client supports two acknowledgment methods for flexibility and efficiency:
1. Individual Chunk Acknowledgment
Each chunk receives explicit confirmation:
// Server response format
{
"id": "1732579200000_000042",
"chunkId": "1732579200000_000042"
}
// Client handling
private async handleChunkAcknowledgment(chunkId: string) {
this.clearAcknowledmentTimeout(chunkId)
const inFlightData = this.inFlight.get(chunkId)
if (inFlightData) {
// Free up in-flight budget
this.inFlightAudioDurationMs -= inFlightData.chunk.durationMs
// Remove from tracking
this.inFlight.delete(chunkId)
// Persist acknowledgment
await this.markChunkAsAcknowledged(chunkId)
// Try sending more chunks
await this.processQueueIfReady()
}
}
Benefits:
- Fine-grained confirmation
- Low-latency feedback
- Simple to implement
2. Batch Acknowledgment
Multiple chunks confirmed with single message:
// Server response format
{
"type": "AUDIO_CHUNK_ACK",
"stream_id": "session-uuid",
"ack_id": 42 // All chunks with sequenceNumber <= 42 confirmed
}
// Client handling
private async handleBatchAcknowledgment(streamId: string, ackId: number) {
const chunksToAck = []
// Find all chunks with sequenceNumber <= ackId
for (const [chunkId, inFlightData] of this.inFlight) {
if (inFlightData.chunk.sequenceNumber <= ackId) {
chunksToAck.push(inFlightData)
}
}
// Sort by sequence to maintain order
chunksToAck.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
// Acknowledge all matched chunks
for (const data of chunksToAck) {
this.clearAcknowledmentTimeout(data.chunk.id)
this.inFlight.delete(data.chunk.id)
await this.markChunkAsAcknowledged(data.chunk.id)
this.inFlightAudioDurationMs -= data.chunk.durationMs
}
await this.processQueueIfReady()
}
Benefits:
- 90%+ reduction in ACK messages
- Handles high-throughput scenarios
- Prevents ACK storms
Timeout-Based Retry
Chunks without acknowledgment are automatically retried:
// Set 30-second timeout on send
private setAcknowledmentTimeout(chunkId: string) {
const timeoutId = setTimeout(() => {
this.handleChunkTimeout(chunkId)
}, CHUNK_TIMEOUT_MS) // 30,000ms
this.acknowledgmentTimeouts.set(chunkId, timeoutId)
}
// Handle timeout
private handleChunkTimeout(chunkId: string) {
const inFlightData = this.inFlight.get(chunkId)
if (inFlightData) {
this.inFlight.delete(chunkId)
this.retryChunk(inFlightData.chunk)
}
}
Timeout Duration: 30 seconds (conservative for high-latency networks)
Exponential Backoff Retry
Failed chunks retry with increasing delays:
private retryChunk(chunk: PendingChunk) {
chunk.retryCount = (chunk.retryCount || 0) + 1
if (chunk.retryCount <= MAX_RETRY_ATTEMPTS) { // MAX = 3
setTimeout(() => {
this.sendQueue.unshift(chunk) // Priority: front of queue
this.processQueueIfReady()
}, jitter(1000 * 2 ** (chunk.retryCount - 1)))
} else {
// Report failure after max attempts
this.handleChunkFailure(chunk)
}
}
Retry Schedule:
- 1st retry: ~1s (2⁰ × 1000ms ± jitter)
- 2nd retry: ~2s (2¹ × 1000ms ± jitter)
- 3rd retry: ~4s (2² × 1000ms ± jitter)
- After 3 failures: Report error via
onErrorcallback
Total Time: ~37 seconds from initial send to final failure
Queue Management
Queue Structure
The client maintains two data structures:
// FIFO queue for chunks awaiting transmission
private sendQueue: PendingChunk[] = []
// Map of chunks sent but not yet acknowledged
private inFlight: Map<string, { chunk: PendingChunk; timestamp: number }> = new Map()
Flow Control
To prevent memory overflow and buffer bloat:
const MAX_IN_FLIGHT_DURATION_MS = 10000 // 10 seconds max unacknowledged
private canSendNow(chunkDurationMs: number) {
return (
this.ws?.readyState === WebSocket.OPEN &&
this.isOnline &&
this.inFlightAudioDurationMs + chunkDurationMs <= MAX_IN_FLIGHT_DURATION_MS
)
}
Mechanism:
- Tracks total duration of unacknowledged audio
- Blocks new sends when limit reached
- Waits for acknowledgments to free budget
- Prevents network congestion
Example:
- Max 10 seconds in-flight
- Chunks are ~600ms each
- Max ~16 chunks simultaneously
- 17th chunk waits until ACK received
Queue Processing
private async processQueueIfReady() {
// Guard: Check connection state
if (!this.ws || this.ws.readyState !== WebSocket.OPEN ||
!this.isOnline || this.isPaused) {
return
}
while (this.sendQueue.length > 0) {
const chunk = this.sendQueue[0]
// Respect flow control limits
if (!this.canSendNow(chunk.durationMs)) {
break // Wait for ACKs
}
this.sendQueue.shift()
try {
await this.sendChunkInternal(chunk, 'sendChunk')
} catch (error) {
if (error instanceof CircuitBreakerError) {
// Return to queue, stop processing
this.sendQueue.unshift(chunk)
break
}
// Other errors: retry with backoff
this.retryChunk(chunk)
break
}
}
}
Queue Discipline:
- FIFO (First In, First Out)
- Priority for retries (added to front via
unshift) - Respects flow control limits
- Breaks on error to prevent starvation
Error Recovery Scenarios
Scenario 1: Network Drop During Recording
Flow:
- Browser detects network loss (
offlineevent) isOnlineflag set tofalsecanSendNow()returnsfalse(blocks queue)- Audio continues recording to buffer
- Chunks flushed and persisted to IndexedDB
- In-flight chunks timeout → moved back to queue
- Network returns (
onlineevent) handleNetworkReconnection()calledresendPendingChunks()re-queues chunksconnect()re-establishes WebSocketrecoverStoredChunks()loads from IndexedDBprocessQueueIfReady()resumes sending
Result: ✅ Zero audio loss
Scenario 2: Browser Crash Mid-Session
Flow:
- Browser crashes, all RAM lost
- IndexedDB survives (disk-persisted)
- User relaunches application
connect()called with same session- Session ID loaded from
transcription_session_current recoverStoredChunks()reads from IndexedDB- Unsent chunks (
sent: false) added to queue - Unacknowledged chunks added to queue
- Queue processed in sequence order
Result: ✅ Session resumed with all audio intact
Scenario 3: Circuit Breaker Opens
Flow:
- Server returns 5 consecutive errors
- Circuit breaker transitions to OPEN
isPausedset totrue- User shown: "Service temporarily unavailable"
- All sends fail immediately with
CircuitBreakerError - Chunks remain in queue and IndexedDB
- After 60s, circuit transitions to HALF_OPEN
- Single test operation attempted
- Success: Circuit closes, queue processed
- Failure: Circuit reopens, wait another 60s
Result: ✅ Graceful degradation, no data loss
Scenario 4: Chunk Timeout (No ACK)
Flow:
- Chunk sent at
t=0 - No ACK by
t=30s - Timeout handler fired
- Chunk removed from in-flight
- Retry scheduled with backoff
- Retry 1 at
t=31s(1s delay) - Retry 2 at
t=33s(2s delay) - Retry 3 at
t=37s(4s delay) handleChunkFailure()called- Error reported via
onErrorcallback
Result: ✅ 4 total attempts over ~37s before reporting failure
Performance Characteristics
Storage Performance
- Write latency: ~5-10ms per chunk (IndexedDB async)
- Read latency: ~10-20ms per chunk recovery
- Bulk recovery: ~100-200ms for 1000 chunks
Network Performance
- Chunk size: ~14-20 KB (0.6s PCM16 @ 16kHz)
- Send frequency: ~1.7 chunks/sec (600ms intervals)
- Bandwidth: ~24-34 KB/sec (~200 Kbps)
- In-flight limit: 10 seconds (~170 KB max buffer)
Memory Footprint
- Send queue: 200 KB - 1 MB (10-50 chunks)
- In-flight map: ~320 KB (~16 chunks)
- Audio buffer: ~19 KB (~0.6s buffering)
- Total RAM: ~500 KB - 1.5 MB (worst case)
Monitoring API
The client exposes methods for monitoring health and performance:
Client State
// Get current connection state
const state = client.getState();
// Returns: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error' | 'PAUSED'
// Get current session ID
const sessionId = client.getSessionId();
// Returns: UUID string
Network Metrics
const metrics = client.getNetworkMetrics();
// Returns: {
// consecutiveFailures: number,
// lastFailureTime: number,
// networkLatency: number,
// throughput: number,
// isOnline: boolean
// }
Circuit Breaker Status
const stats = client.getCircuitBreakerStats();
// Returns: {
// state: 'CLOSED' | 'OPEN' | 'HALF_OPEN',
// failureCount: number,
// lastFailureTime: number,
// retryAfter: number,
// operations: Record<string, OperationStats>
// }
const state = client.getCircuitBreakerState();
// Returns: 'CLOSED' | 'OPEN' | 'HALF_OPEN'
Storage Information
const storageInfo = await client.getStorageInfo();
// Returns: {
// totalChunks: number,
// totalSessions: number,
// currentSessionChunks: number
// }
Best Practices
1. Monitor Circuit Breaker State
client.onState = (state) => {
if (state === "PAUSED") {
// Show user-friendly message
showNotification("Transcription temporarily paused. Retrying...");
}
};
2. Handle Errors Gracefully
client.onError = (error) => {
console.error("Transcription error:", error);
// Log to monitoring service
logError(error, { sessionId: client.getSessionId() });
};
3. Track Network Status
client.onNetworkStatus = (isOnline) => {
if (!isOnline) {
showWarning("Network connection lost. Audio being saved locally.");
} else {
showSuccess("Network reconnected. Resuming transcription.");
}
};
4. Monitor Storage Usage
// Periodically check storage
setInterval(async () => {
const info = await client.getStorageInfo();
if (info.currentSessionChunks > 500) {
console.warn("Large number of pending chunks:", info.currentSessionChunks);
}
}, 30000);
5. Clean Up on Session End
// After transcription complete
await client.end();
await client.cleanLocalStorage();
Conclusion
The Invox Medical streaming transcription client provides enterprise-grade audio protection through:
- ✅ Zero data loss guarantee via IndexedDB persistence
- ✅ Intelligent retry logic with exponential backoff
- ✅ Circuit breaker protection against cascading failures
- ✅ Dual acknowledgment system for efficiency
- ✅ Automatic recovery from crashes and network failures
- ✅ Flow control to prevent buffer overflow
- ✅ Session restoration after interruptions
This architecture ensures that audio transcription remains robust and reliable even under adverse conditions including network failures, server outages, browser crashes, and high-latency scenarios.