afv-library/skills/experience-lwc-generate/references/async-notification-patterns.md

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

  1. Setup → Integrations → Change Data Capture
  2. Select objects to track
  3. 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