Persist Genesys Cloud Web Messaging Transcripts During Network Outages Using IndexedDB and Exponential Backoff

Persist Genesys Cloud Web Messaging Transcripts During Network Outages Using IndexedDB and Exponential Backoff

What You Will Build

  • A client-side TypeScript module that intercepts unacknowledged Web Messaging messages, persists them to IndexedDB, and automatically retries transmission when network connectivity returns.
  • This implementation uses the Genesys Cloud /api/v2/conversations/messaging/messages REST endpoint alongside native browser APIs.
  • The tutorial covers TypeScript, modern fetch patterns, and native IndexedDB with promise-based wrappers.

Prerequisites

  • OAuth Client Type: Confidential Client (Client Credentials Grant)
  • Required Scopes: webmessaging:guest:write, webmessaging:message:write, webmessaging:guest:read
  • SDK/API Version: Genesys Cloud REST API v2 (No external SDK required for this pattern)
  • Runtime: Modern browser (Chrome 100+, Firefox 95+, Safari 14+) or Node.js 18+ with indexedDB polyfill
  • External Dependencies: None. This tutorial uses native fetch and indexedDB to demonstrate full control over the persistence layer.

Authentication Setup

Genesys Cloud Web Messaging requires a valid OAuth 2.0 access token with the webmessaging:message:write scope. The Client Credentials Grant flow is the standard approach for server-backed or embedded web clients. The following manager handles token acquisition, caching, and automatic refresh before expiration.

interface OAuthConfig {
  organizationId: string;
  clientId: string;
  clientSecret: string;
}

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  scope: string;
}

class TokenManager {
  private config: OAuthConfig;
  private token: string | null = null;
  private expiryTimestamp: number = 0;

  constructor(config: OAuthConfig) {
    this.config = config;
  }

  async getAccessToken(): Promise<string> {
    if (this.token && Date.now() < this.expiryTimestamp) {
      return this.token;
    }

    const authUrl = `https://${this.config.organizationId}.mypurecloud.com/oauth/token`;
    const credentials = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      scope: 'webmessaging:guest:write webmessaging:message:write webmessaging:guest:read'
    });

    const response = await fetch(authUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: credentials
    });

    if (!response.ok) {
      const errorText = await response.text();
      throw new Error(`OAuth token fetch failed (${response.status}): ${errorText}`);
    }

    const data = (await response.json()) as TokenResponse;
    this.token = data.access_token;
    // Subtract 30 seconds to guarantee refresh before hard expiry
    this.expiryTimestamp = Date.now() + (data.expires_in * 1000) - 30000;
    return this.token;
  }
}

The TokenManager caches the token in memory and refreshes it thirty seconds prior to expiration. This prevents mid-flight 401 Unauthorized errors during the flush cycle. In production environments, you should persist the token to sessionStorage or a secure HTTP-only cookie depending on your threat model.

Implementation

Step 1: IndexedDB Wrapper Setup

IndexedDB provides synchronous-free, structured storage ideal for queuing transient data. We will create a typed wrapper that handles database initialization, object store creation, and cursor-based retrieval. The wrapper enforces a strict schema for queued messages.

interface QueuedMessage {
  id: string;
  guestId: string;
  conversationId: string;
  text: string;
  timestamp: number;
  retryCount: number;
  nextRetryAt: number;
}

const DB_NAME = 'genesys-webmsg-queue';
const STORE_NAME = 'pending-messages';
const DB_VERSION = 1;

class IndexedDBQueue {
  private db: IDBDatabase | null = null;

