Implementing offline message queuing for Genesys Cloud Web Messaging using a TypeScript service worker that intercepts fetch requests and batches payloads on network recovery

Implementing offline message queuing for Genesys Cloud Web Messaging using a TypeScript service worker that intercepts fetch requests and batches payloads on network recovery

What You Will Build

  • A TypeScript service worker that intercepts outbound Genesys Cloud Web Messaging REST calls, stores failed requests in IndexedDB when offline, and batches them for retry when connectivity returns.
  • This solution uses the Genesys Cloud /api/v2/conversations/messaging/messages endpoint and the Service Worker fetch API.
  • The tutorial covers TypeScript, IndexedDB via the idb library, and production-grade retry logic with rate limit handling.

Prerequisites

  • OAuth 2.0 client with the webmessaging:send scope
  • Genesys Cloud API v2
  • Node.js 18 or later, TypeScript 5 or later
  • idb package for type-safe IndexedDB operations (npm install idb)
  • A local development server or HTTPS environment (Service Workers require secure contexts)

Authentication Setup

Genesys Cloud Web Messaging requires a valid OAuth 2.0 Bearer token with the webmessaging:send scope. Service workers cannot securely initiate OAuth flows or store long-lived credentials. The main application thread must acquire the token and attach it to every outgoing request. The service worker intercepts those requests, preserves the Authorization header, and reuses it during retries.

