Implementing Real-Time Typing Indicators in Genesys Cloud Web Messaging with TypeScript
What You Will Build
- A TypeScript service that captures keystrokes, applies network-safe throttling, and broadcasts typing status to agents via WebSocket control messages.
- Uses the
@genesyscloud/purecloud-web-messagingclient SDK for connection lifecycle management and message routing. - Written in TypeScript with strict typing, exponential backoff retry logic, and a merge conflict resolution strategy for concurrent multi-guest or multi-tab typing events.
Prerequisites
- Genesys Cloud Web Messaging Client ID and API Host (
*.mypurecloud.com) - Required OAuth scopes:
webmessaging:write,conversation:write(used by the SDK during token exchange) - Node.js 18+ or a modern browser environment with TypeScript 5+
- Dependencies:
@genesyscloud/purecloud-web-messaging,uuid - TypeScript compiler configured with
strict: trueandtarget: ES2020
Authentication Setup
The Web Messaging client SDK handles OAuth 2.0 token exchange internally. You must provide a valid clientId, apiHost, and userId. The SDK requests a bearer token from the Genesys Cloud token endpoint using the client credentials flow, then establishes the WebSocket connection to the messaging gateway.
import { WebMessagingClient, WebMessagingConfig } from '@genesyscloud/purecloud-web-messaging';
export class MessagingAuthService {
private client: WebMessagingClient;
constructor(config: WebMessagingConfig) {
this.client = new WebMessagingClient({
clientId: config.clientId,
apiHost: config.apiHost,
userId: config.userId,
routingEmail: config.routingEmail,
// The SDK automatically handles token caching and refresh
tokenRefreshIntervalMs: 450000,
});
}
async connect(): Promise<void> {
try {
await this.client.connect();
this.client.on('connected', () => {
console.log('WebSocket connection established with Genesys Cloud messaging gateway');
});
this.client.on('disconnected', (event) => {
console.warn('WebSocket disconnected:', event.reason);
});
this.client.on('error', (error) => {
console.error('Authentication or transport error:', error);
});
} catch (error: unknown) {
if (error instanceof Error && error.message.includes('401')) {
throw new Error('Invalid clientId or apiHost. Verify OAuth credentials');
}
throw error;
}
}
getClient(): WebMessagingClient {
return this.client;
}
}
The SDK exchanges the clientId for a short-lived access token at /oauth/token and attaches it to the WebSocket upgrade request. If the token expires, the SDK automatically refreshes it using the cached refresh token. You do not need to manage token storage manually.
Implementation
Step 1: Initialize Client & Attach Keystroke Listener
The service must listen to DOM input events and map them to typing state changes. You attach listeners to the message input element and normalize rapid keystrokes into discrete typing events.
import { fromEvent, tap, catchError } from 'rxjs';
import { WebMessagingClient } from '@genesyscloud/purecloud-web-messaging';
export class TypingIndicatorService {
private readonly THROTTLE_MS = 1000;
private readonly TTL_MS = 5000;
private readonly MAX_RETRIES = 3;
private typingState: Map<string, { isTyping: boolean; timestamp: number; version: number }> = new Map();
private activeConversationId: string | null = null;
constructor(
private client: WebMessagingClient,
private guestId: string,
private inputElement: HTMLInputElement
) {}
initKeystrokeListener(): void {
fromEvent(this.inputElement, 'input')
.pipe(
tap(() => this.registerTypingEvent(true)),
catchError((error: Error) => {
console.error('Keystroke stream error:', error);
return [];
})
)
.subscribe(() => {
this.throttledBroadcast(true);
});
fromEvent(this.inputElement, 'blur')
.pipe(
tap(() => this.registerTypingEvent(false))
)
.subscribe(() => {
this.throttledBroadcast(false);
});
}
setActiveConversationId(conversationId: string): void {
this.activeConversationId = conversationId;
}
private registerTypingEvent(isTyping: boolean): void {
const now = Date.now();
const current = this.typingState.get(this.guestId) ?? { isTyping: false, timestamp: 0, version: 0 };
this.typingState.set(this.guestId, {
isTyping,
timestamp: now,
version: current.version + 1,
});
this.expireStaleEntries();
}
private expireStaleEntries(): void {
const now = Date.now();
for (const [guestId, state] of this.typingState.entries()) {
if (now - state.timestamp > this.TTL_MS) {
this.typingState.delete(guestId);
}
}
}
}
The registerTypingEvent method updates a local state map per guest identifier. The TTL_MS constant ensures that typing indicators automatically expire after five seconds of inactivity, preventing stale “is typing” states from persisting in the agent UI.
Step 2: Throttle Updates & Construct WebSocket Control Messages
Raw keystroke events generate excessive network traffic. You must throttle updates and construct control messages that match the Genesys Cloud WebSocket schema. The service applies a 1000-millisecond throttle window and constructs a JSON payload with the correct type, id, and data structure.
import { v4 as uuidv4 } from 'uuid';
interface TypingControlMessage {
type: 'typingIndicator';
id: string;
timestamp: number;
data: {
conversationId: string;
guestId: string;
isTyping: boolean;
};
}
// Inside TypingIndicatorService class:
private throttleTimer: ReturnType<typeof setTimeout> | null = null;
private pendingState: boolean = false;
private throttledBroadcast(isTyping: boolean): void {
this.pendingState = isTyping;
if (this.throttleTimer !== null) return;
this.throttleTimer = setTimeout(() => {
this.throttleTimer = null;
this.broadcastTypingIndicator(this.pendingState);
}, this.THROTTLE_MS);
}
private async broadcastTypingIndicator(isTyping: boolean): Promise<void> {
if (!this.activeConversationId) {
console.warn('Cannot broadcast typing indicator: no active conversation ID');
return;
}
const message: TypingControlMessage = {
type: 'typingIndicator',
id: uuidv4(),
timestamp: Date.now(),
data: {
conversationId: this.activeConversationId,
guestId: this.guestId,
isTyping,
},
};
await this.sendWithRetry(message);
}
private async sendWithRetry(payload: TypingControlMessage, attempt = 1): Promise<void> {
try {
// The SDK exposes send() for custom control messages
await this.client.send(payload);
} catch (error: unknown) {
const errorMessage = (error as Error).message;
if (errorMessage.includes('429') || errorMessage.includes('rate limit')) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.warn(`Rate limit hit. Retrying in ${delay}ms (attempt ${attempt}/${this.MAX_RETRIES})`);
if (attempt < this.MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, delay));
return this.sendWithRetry(payload, attempt + 1);
}
throw new Error('Max retry attempts exceeded for typing indicator broadcast');
}
if (errorMessage.includes('401') || errorMessage.includes('403')) {
throw new Error('Authentication failed. Reinitialize the Web Messaging client');
}
if (errorMessage.includes('WebSocket') || errorMessage.includes('network')) {
console.error('Transport failure during typing indicator send:', error);
throw error;
}
throw error;
}
}
The sendWithRetry method implements exponential backoff for HTTP 429 responses. The payload structure matches the Genesys Cloud WebSocket control message schema. The type field must be exactly typingIndicator. The data object contains the conversation identifier, guest identifier, and boolean typing state. The SDK routes this message through the established WebSocket connection to the messaging gateway, which forwards it to the connected agent.
Step 3: Handle Race Conditions & Merge Conflict Resolution
Multiple guests or multiple browser tabs representing the same guest can emit conflicting typing events simultaneously. You must implement a merge conflict resolution strategy that coalesces states, applies last-write-wins semantics per guest, and broadcasts a consolidated indicator.
// Inside TypingIndicatorService class:
private lastBroadcastState: boolean = false;
private resolveAndBroadcast(isTyping: boolean): void {
this.registerTypingEvent(isTyping);
// Merge conflict resolution: coalesce all active typers
const activeTypers = Array.from(this.typingState.entries())
.filter(([_, state]) => state.isTyping)
.map(([guestId]) => guestId);
const hasActiveTypers = activeTypers.length > 0;
// Only broadcast if the consolidated state changed
if (hasActiveTypers !== this.lastBroadcastState) {
this.lastBroadcastState = hasActiveTypers;
this.broadcastTypingIndicator(hasActiveTypers);
}
}
// Replace throttledBroadcast call with:
private throttledBroadcast(isTyping: boolean): void {
this.pendingState = isTyping;
if (this.throttleTimer !== null) return;
this.throttleTimer = setTimeout(() => {
this.throttleTimer = null;
this.resolveAndBroadcast(this.pendingState);
}, this.THROTTLE_MS);
}
// Add method to simulate multi-guest/multi-tab conflict resolution
public handleExternalTypingEvent(guestId: string, isTyping: boolean, version: number): void {
const now = Date.now();
const existing = this.typingState.get(guestId);
// Last-write-wins with version control prevents stale overrides
if (!existing || version > existing.version || (version === existing.version && now > existing.timestamp)) {
this.typingState.set(guestId, { isTyping, timestamp: now, version });
this.expireStaleEntries();
// Recalculate consolidated state without immediate broadcast
const activeTypers = Array.from(this.typingState.entries())
.filter(([_, state]) => state.isTyping)
.length;
const newConsolidatedState = activeTypers > 0;
if (newConsolidatedState !== this.lastBroadcastState) {
this.lastBroadcastState = newConsolidatedState;
this.broadcastTypingIndicator(newConsolidatedState);
}
}
}
The handleExternalTypingEvent method accepts typing updates from other client instances or guest participants. The version number ensures that newer events override older ones, preventing race conditions where stale network packets overwrite current state. The service calculates a consolidated boolean state across all tracked guests and only broadcasts when the aggregate state changes. This prevents agent UI flicker and reduces WebSocket message volume.
Complete Working Example
import { WebMessagingClient, WebMessagingConfig } from '@genesyscloud/purecloud-web-messaging';
import { fromEvent, tap, catchError } from 'rxjs';
import { v4 as uuidv4 } from 'uuid';
interface TypingControlMessage {
type: 'typingIndicator';
id: string;
timestamp: number;
data: {
conversationId: string;
guestId: string;
isTyping: boolean;
};
}
export class TypingIndicatorService {
private readonly THROTTLE_MS = 1000;
private readonly TTL_MS = 5000;
private readonly MAX_RETRIES = 3;
private typingState: Map<string, { isTyping: boolean; timestamp: number; version: number }> = new Map();
private activeConversationId: string | null = null;
private throttleTimer: ReturnType<typeof setTimeout> | null = null;
private pendingState: boolean = false;
private lastBroadcastState: boolean = false;
constructor(
private client: WebMessagingClient,
private guestId: string,
private inputElement: HTMLInputElement
) {}
initKeystrokeListener(): void {
fromEvent(this.inputElement, 'input')
.pipe(
tap(() => this.registerTypingEvent(true)),
catchError((error: Error) => {
console.error('Keystroke stream error:', error);
return [];
})
)
.subscribe(() => {
this.throttledBroadcast(true);
});
fromEvent(this.inputElement, 'blur')
.pipe(
tap(() => this.registerTypingEvent(false))
)
.subscribe(() => {
this.throttledBroadcast(false);
});
}
setActiveConversationId(conversationId: string): void {
this.activeConversationId = conversationId;
}
handleExternalTypingEvent(guestId: string, isTyping: boolean, version: number): void {
const now = Date.now();
const existing = this.typingState.get(guestId);
if (!existing || version > existing.version || (version === existing.version && now > existing.timestamp)) {
this.typingState.set(guestId, { isTyping, timestamp: now, version });
this.expireStaleEntries();
const activeTypers = Array.from(this.typingState.entries()).filter(([_, state]) => state.isTyping).length;
const newConsolidatedState = activeTypers > 0;
if (newConsolidatedState !== this.lastBroadcastState) {
this.lastBroadcastState = newConsolidatedState;
this.broadcastTypingIndicator(newConsolidatedState);
}
}
}
private registerTypingEvent(isTyping: boolean): void {
const now = Date.now();
const current = this.typingState.get(this.guestId) ?? { isTyping: false, timestamp: 0, version: 0 };
this.typingState.set(this.guestId, { isTyping, timestamp: now, version: current.version + 1 });
this.expireStaleEntries();
}
private expireStaleEntries(): void {
const now = Date.now();
for (const [guestId, state] of this.typingState.entries()) {
if (now - state.timestamp > this.TTL_MS) {
this.typingState.delete(guestId);
}
}
}
private throttledBroadcast(isTyping: boolean): void {
this.pendingState = isTyping;
if (this.throttleTimer !== null) return;
this.throttleTimer = setTimeout(() => {
this.throttleTimer = null;
this.resolveAndBroadcast(this.pendingState);
}, this.THROTTLE_MS);
}
private resolveAndBroadcast(isTyping: boolean): void {
this.registerTypingEvent(isTyping);
const activeTypers = Array.from(this.typingState.entries()).filter(([_, state]) => state.isTyping).length;
const hasActiveTypers = activeTypers > 0;
if (hasActiveTypers !== this.lastBroadcastState) {
this.lastBroadcastState = hasActiveTypers;
this.broadcastTypingIndicator(hasActiveTypers);
}
}
private async broadcastTypingIndicator(isTyping: boolean): Promise<void> {
if (!this.activeConversationId) {
console.warn('Cannot broadcast typing indicator: no active conversation ID');
return;
}
const message: TypingControlMessage = {
type: 'typingIndicator',
id: uuidv4(),
timestamp: Date.now(),
data: {
conversationId: this.activeConversationId,
guestId: this.guestId,
isTyping,
},
};
await this.sendWithRetry(message);
}
private async sendWithRetry(payload: TypingControlMessage, attempt = 1): Promise<void> {
try {
await this.client.send(payload);
} catch (error: unknown) {
const errorMessage = (error as Error).message;
if (errorMessage.includes('429') || errorMessage.includes('rate limit')) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
console.warn(`Rate limit hit. Retrying in ${delay}ms (attempt ${attempt}/${this.MAX_RETRIES})`);
if (attempt < this.MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, delay));
return this.sendWithRetry(payload, attempt + 1);
}
throw new Error('Max retry attempts exceeded for typing indicator broadcast');
}
if (errorMessage.includes('401') || errorMessage.includes('403')) {
throw new Error('Authentication failed. Reinitialize the Web Messaging client');
}
if (errorMessage.includes('WebSocket') || errorMessage.includes('network')) {
console.error('Transport failure during typing indicator send:', error);
throw error;
}
throw error;
}
}
}
// Initialization example
async function main() {
const config: WebMessagingConfig = {
clientId: 'YOUR_CLIENT_ID',
apiHost: 'YOUR_API_HOST.mypurecloud.com',
userId: 'guest-12345',
routingEmail: 'guest@example.com',
};
const client = new WebMessagingClient(config);
await client.connect();
const inputElement = document.getElementById('message-input') as HTMLInputElement;
if (!inputElement) {
throw new Error('Message input element not found');
}
const typingService = new TypingIndicatorService(client, config.userId, inputElement);
typingService.initKeystrokeListener();
client.on('message', (msg) => {
if (msg.type === 'conversationStarted') {
typingService.setActiveConversationId(msg.data.conversationId);
}
});
}
Common Errors & Debugging
Error: 401 Unauthorized
- What causes it: Invalid
clientId, expired refresh token, or mismatchedapiHostregion. - How to fix it: Verify the client ID matches the Web Messaging configuration in the Genesys Cloud admin console. Ensure the
apiHostmatches your deployment region. Reinitialize theWebMessagingClientto trigger a fresh token exchange. - Code showing the fix:
try {
await client.connect();
} catch (error: unknown) {
if ((error as Error).message.includes('401')) {
console.error('Token exchange failed. Check clientId and apiHost');
// Trigger UI re-authentication or fallback flow
}
}
Error: 429 Too Many Requests
- What causes it: Throttle window is too aggressive, or multiple browser tabs send uncoordinated typing events.
- How to fix it: Increase
THROTTLE_MSto 1500 or 2000. Ensure the merge conflict resolution strategy is active so only consolidated state changes trigger network requests. - Code showing the fix:
private readonly THROTTLE_MS = 1500; // Increased from 1000
// Verify resolveAndBroadcast is called instead of direct broadcast
Error: WebSocket Connection Drops
- What causes it: Network instability, idle timeout, or server-side gateway reset.
- How to fix it: Implement reconnection logic using the SDK’s
reconnect()method. Cache the last typing state and resume broadcasting after reconnection. - Code showing the fix:
client.on('disconnected', async () => {
console.warn('WebSocket dropped. Attempting reconnection');
try {
await client.reconnect();
} catch (error) {
console.error('Reconnection failed:', error);
}
});
Error: Duplicate or Flickering Typing Indicators
- What causes it: Missing version control in conflict resolution, or TTL expiration not cleaning stale entries.
- How to fix it: Ensure
handleExternalTypingEventcompares version numbers before overriding state. VerifyexpireStaleEntriesruns on every state change. - Code showing the fix:
// Already implemented in handleExternalTypingEvent with version comparison
if (!existing || version > existing.version || (version === existing.version && now > existing.timestamp)) {
// Override state
}