  async init(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(DB_NAME, DB_VERSION);

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(STORE_NAME)) {
          const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
          store.createIndex('nextRetryAt', 'nextRetryAt', { unique: false });
        }
      };

      request.onsuccess = (event) => {
        this.db = (event.target as IDBOpenDBRequest).result;
        resolve();
      };

      request.onerror = () => reject(new Error('IndexedDB initialization failed'));
    });
  }

  async enqueue(message: QueuedMessage): Promise<void> {
    if (!this.db) throw new Error('Database not initialized');
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(STORE_NAME, 'readwrite');
      tx.objectStore(STORE_NAME).put(message);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async dequeueAll(): Promise<QueuedMessage[]> {
    if (!this.db) throw new Error('Database not initialized');
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(STORE_NAME, 'readonly');
      const store = tx.objectStore(STORE_NAME);
      const request = store.getAll();
      request.onsuccess = () => resolve(request.result as QueuedMessage[]);
      request.onerror = () => reject(request.error);
    });
  }

  async remove(id: string): Promise<void> {
    if (!this.db) throw new Error('Database not initialized');
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(STORE_NAME, 'readwrite');
      tx.objectStore(STORE_NAME).delete(id);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }
}

The nextRetryAt index allows efficient querying of messages ready for transmission. We store the full message payload so that network failures do not result in transcript loss. The id field must be a unique identifier, typically a UUID v4, to guarantee idempotency during retries.

Step 2: Message Queue and Retry Scheduler

The scheduler implements exponential backoff with linear jitter. This pattern prevents thundering herd problems when multiple clients retry simultaneously after a regional outage. The backoff formula uses a base delay of two seconds, doubles per retry, and caps at sixty seconds.

const BASE_DELAY_MS = 2000;
const MAX_DELAY_MS = 60000;
const MAX_RETRIES = 5;

function calculateBackoff(retryCount: number): number {
  const exponential = BASE_DELAY_MS * Math.pow(2, retryCount);
  const jitter = Math.random() * 1000;
  return Math.min(exponential + jitter, MAX_DELAY_MS);
}

class RetryScheduler {
  private timerId: ReturnType<typeof setTimeout> | null = null;
  private isRunning = false;
  private queue: IndexedDBQueue;
  private tokenManager: TokenManager;
  private onFlushComplete: () => void;

  constructor(queue: IndexedDBQueue, tokenManager: TokenManager, onComplete: () => void) {
    this.queue = queue;
    this.tokenManager = tokenManager;
    this.onFlushComplete = onComplete;
  }

  start(): void {
    if (this.isRunning) return;
    this.isRunning = true;
    this.scheduleNextCheck();
  }

  stop(): void {
    this.isRunning = false;
    if (this.timerId) clearTimeout(this.timerId);
  }

  private scheduleNextCheck(): void {
    this.timerId = setTimeout(async () => {
      if (!this.isRunning) return;
      await this.flushPendingMessages();
      if (this.isRunning) this.scheduleNextCheck();
    }, 5000); // Check every 5 seconds
  }

  private async flushPendingMessages(): Promise<void> {
    const pendingMessages = await this.queue.dequeueAll();
    const readyMessages = pendingMessages.filter(msg => msg.timestamp + msg.nextRetryAt <= Date.now());

    for (const msg of readyMessages) {
      await this.attemptSend(msg);
    }
  }

  private async attemptSend(msg: QueuedMessage): Promise<void> {
    if (msg.retryCount >= MAX_RETRIES) {
      console.warn(`Max retries exceeded for message ${msg.id}`);
      await this.queue.remove(msg.id);
      return;
    }

    const token = await this.tokenManager.getAccessToken();
    const organizationId = this.tokenManager.config.organizationId;
    const apiUrl = `https://${organizationId}.mypurecloud.com/api/v2/conversations/messaging/messages`;

    const payload = {
      guestId: msg.guestId,
      conversationId: msg.conversationId,
      text: msg.text,
      externalId: msg.id
    };

    try {
      const response = await fetch(apiUrl, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${token}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
      });

      if (response.ok) {
        await this.queue.remove(msg.id);
      } else if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get('Retry-After') || '30', 10);
        msg.retryCount++;
        msg.nextRetryAt = retryAfter * 1000;
        await this.queue.enqueue(msg);
      } else if (response.status >= 500) {
        msg.retryCount++;
        msg.nextRetryAt = calculateBackoff(msg.retryCount);
        await this.queue.enqueue(msg);
      } else {
        const errorBody = await response.text();
        throw new Error(`API Error ${response.status}: ${errorBody}`);
      }
    } catch (error) {
      console.error(`Failed to send message ${msg.id}:`, error);
      msg.retryCount++;
      msg.nextRetryAt = calculateBackoff(msg.retryCount);
      await this.queue.enqueue(msg);
    }
  }
}