// auth.ts
export async function acquireWebMessagingToken(
  clientId: string,
  clientSecret: string,
  baseUrl: string
): Promise<string> {
  const response = await fetch(`${baseUrl}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: clientId,
      client_secret: clientSecret,
      scope: 'webmessaging:send'
    })
  });

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

  const data = await response.json();
  return data.access_token;
}

The service worker relies on the token being present in the intercepted request headers. If the token expires during offline storage, the retry logic will receive a 401 Unauthorized response. The worker will communicate this back to the main thread via postMessage so the application can refresh the token and re-queue the failed requests.

Implementation

Step 1: Service Worker Registration & Fetch Interception

The service worker listens for the fetch event and filters requests targeting the Genesys Cloud Web Messaging endpoint. When a request matches, the worker attempts to forward it. If the network is unavailable or the request fails, the worker clones the request body, stores it in IndexedDB, and returns a 202 Accepted response to the main thread indicating the payload is safely queued.

// sw.ts
import { openDB, IDBPDatabase } from 'idb';

const DB_NAME = 'genesys-messaging-queue';
const STORE_NAME = 'pending';
const MESSAGING_PATH = '/api/v2/conversations/messaging/messages';

let db: IDBPDatabase;

async function initDB(): Promise<IDBPDatabase> {
  return openDB(DB_NAME, 1, {
    upgrade(database) {
      if (!database.objectStoreNames.contains(STORE_NAME)) {
        const store = database.createObjectStore(STORE_NAME, { keyPath: 'id' });
        store.createIndex('timestamp', 'timestamp');
      }
    }
  });
}

self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', (event: FetchEvent) => {
  const url = new URL(event.request.url);
  if (url.pathname === MESSAGING_PATH && event.request.method === 'POST') {
    event.respondWith(interceptMessagingRequest(event.request));
  }
});

async function interceptMessagingRequest(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    if (!response.ok) {
      throw new Error(`Genesys Cloud returned ${response.status}`);
    }
    return response;
  } catch (error) {
    const payload = await request.clone().json();
    const queueEntry = {
      id: crypto.randomUUID(),
      timestamp: Date.now(),
      payload,
      headers: Object.fromEntries(request.headers.entries()),
      retryCount: 0,
      lastError: error instanceof Error ? error.message : 'Unknown network error'
    };

    if (!db) db = await initDB();
    await db.put(STORE_NAME, queueEntry);

    return new Response(JSON.stringify({ queued: true, id: queueEntry.id }), {
      status: 202,
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

The fetch event handler uses event.respondWith() to take control of the network request. If the initial fetch succeeds, the original response passes through. If it fails, the request body is cloned to prevent stream consumption, serialized to JSON, and persisted. The 202 response prevents the main thread from throwing an unhandled network exception.

Step 2: IndexedDB Queue Management & Network Recovery

IndexedDB provides asynchronous, transactional storage that survives browser restarts. The worker listens for the online event and triggers a batch processing routine. The routine reads queued entries ordered by timestamp, groups them into batches to respect API rate limits, and attempts retransmission.

// sw.ts (continued)
self.addEventListener('online', () => {
  processQueue();
});

async function processQueue(): Promise<void> {
  if (!db) db = await initDB();
  
  const tx = db.transaction(STORE_NAME, 'readwrite');
  const store = tx.objectStore(STORE_NAME);
  const index = store.index('timestamp');
  let cursor = await index.openCursor();

  const BATCH_SIZE = 5;
  let batch: IDBPDatabase['stores'][typeof STORE_NAME][number][] = [];

  while (cursor) {
    batch.push(cursor.value);
    if (batch.length >= BATCH_SIZE) {
      await flushBatch(batch);
      batch = [];
    }
    cursor = await cursor.continue();
  }

  if (batch.length > 0) {
    await flushBatch(batch);
  }
}

The cursor reads entries chronologically to preserve message ordering. Batching limits concurrent outbound connections and prevents overwhelming the Genesys Cloud gateway. The flushBatch function handles individual retries with exponential backoff and rate limit compliance.

Step 3: Batch Retry Logic with Rate Limit & Token Handling

Genesys Cloud enforces strict rate limits. When a 429 Too Many Requests response occurs, the API returns a Retry-After header. The retry logic parses this header, delays subsequent attempts, and updates the queue entry with the new retry count. A 401 Unauthorized response triggers a message to the main thread requesting a token refresh.

// sw.ts (continued)
async function flushBatch(items: any[]): Promise<void> {
  if (!db) db = await initDB();

  for (const item of items) {
    const headers = new Headers(item.headers);
    const request = new Request('https://api.mypurecloud.com' + MESSAGING_PATH, {
      method: 'POST',
      headers,
      body: JSON.stringify(item.payload)
    });

    try {
      const response = await fetch(request);
      
      if (response.ok) {
        await db.delete(STORE_NAME, item.id);
        continue;
      }

      if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
        await delay(retryAfter * 1000);
        item.retryCount += 1;
        item.lastError = `Rate limited. Retry-After: ${retryAfter}s`;
        await db.put(STORE_NAME, item);
        continue;
      }

      if (response.status === 401) {
        await db.put(STORE_NAME, { ...item, lastError: 'Token expired' });
        self.clients.matchAll().then(clients => {
          clients.forEach(client => {
            client.postMessage({ type: 'TOKEN_REFRESH_REQUIRED', queueId: item.id });
          });
        });
        continue;
      }

      // Handle 4xx/5xx non-transient errors
      if (response.status >= 500) {
        const backoff = Math.min(2 ** item.retryCount * 1000, 30000);
        await delay(backoff);
        item.retryCount += 1;
        item.lastError = `Server error ${response.status}`;
        await db.put(STORE_NAME, item);
      }
    } catch (networkError) {
      item.retryCount += 1;
      item.lastError = networkError instanceof Error ? networkError.message : 'Network failure';
      await db.put(STORE_NAME, item);
    }
  }
}

function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

The retry loop processes each item sequentially within the batch to maintain order. Exponential backoff caps at 30 seconds to prevent indefinite delays. The 401 case uses self.clients.matchAll() to broadcast a token refresh request. The main thread must listen for this message, refresh the token, and update the queued request headers.

Step 4: Main Thread Integration & Message Dispatch

The main application registers the service worker and sends messages using standard fetch. The service worker intercepts automatically. The main thread also listens for TOKEN_REFRESH_REQUIRED messages and re-injects the new token into queued requests.

// main.ts
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => console.log('Service Worker registered:', registration.scope))
    .catch(error => console.error('Service Worker registration failed:', error));
}

export async function sendWebMessage(
  conversationId: string,
  text: string,
  accessToken: string
): Promise<any> {
  const payload = {
    to: { id: conversationId, type: 'conversation' },
    text: text,
    from: { id: 'web-user-1', type: 'user' }
  };

  const response = await fetch('https://api.mypurecloud.com/api/v2/conversations/messaging/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${accessToken}`
    },
    body: JSON.stringify(payload)
  });

  return response.json();
}

