mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
20 KiB
20 KiB
Async Notification Patterns in LWC
This guide covers real-time notification patterns for Lightning Web Components using Platform Events, Change Data Capture, and the Emp API.
Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ ASYNC NOTIFICATION ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ Platform Events ┌──────────────┐ │
│ │ Apex │ ─────────────────────────────▶│ LWC │ │
│ │ Queueable │ /event/My_Event__e │ empApi │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ Change Data Capture ┌──────────────┐ │
│ │ DML │ ─────────────────────────────▶│ LWC │ │
│ │ Operation │ /data/AccountChangeEvent │ empApi │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ Use Cases: │
│ • Queueable/Batch job completion notification │
│ • Real-time record updates across users │
│ • External system webhook processing notification │
│ • Background AI generation completion │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Emp API Basics
The lightning/empApi module enables LWC to subscribe to Streaming API channels.
Import and Setup
import { LightningElement } from 'lwc';
import { subscribe, unsubscribe, onError, setDebugFlag } from 'lightning/empApi';
export default class NotificationListener extends LightningElement {
subscription = null;
channelName = '/event/Job_Complete__e';
connectedCallback() {
// Enable debug logging (development only)
setDebugFlag(true);
// Register global error handler
this.registerErrorListener();
// Subscribe to channel
this.handleSubscribe();
}
disconnectedCallback() {
// CRITICAL: Always unsubscribe to prevent memory leaks
this.handleUnsubscribe();
}
}
Subscribe to Platform Events
async handleSubscribe() {
// Callback invoked when event received
const messageCallback = (response) => {
console.log('Event received:', JSON.stringify(response));
this.handleEvent(response.data.payload);
};
try {
// Subscribe and store reference
this.subscription = await subscribe(
this.channelName,
-1, // -1 = all new events, -2 = replay last 24h
messageCallback
);
console.log('Subscribed to:', JSON.stringify(this.subscription));
} catch (error) {
console.error('Subscribe error:', error);
}
}
handleUnsubscribe() {
if (this.subscription) {
unsubscribe(this.subscription, (response) => {
console.log('Unsubscribed:', JSON.stringify(response));
});
}
}
registerErrorListener() {
onError((error) => {
console.error('Streaming API error:', JSON.stringify(error));
// Attempt reconnection after delay
setTimeout(() => this.handleSubscribe(), 5000);
});
}
Pattern 1: Queueable Job Completion
Notify users when async Apex processing completes.
Platform Event Definition
<!-- force-app/main/default/objects/Job_Complete__e/Job_Complete__e.object-meta.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
<deploymentStatus>Deployed</deploymentStatus>
<eventType>StandardVolume</eventType>
<label>Job Complete</label>
<pluralLabel>Job Complete Events</pluralLabel>
<publishBehavior>PublishAfterCommit</publishBehavior>
<fields>
<fullName>Job_Id__c</fullName>
<label>Job ID</label>
<type>Text</type>
<length>50</length>
</fields>
<fields>
<fullName>User_Id__c</fullName>
<label>User ID</label>
<type>Text</type>
<length>18</length>
</fields>
<fields>
<fullName>Status__c</fullName>
<label>Status</label>
<type>Text</type>
<length>20</length>
</fields>
<fields>
<fullName>Message__c</fullName>
<label>Message</label>
<type>TextArea</type>
</fields>
<fields>
<fullName>Record_Id__c</fullName>
<label>Record ID</label>
<type>Text</type>
<length>18</length>
</fields>
</CustomObject>
Apex Queueable with Event Publishing
public class AIGenerationQueueable implements Queueable, Database.AllowsCallouts {
private Id recordId;
private Id userId;
public AIGenerationQueueable(Id recordId, Id userId) {
this.recordId = recordId;
this.userId = userId;
}
public void execute(QueueableContext context) {
String status;
String message;
try {
// Perform async work (AI generation, callout, etc.)
performGeneration();
status = 'SUCCESS';
message = 'AI content generated successfully';
} catch (Exception e) {
status = 'ERROR';
message = e.getMessage();
}
// Publish completion event
publishCompletionEvent(status, message);
}
private void publishCompletionEvent(String status, String message) {
Job_Complete__e event = new Job_Complete__e();
event.Job_Id__c = 'AI_GEN_' + recordId;
event.User_Id__c = userId;
event.Status__c = status;
event.Message__c = message;
event.Record_Id__c = recordId;
Database.SaveResult result = EventBus.publish(event);
if (!result.isSuccess()) {
System.debug('Event publish failed: ' + result.getErrors());
}
}
private void performGeneration() {
// AI generation logic...
}
}
LWC Listener Component
// jobCompletionListener.js
import { LightningElement, api } from 'lwc';
import { subscribe, unsubscribe, onError } from 'lightning/empApi';
import { ShowToastEvent } from 'lightning/platformShowToastEvent';
import { refreshApex } from '@salesforce/apex';
import userId from '@salesforce/user/Id';
export default class JobCompletionListener extends LightningElement {
@api recordId;
subscription = null;
channelName = '/event/Job_Complete__e';
// Reference to wired data for refresh
wiredResult;
connectedCallback() {
this.registerErrorListener();
this.subscribeToChannel();
}
disconnectedCallback() {
this.unsubscribeFromChannel();
}
async subscribeToChannel() {
if (this.subscription) return;
try {
this.subscription = await subscribe(
this.channelName,
-1,
(message) => this.handleMessage(message)
);
} catch (error) {
console.error('Subscription error:', error);
}
}
unsubscribeFromChannel() {
if (this.subscription) {
unsubscribe(this.subscription);
this.subscription = null;
}
}
handleMessage(message) {
const payload = message.data.payload;
// Filter: Only process events for current user and record
if (payload.User_Id__c !== userId) return;
if (payload.Record_Id__c !== this.recordId) return;
// Show toast notification
const variant = payload.Status__c === 'SUCCESS' ? 'success' : 'error';
this.dispatchEvent(new ShowToastEvent({
title: payload.Status__c === 'SUCCESS' ? 'Complete' : 'Error',
message: payload.Message__c,
variant: variant
}));
// Refresh data if successful
if (payload.Status__c === 'SUCCESS' && this.wiredResult) {
refreshApex(this.wiredResult);
}
// Dispatch custom event for parent components
this.dispatchEvent(new CustomEvent('jobcomplete', {
detail: {
jobId: payload.Job_Id__c,
status: payload.Status__c,
message: payload.Message__c
}
}));
}
registerErrorListener() {
onError((error) => {
console.error('EmpApi error:', JSON.stringify(error));
// Reconnect after delay
this.subscription = null;
setTimeout(() => this.subscribeToChannel(), 3000);
});
}
}
Pattern 2: Real-Time Record Updates (CDC)
Use Change Data Capture to sync UI when records change.
Enable CDC for Object
- Setup → Integrations → Change Data Capture
- Select objects to track
- Deploy (or enable via metadata)
CDC Channel Names
| Object Type | Channel Pattern |
|---|---|
| Standard Object | /data/AccountChangeEvent |
| Custom Object | /data/My_Object__ChangeEvent |
LWC CDC Listener
// recordChangeListener.js
import { LightningElement, api, wire } from 'lwc';
import { subscribe, unsubscribe, onError } from 'lightning/empApi';
import { getRecord, getFieldValue } from 'lightning/uiRecordApi';
import { refreshApex } from '@salesforce/apex';
const FIELDS = ['Account.Name', 'Account.Industry', 'Account.AnnualRevenue'];
export default class RecordChangeListener extends LightningElement {
@api recordId;
subscription = null;
channelName = '/data/AccountChangeEvent';
@wire(getRecord, { recordId: '$recordId', fields: FIELDS })
wiredRecord;
get accountName() {
return getFieldValue(this.wiredRecord.data, 'Account.Name');
}
connectedCallback() {
this.registerErrorListener();
this.subscribeToChanges();
}
disconnectedCallback() {
if (this.subscription) {
unsubscribe(this.subscription);
}
}
async subscribeToChanges() {
try {
this.subscription = await subscribe(
this.channelName,
-1,
(message) => this.handleChange(message)
);
} catch (error) {
console.error('CDC subscription error:', error);
}
}
handleChange(message) {
const payload = message.data.payload;
const changeType = payload.ChangeEventHeader.changeType;
const recordIds = payload.ChangeEventHeader.recordIds;
// Only process changes for our record
if (!recordIds.includes(this.recordId)) return;
console.log(`Record ${changeType}:`, payload);
// Refresh the wire
if (this.wiredRecord) {
refreshApex(this.wiredRecord);
}
// Notify parent
this.dispatchEvent(new CustomEvent('recordchange', {
detail: {
changeType,
changedFields: payload.ChangeEventHeader.changedFields,
payload
}
}));
}
registerErrorListener() {
onError((error) => {
console.error('CDC error:', error);
this.subscription = null;
setTimeout(() => this.subscribeToChanges(), 5000);
});
}
}
CDC Payload Structure
{
"data": {
"schema": "...",
"payload": {
"ChangeEventHeader": {
"entityName": "Account",
"recordIds": ["001xx000003ABCDE"],
"changeType": "UPDATE", // CREATE, UPDATE, DELETE, UNDELETE
"changedFields": ["Name", "Industry"],
"changeOrigin": "com/salesforce/api/soap/58.0",
"transactionKey": "...",
"sequenceNumber": 1,
"commitTimestamp": 1705612800000,
"commitUser": "005xx000001AAAAA",
"commitNumber": 123456789
},
// Changed field values
"Name": "Updated Account Name",
"Industry": "Technology"
}
}
}
Pattern 3: Multi-User Collaboration
Notify all users viewing the same record.
// collaborativeEditor.js
import { LightningElement, api } from 'lwc';
import { subscribe, unsubscribe, onError } from 'lightning/empApi';
import userId from '@salesforce/user/Id';
export default class CollaborativeEditor extends LightningElement {
@api recordId;
subscription = null;
channelName = '/event/Edit_Activity__e';
activeEditors = [];
connectedCallback() {
this.registerErrorListener();
this.subscribeToActivity();
this.announcePresence();
}
disconnectedCallback() {
this.announceExit();
this.unsubscribe();
}
async subscribeToActivity() {
this.subscription = await subscribe(
this.channelName,
-1,
(message) => this.handleActivity(message)
);
}
handleActivity(message) {
const { Record_Id__c, User_Id__c, User_Name__c, Action__c } = message.data.payload;
// Ignore our own events, only track this record
if (User_Id__c === userId || Record_Id__c !== this.recordId) return;
if (Action__c === 'JOINED') {
this.addEditor(User_Id__c, User_Name__c);
} else if (Action__c === 'LEFT') {
this.removeEditor(User_Id__c);
} else if (Action__c === 'EDITING') {
this.showEditingIndicator(User_Name__c, message.data.payload.Field__c);
}
}
addEditor(userId, userName) {
if (!this.activeEditors.find(e => e.id === userId)) {
this.activeEditors = [...this.activeEditors, { id: userId, name: userName }];
}
}
removeEditor(userId) {
this.activeEditors = this.activeEditors.filter(e => e.id !== userId);
}
// Call Apex to publish presence event
announcePresence() {
// publishEditActivity({ recordId: this.recordId, action: 'JOINED' });
}
announceExit() {
// publishEditActivity({ recordId: this.recordId, action: 'LEFT' });
}
unsubscribe() {
if (this.subscription) {
unsubscribe(this.subscription);
}
}
registerErrorListener() {
onError((error) => {
console.error('Collaboration error:', error);
});
}
}
Pattern 4: Polling Fallback
When empApi isn't available (Communities, some mobile contexts), use polling.
// pollingFallback.js
import { LightningElement, api } from 'lwc';
import checkJobStatus from '@salesforce/apex/JobStatusController.checkJobStatus';
export default class PollingFallback extends LightningElement {
@api jobId;
pollingInterval = null;
pollFrequencyMs = 3000;
maxAttempts = 60;
attemptCount = 0;
connectedCallback() {
this.startPolling();
}
disconnectedCallback() {
this.stopPolling();
}
startPolling() {
this.pollingInterval = setInterval(() => {
this.checkStatus();
}, this.pollFrequencyMs);
}
stopPolling() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
async checkStatus() {
this.attemptCount++;
if (this.attemptCount >= this.maxAttempts) {
this.stopPolling();
this.handleTimeout();
return;
}
try {
const result = await checkJobStatus({ jobId: this.jobId });
if (result.status === 'COMPLETE' || result.status === 'ERROR') {
this.stopPolling();
this.handleCompletion(result);
}
} catch (error) {
console.error('Polling error:', error);
}
}
handleCompletion(result) {
this.dispatchEvent(new CustomEvent('complete', { detail: result }));
}
handleTimeout() {
this.dispatchEvent(new CustomEvent('timeout'));
}
}
Replay Options
| Replay ID | Behavior |
|---|---|
-1 |
Only new events (after subscription) |
-2 |
All stored events (last 24 hours) + new |
| Specific ID | Events after that replay ID |
Storing Replay Position
// For durable subscriptions, store last processed replay ID
handleMessage(message) {
const replayId = message.data.event.replayId;
// Process message...
// Store replay ID for recovery
this.lastReplayId = replayId;
localStorage.setItem('myapp_replay_id', replayId);
}
connectedCallback() {
// Recover from stored position
const storedReplayId = localStorage.getItem('myapp_replay_id');
const replayFrom = storedReplayId ? parseInt(storedReplayId, 10) : -1;
this.subscribeFromReplayId(replayFrom);
}
Best Practices
DO
| Practice | Reason |
|---|---|
Always unsubscribe in disconnectedCallback |
Prevents memory leaks |
| Filter events by user/record ID | Reduces unnecessary processing |
| Register error handler | Enables reconnection on failure |
Use refreshApex after events |
Keeps wire data in sync |
| Set reasonable replay ID | -1 for real-time, -2 for recovery |
DON'T
| Anti-Pattern | Problem |
|---|---|
| Subscribe without unsubscribing | Memory leak, duplicate handlers |
| Process all events without filtering | Performance impact |
| Ignore error handler | Silent failures, no reconnection |
| Mutate wire data directly | Breaks reactivity |
| Use CDC for high-frequency updates | Rate limits, performance |
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
| No events received | Subscription failed silently | Check error handler, verify channel name |
| Duplicate processing | Component re-mounted | Track subscription state, dedupe by replay ID |
| Events delayed | High server load | Normal behavior, events are async |
| "Handshake denied" | Missing permissions | Verify Streaming API access in profile |
| Events stop after time | Session expired | Handle error event, resubscribe |
Cross-Skill References
| Topic | Resource |
|---|---|
| Platform Event definition | integration-connectivity-generate/references/platform-events-guide.md |
| Publishing from Apex | sf-apex/references/best-practices.md |
| State management | state-management.md |
| Agentforce Models API | agentforce-generate/references/models-api.md |