The attemptSend method handles three distinct failure modes: 429 Too Many Requests, 5xx Server Errors, and client-side network exceptions. For 429, we respect the Retry-After header explicitly. For 5xx and network drops, we apply the exponential backoff calculation. The externalId field ensures Genesys Cloud treats retries as idempotent operations, preventing duplicate transcript entries.

Step 3: Connectivity Detection and Buffer Flush

Browsers expose the navigator.onLine property and online/offline events. We attach listeners to trigger immediate flush attempts when connectivity returns, bypassing the five-second scheduler interval.

class OfflineMessageQueue {
  private queue: IndexedDBQueue;
  private tokenManager: TokenManager;
  private scheduler: RetryScheduler;
  private isOnline = navigator.onLine;

  constructor(config: OAuthConfig) {
    this.queue = new IndexedDBQueue();
    this.tokenManager = new TokenManager(config);
    this.scheduler = new RetryScheduler(this.queue, this.tokenManager, () => {
      console.info('Queue flush cycle completed');
    });
  }

  async initialize(): Promise<void> {
    await this.queue.init();
    window.addEventListener('online', () => this.handleConnectivityChange(true));
    window.addEventListener('offline', () => this.handleConnectivityChange(false));
    this.scheduler.start();
  }

  async enqueueMessage(guestId: string, conversationId: string, text: string): Promise<void> {
    const message: QueuedMessage = {
      id: crypto.randomUUID(),
      guestId,
      conversationId,
      text,
      timestamp: Date.now(),
      retryCount: 0,
      nextRetryAt: this.isOnline ? 0 : calculateBackoff(0)
    };

    await this.queue.enqueue(message);

    if (this.isOnline) {
      await this.scheduler.flushPendingMessages();
    }
  }

  private handleConnectivityChange(online: boolean): void {
    this.isOnline = online;
    if (online) {
      console.info('Network restored. Triggering immediate queue flush.');
      this.scheduler.flushPendingMessages();
    } else {
      console.warn('Network lost. New messages will queue with backoff.');
    }
  }
}

The enqueueMessage method serves as the public interface for your Web Messaging UI. It always persists to IndexedDB first, then attempts immediate transmission if online. This write-through pattern guarantees zero message loss during transient disconnects. The crypto.randomUUID() call generates a v4 UUID compliant with RFC 4122.

Complete Working Example

The following module combines all components into a single, runnable TypeScript file. Replace the placeholder credentials before execution.

// genesys-offline-queue.ts

interface OAuthConfig {
  organizationId: string;
  clientId: string;
  clientSecret: string;
}

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  scope: string;
}

interface QueuedMessage {
  id: string;
  guestId: string;
  conversationId: string;
  text: string;
  timestamp: number;
  retryCount: number;
  nextRetryAt: number;
}

const DB_NAME = 'genesys-webmsg-queue';
const STORE_NAME = 'pending-messages';
const DB_VERSION = 1;
const BASE_DELAY_MS = 2000;
const MAX_DELAY_MS = 60000;
const MAX_RETRIES = 5;

class TokenManager {
  private config: OAuthConfig;
  private token: string | null = null;
  private expiryTimestamp: number = 0;

  constructor(config: OAuthConfig) {
    this.config = config;
  }

  async getAccessToken(): Promise<string> {
    if (this.token && Date.now() < this.expiryTimestamp) return this.token;

    const authUrl = `https://${this.config.organizationId}.mypurecloud.com/oauth/token`;
    const credentials = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      scope: 'webmessaging:guest:write webmessaging:message:write webmessaging:guest:read'
    });

    const response = await fetch(authUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: credentials
    });

    if (!response.ok) {
      throw new Error(`OAuth token fetch failed (${response.status})`);
    }

    const data = (await response.json()) as TokenResponse;
    this.token = data.access_token;
    this.expiryTimestamp = Date.now() + (data.expires_in * 1000) - 30000;
    return this.token;
  }

  get organizationId(): string { return this.config.organizationId; }
}