// Listen for token refresh requests from the service worker
navigator.serviceWorker?.addEventListener('message', (event: MessageEvent) => {
  if (event.data.type === 'TOKEN_REFRESH_REQUIRED') {
    console.log(`Service worker requires token refresh for queue ID: ${event.data.queueId}`);
    // Implement your token refresh logic here
    // After refresh, update the queued request headers via IndexedDB or postMessage back to SW
  }
});

The main thread remains unaware of offline conditions. It simply issues the fetch call. The service worker handles persistence, retry scheduling, and rate limit compliance transparently.

Complete Working Example

sw.ts

import { openDB, IDBPDatabase } from 'idb';

const DB_NAME = 'genesys-messaging-queue';
const STORE_NAME = 'pending';
const MESSAGING_PATH = '/api/v2/conversations/messaging/messages';

let db: IDBPDatabase;

async function initDB(): Promise<IDBPDatabase> {
  return openDB(DB_NAME, 1, {
    upgrade(database) {
      if (!database.objectStoreNames.contains(STORE_NAME)) {
        const store = database.createObjectStore(STORE_NAME, { keyPath: 'id' });
        store.createIndex('timestamp', 'timestamp');
      }
    }
  });
}

self.addEventListener('install', (event: ExtendableEvent) => {
  event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', (event: ExtendableEvent) => {
  event.waitUntil(self.clients.claim());
});

self.addEventListener('fetch', (event: FetchEvent) => {
  const url = new URL(event.request.url);
  if (url.pathname === MESSAGING_PATH && event.request.method === 'POST') {
    event.respondWith(interceptMessagingRequest(event.request));
  }
});

