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/messagesREST endpoint alongside native browser APIs. - The tutorial covers TypeScript, modern
fetchpatterns, 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
indexedDBpolyfill - External Dependencies: None. This tutorial uses native
fetchandindexedDBto 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
TokenManagerrefreshes the token beforeexpires_inelapses. Ensure theclient_secretmatches the confidential client registered in Genesys Cloud. - Code Fix: The provided
TokenManagersubtracts 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:writescope, 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:writeandwebmessaging:guest:writeare explicitly granted. - Code Fix: Update the
scopeparameter in theURLSearchParamsconstructor 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-Afterheader directly from the response. If the header is missing, the code defaults to a thirty-second delay. You may increaseBASE_DELAY_MSto 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
externalIdsubmission within the idempotency window. Genesys Cloud rejects repeated messages with the sameexternalIdif 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, the409response confirms the message already exists. The handler should treat409as 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 theattemptSendmethod.