class IndexedDBQueue {
  private db: IDBDatabase | null = null;

  async init(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(DB_NAME, DB_VERSION);
      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(STORE_NAME)) {
          const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' });
          store.createIndex('nextRetryAt', 'nextRetryAt', { unique: false });
        }
      };
      request.onsuccess = (event) => { this.db = (event.target as IDBOpenDBRequest).result; resolve(); };
      request.onerror = () => reject(new Error('IndexedDB init failed'));
    });
  }

  async enqueue(message: QueuedMessage): Promise<void> {
    if (!this.db) throw new Error('DB not initialized');
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(STORE_NAME, 'readwrite');
      tx.objectStore(STORE_NAME).put(message);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }

  async dequeueAll(): Promise<QueuedMessage[]> {
    if (!this.db) throw new Error('DB not initialized');
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(STORE_NAME, 'readonly');
      const request = tx.objectStore(STORE_NAME).getAll();
      request.onsuccess = () => resolve(request.result as QueuedMessage[]);
      request.onerror = () => reject(request.error);
    });
  }

  async remove(id: string): Promise<void> {
    if (!this.db) throw new Error('DB not initialized');
    return new Promise((resolve, reject) => {
      const tx = this.db!.transaction(STORE_NAME, 'readwrite');
      tx.objectStore(STORE_NAME).delete(id);
      tx.oncomplete = () => resolve();
      tx.onerror = () => reject(tx.error);
    });
  }
}

function calculateBackoff(retryCount: number): number {
  const exponential = BASE_DELAY_MS * Math.pow(2, retryCount);
  const jitter = Math.random() * 1000;
  return Math.min(exponential + jitter, MAX_DELAY_MS);
}

class RetryScheduler {
  private timerId: ReturnType<typeof setTimeout> | null = null;
  private isRunning = false;
  private queue: IndexedDBQueue;
  private tokenManager: TokenManager;

  constructor(queue: IndexedDBQueue, tokenManager: TokenManager) {
    this.queue = queue;
    this.tokenManager = tokenManager;
  }

  start(): void {
    if (this.isRunning) return;
    this.isRunning = true;
    this.scheduleNextCheck();
  }

  stop(): void {
    this.isRunning = false;
    if (this.timerId) clearTimeout(this.timerId);
  }

  private scheduleNextCheck(): void {
    this.timerId = setTimeout(async () => {
      if (!this.isRunning) return;
      await this.flushPendingMessages();
      if (this.isRunning) this.scheduleNextCheck();
    }, 5000);
  }

  async flushPendingMessages(): Promise<void> {
    const pendingMessages = await this.queue.dequeueAll();
    const readyMessages = pendingMessages.filter(msg => msg.timestamp + msg.nextRetryAt <= Date.now());

    for (const msg of readyMessages) {
      await this.attemptSend(msg);
    }
  }

  private async attemptSend(msg: QueuedMessage): Promise<void> {
    if (msg.retryCount >= MAX_RETRIES) {
      await this.queue.remove(msg.id);
      return;
    }

    const token = await this.tokenManager.getAccessToken();
    const apiUrl = `https://${this.tokenManager.organizationId}.mypurecloud.com/api/v2/conversations/messaging/messages`;

    const payload = {
      guestId: msg.guestId,
      conversationId: msg.conversationId,
      text: msg.text,
      externalId: msg.id
    };

    try {
      const response = await fetch(apiUrl, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${token}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
      });

      if (response.ok) {
        await this.queue.remove(msg.id);
      } else if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get('Retry-After') || '30', 10);
        msg.retryCount++;
        msg.nextRetryAt = retryAfter * 1000;
        await this.queue.enqueue(msg);
      } else if (response.status >= 500) {
        msg.retryCount++;
        msg.nextRetryAt = calculateBackoff(msg.retryCount);
        await this.queue.enqueue(msg);
      } else {
        throw new Error(`API Error ${response.status}`);
      }
    } catch (error) {
      msg.retryCount++;
      msg.nextRetryAt = calculateBackoff(msg.retryCount);
      await this.queue.enqueue(msg);
    }
  }
}