async function interceptMessagingRequest(request: Request): Promise<Response> {
  try {
    const response = await fetch(request);
    if (!response.ok) throw new Error(`Genesys Cloud returned ${response.status}`);
    return response;
  } catch (error) {
    const payload = await request.clone().json();
    const queueEntry = {
      id: crypto.randomUUID(),
      timestamp: Date.now(),
      payload,
      headers: Object.fromEntries(request.headers.entries()),
      retryCount: 0,
      lastError: error instanceof Error ? error.message : 'Unknown network error'
    };

    if (!db) db = await initDB();
    await db.put(STORE_NAME, queueEntry);

    return new Response(JSON.stringify({ queued: true, id: queueEntry.id }), {
      status: 202,
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

self.addEventListener('online', () => {
  processQueue();
});

async function processQueue(): Promise<void> {
  if (!db) db = await initDB();
  const tx = db.transaction(STORE_NAME, 'readwrite');
  const store = tx.objectStore(STORE_NAME);
  const index = store.index('timestamp');
  let cursor = await index.openCursor();

  const BATCH_SIZE = 5;
  let batch: any[] = [];

  while (cursor) {
    batch.push(cursor.value);
    if (batch.length >= BATCH_SIZE) {
      await flushBatch(batch);
      batch = [];
    }
    cursor = await cursor.continue();
  }

  if (batch.length > 0) {
    await flushBatch(batch);
  }
}

async function flushBatch(items: any[]): Promise<void> {
  if (!db) db = await initDB();
  for (const item of items) {
    const headers = new Headers(item.headers);
    const request = new Request('https://api.mypurecloud.com' + MESSAGING_PATH, {
      method: 'POST',
      headers,
      body: JSON.stringify(item.payload)
    });

    try {
      const response = await fetch(request);
      if (response.ok) {
        await db.delete(STORE_NAME, item.id);
        continue;
      }

      if (response.status === 429) {
        const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
        await delay(retryAfter * 1000);
        item.retryCount += 1;
        item.lastError = `Rate limited. Retry-After: ${retryAfter}s`;
        await db.put(STORE_NAME, item);
        continue;
      }

      if (response.status === 401) {
        await db.put(STORE_NAME, { ...item, lastError: 'Token expired' });
        self.clients.matchAll().then(clients => {
          clients.forEach(client => client.postMessage({ type: 'TOKEN_REFRESH_REQUIRED', queueId: item.id }));
        });
        continue;
      }

      if (response.status >= 500) {
        const backoff = Math.min(2 ** item.retryCount * 1000, 30000);
        await delay(backoff);
        item.retryCount += 1;
        item.lastError = `Server error ${response.status}`;
        await db.put(STORE_NAME, item);
      }
    } catch (networkError) {
      item.retryCount += 1;
      item.lastError = networkError instanceof Error ? networkError.message : 'Network failure';
      await db.put(STORE_NAME, item);
    }
  }
}

function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

main.ts

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(registration => console.log('Service Worker registered:', registration.scope))
    .catch(error => console.error('Service Worker registration failed:', error));
}

export async function sendWebMessage(
  conversationId: string,
  text: string,
  accessToken: string
): Promise<any> {
  const payload = {
    to: { id: conversationId, type: 'conversation' },
    text: text,
    from: { id: 'web-user-1', type: 'user' }
  };

  const response = await fetch('https://api.mypurecloud.com/api/v2/conversations/messaging/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${accessToken}`
    },
    body: JSON.stringify(payload)
  });

  return response.json();
}

navigator.serviceWorker?.addEventListener('message', (event: MessageEvent) => {
  if (event.data.type === 'TOKEN_REFRESH_REQUIRED') {
    console.log(`Service worker requires token refresh for queue ID: ${event.data.queueId}`);
  }
});

Common Errors & Debugging

Error: 401 Unauthorized during retry

  • What causes it: The OAuth Bearer token cached in the request headers expired while the payload was stored in IndexedDB.
  • How to fix it: Listen for the TOKEN_REFRESH_REQUIRED message from the service worker. Refresh the token using your OAuth endpoint, then update the stored request headers in IndexedDB or post the new token back to the service worker to re-attempt the batch.
  • Code showing the fix:
// In main.ts
navigator.serviceWorker?.addEventListener('message', async (event: MessageEvent) => {
  if (event.data.type === 'TOKEN_REFRESH_REQUIRED') {
    const newToken = await acquireWebMessagingToken(CLIENT_ID, CLIENT_SECRET, BASE_URL);
    event.source?.postMessage({ type: 'TOKEN_UPDATED', token: newToken });
  }
});

// In sw.ts, add listener for updated token
self.addEventListener('message', async (event: ExtendableMessageEvent) => {
  if (event.data.type === 'TOKEN_UPDATED') {
    // Update pending requests with new token
    if (!db) db = await initDB();
    const tx = db.transaction(STORE_NAME, 'readwrite');
    const store = tx.objectStore(STORE_NAME);
    let cursor = await store.openCursor();
    while (cursor) {
      const headers = new Headers(cursor.value.headers);
      headers.set('Authorization', `Bearer ${event.data.token}`);
      cursor.value.headers = Object.fromEntries(headers.entries());
      await cursor.update(cursor.value);
      cursor = await cursor.continue();
    }
    processQueue();
  }
});

Error: 429 Too Many Requests

  • What causes it: The application or retry loop exceeded Genesys Cloud rate limits.
  • How to fix it: The flushBatch function already parses the Retry-After header and delays execution. Ensure you do not spawn parallel retry loops. Keep BATCH_SIZE conservative and respect the header value.
  • Code showing the fix: Already implemented in flushBatch with retryAfter parsing and delay().

Error: IndexedDB quota exceeded

  • What causes it: The browser storage limit (typically 50MB to several GB depending on OS) is exhausted by queued payloads.
  • How to fix it: Implement a maximum queue size and drop the oldest messages when the limit is reached. Add a maxQueueSize check before db.put().
  • Code showing the fix:
const MAX_QUEUE_SIZE = 1000;
// Inside interceptMessagingRequest catch block:
const count = await db.count(STORE_NAME);
if (count >= MAX_QUEUE_SIZE) {
  // Remove oldest entry
  const tx = db.transaction(STORE_NAME, 'readwrite');
  const store = tx.objectStore(STORE_NAME);
  const index = store.index('timestamp');
  const cursor = await index.openCursor();
  if (cursor) await cursor.delete();
}
await db.put(STORE_NAME, queueEntry);

Error: Service Worker scope mismatch

  • What causes it: The service worker file is not hosted at the root or the registration path does not match the fetch URL domain.
  • How to fix it: Host sw.js at the application root or adjust the register() path. Ensure the intercepted URL matches the exact origin registered in the worker.
  • Code showing the fix: Register at root: navigator.serviceWorker.register('/sw.js') and ensure all Genesys Cloud fetch calls use the exact same origin or configure CORS appropriately.

Official References