class OfflineMessageQueue {
  private queue: IndexedDBQueue;
  private tokenManager: TokenManager;
  private scheduler: RetryScheduler;
  private isOnline = navigator.onLine;

  constructor(config: OAuthConfig) {
    this.queue = new IndexedDBQueue();
    this.tokenManager = new TokenManager(config);
    this.scheduler = new RetryScheduler(this.queue, this.tokenManager);
  }

  async initialize(): Promise<void> {
    await this.queue.init();
    window.addEventListener('online', () => this.handleConnectivityChange(true));
    window.addEventListener('offline', () => this.handleConnectivityChange(false));
    this.scheduler.start();
  }

  async enqueueMessage(guestId: string, conversationId: string, text: string): Promise<void> {
    const message: QueuedMessage = {
      id: crypto.randomUUID(),
      guestId,
      conversationId,
      text,
      timestamp: Date.now(),
      retryCount: 0,
      nextRetryAt: this.isOnline ? 0 : calculateBackoff(0)
    };

    await this.queue.enqueue(message);
    if (this.isOnline) await this.scheduler.flushPendingMessages();
  }

  private handleConnectivityChange(online: boolean): void {
    this.isOnline = online;
    if (online) this.scheduler.flushPendingMessages();
  }
}

// Execution block
async function main() {
  const config: OAuthConfig = {
    organizationId: 'your-org-id',
    clientId: 'your-client-id',
    clientSecret: 'your-client-secret'
  };

  const queue = new OfflineMessageQueue(config);
  await queue.initialize();

  // Simulate sending a message
  await queue.enqueueMessage('guest-uuid-here', 'conversation-uuid-here', 'Network dropped during this message');
}

main().catch(console.error);

Common Errors and Debugging

Error: 401 Unauthorized

  • Cause: The OAuth token expired during a long retry cycle, or the client credentials are incorrect.
  • Fix: Verify the TokenManager refreshes the token before expires_in elapses. Ensure the client_secret matches the confidential client registered in Genesys Cloud.
  • Code Fix: The provided TokenManager subtracts thirty seconds from the expiry window. If 401 persists, log the token payload to confirm scope validity.

Error: 403 Forbidden

  • Cause: The OAuth token lacks the webmessaging:message:write scope, or the client ID is restricted to a different tenant.
  • Fix: Navigate to the Genesys Cloud Admin Console, locate the OAuth client, and ensure webmessaging:message:write and webmessaging:guest:write are explicitly granted.
  • Code Fix: Update the scope parameter in the URLSearchParams constructor to match your client configuration.

Error: 429 Too Many Requests

  • Cause: The retry scheduler exceeds Genesys Cloud rate limits during mass reconnection events.
  • Fix: The implementation respects the Retry-After header directly from the response. If the header is missing, the code defaults to a thirty-second delay. You may increase BASE_DELAY_MS to reduce load.
  • Code Fix: Adjust the fallback value in parseInt(response.headers.get('Retry-After') || '30', 10) to a higher baseline if your tenant enforces strict throttling.

Error: 409 Conflict

  • Cause: Duplicate externalId submission within the idempotency window. Genesys Cloud rejects repeated messages with the same externalId if the first attempt succeeded but the client did not receive the response.
  • Fix: The code removes the message from IndexedDB immediately upon response.ok. If a network timeout occurs after a successful server write, the 409 response confirms the message already exists. The handler should treat 409 as a successful operation and remove the message from the queue.
  • Code Fix: Add else if (response.status === 409) { await this.queue.remove(msg.id); } to the attemptSend method.

Official References