Build a Real-Time Chat in Salesforce with Platform Events and CometD (LWC Template)

One message object, one Platform Event carrying only a thread ID, and LWC subscribers that re-query on each event, with replay IDs, limits and safe site auth.

By Published

This is the template we would hand a Salesforce developer who has been asked for chat inside the org: an agent console on the record page, a chat window on an Experience Cloud site, read receipts, and no messaging vendor in between. It is a generic version of the production build in our real-time patient chat case study, with every name changed so it fits any org. Everything below is code you can adapt; the screenshots are mocks.

What does the chat template contain?

The pieces of the Platform Events chat template
PieceAPI nameJob
Thread objectChat_Thread__cOne conversation: owner (agent or queue), customer contact, status, last message time
Message objectChat_Message__cMaster-detail to the thread; body, direction (Inbound, Outbound, System), sender name, read time, and a unique client message ID
Platform eventChat_Event__eHigh volume, Publish After Commit; two fields, Thread_Id__c and Kind__c
Apex serviceChatServiceWith sharing; saves, reads and marks messages read, and publishes the event in the same call
Agent componentchatConsoleLWC on the thread record page; subscribes with lightning/empApi
Site componentchatWindowLWC for signed-in Experience Cloud users; subscribes with CometD
Static resourcecometdThe CometD JavaScript file uploaded on its own
Illustration: the agent console. Unread counts on the thread list, a read time under the agent's reply, and a system note that customers never see. Mock screen with generic data.

Why does the event carry only a thread ID?

Because every subscriber on a channel receives every event. The browser filters after delivery, so a message body in the payload reaches every open console and every signed-in site visitor subscribed to the channel, whether or not the thread is theirs. With only the thread ID, the component has to ask Apex for the messages, and Apex running WITH USER_MODE returns only rows that person is allowed to see.

Three more things come free with that design. Ordering is whatever the query says, so out-of-order events do not scramble the thread. A missed event costs nothing, because the next query catches up. And the payload stays far under the 1 MB maximum event message size (Common Platform Event Allocations). Publish After Commit means subscribers never hear about a message whose save rolled back.

Illustration: one message from send to read receipt. The event carries the thread ID only, and each subscriber decides for itself whether to re-query.

The same sequence, step by step:

  1. The agent console calls ChatService.sendMessage with the thread ID and the text.
  2. Apex checks the user can see the thread and upserts the message on its client message ID.
  3. Apex publishes Chat_Event__e with Thread_Id__c; the bus delivers it after the transaction commits.
  4. Every subscriber on the channel receives the event.
  5. Each component compares the thread ID with the thread it has open and ignores the rest.
  6. The matching component calls getMessages, which returns only rows the user may see.
  7. When the reader has the thread on screen, the component calls markRead, which stamps the read time and publishes a read event.
  8. The sender's view re-queries and shows the read time.

How do you define the event and publish it from Apex?

<!-- objects/Chat_Event__e/Chat_Event__e.object-meta.xml -->
<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">
    <deploymentStatus>Deployed</deploymentStatus>
    <eventType>HighVolume</eventType>
    <label>Chat Event</label>
    <pluralLabel>Chat Events</pluralLabel>
    <publishBehavior>PublishAfterCommit</publishBehavior>
</CustomObject>
<!-- Two fields: Thread_Id__c (Text 18) and Kind__c (Text 20: message | read) -->

Publish from the Apex method that saves the message, not from a trigger on every insert. The method already knows the thread and the sender, and a bulk import or data fix that inserts messages does not ring every open chat. The service below does the write and the publish in one transaction, enforces sharing on every query, and uses a client-generated message ID so a retried send cannot create a second message.

public with sharing class ChatService {

    @AuraEnabled
    public static Id sendMessage(Id threadId, String body, String clientMessageId) {
        if (String.isBlank(body) || body.length() > 4000) {
            throw new AuraHandledException('Message is empty or too long.');
        }
        // Throws if the running user cannot see this thread.
        Chat_Thread__c thread = [
            SELECT Id FROM Chat_Thread__c WHERE Id = :threadId WITH USER_MODE
        ];
        Chat_Message__c msg = new Chat_Message__c(
            Thread__c = thread.Id,
            Body__c = body,
            Direction__c = isInternal() ? 'Outbound' : 'Inbound',
            Sender_Name__c = UserInfo.getName(),
            Client_Message_Id__c = clientMessageId // unique external ID
        );
        // A retried send updates the same row instead of adding a second one.
        Database.upsert(msg, Chat_Message__c.Client_Message_Id__c, true, AccessLevel.USER_MODE);

        // Publish After Commit: subscribers hear about it only if the save commits.
        Database.SaveResult result = EventBus.publish(
            new Chat_Event__e(Thread_Id__c = thread.Id, Kind__c = 'message')
        );
        if (!result.isSuccess()) {
            // Write this to your error-log object. A failed publish looks
            // exactly like a customer who stopped replying.
            System.debug(LoggingLevel.ERROR, result.getErrors());
        }
        return msg.Id;
    }

    @AuraEnabled
    public static List<Chat_Message__c> getMessages(Id threadId) {
        Set<String> visible = isInternal()
            ? new Set<String>{ 'Inbound', 'Outbound', 'System' }
            : new Set<String>{ 'Inbound', 'Outbound' }; // system notes stay internal
        return [
            SELECT Id, Body__c, Direction__c, Sender_Name__c, Read_At__c, CreatedDate
            FROM Chat_Message__c
            WHERE Thread__c = :threadId AND Direction__c IN :visible
            WITH USER_MODE
            ORDER BY CreatedDate DESC
            LIMIT 200
        ];
    }

    @AuraEnabled
    public static void markRead(Id threadId) {
        String incoming = isInternal() ? 'Inbound' : 'Outbound';
        List<Chat_Message__c> unread = [
            SELECT Id FROM Chat_Message__c
            WHERE Thread__c = :threadId AND Direction__c = :incoming AND Read_At__c = null
            WITH USER_MODE
        ];
        if (unread.isEmpty()) {
            return; // nothing new, so no event and no refresh loop
        }
        for (Chat_Message__c m : unread) {
            m.Read_At__c = System.now();
        }
        Database.update(unread, AccessLevel.USER_MODE);
        EventBus.publish(new Chat_Event__e(Thread_Id__c = threadId, Kind__c = 'read'));
    }

    private static Boolean isInternal() {
        return UserInfo.getUserType() == 'Standard';
    }
}

How does the agent console subscribe?

Inside Lightning Experience, use lightning/empApi. It subscribes over a CometD connection that Salesforce shares across components for one user, so there is no session to fetch or store. It is supported in desktop browsers, requires API version 44.0 or later, and works only on the main window, not in a utility bar pop-out (Lightning Component Reference, read 16 October 2026).

// lwc/chatConsole/chatConsole.js: on the Chat_Thread__c record page
import { LightningElement, api } from 'lwc';
import { subscribe, unsubscribe, onError } from 'lightning/empApi';
import getMessages from '@salesforce/apex/ChatService.getMessages';
import markRead from '@salesforce/apex/ChatService.markRead';

const CHANNEL = '/event/Chat_Event__e';

export default class ChatConsole extends LightningElement {
    @api recordId; // 18-character Chat_Thread__c Id
    messages = [];
    subscription;
    pending;

    async connectedCallback() {
        onError((error) => console.error('Chat channel error', JSON.stringify(error)));
        await this.refresh(); // load first: the records are the source of truth
        this.subscription = await subscribe(CHANNEL, -1, (event) => {
            if (event?.data?.payload?.Thread_Id__c === this.recordId) {
                this.scheduleRefresh();
            }
        });
    }

    disconnectedCallback() {
        if (this.subscription) {
            unsubscribe(this.subscription);
        }
        clearTimeout(this.pending);
    }

    // A message and its read receipt arrive close together: one query, not two.
    scheduleRefresh() {
        clearTimeout(this.pending);
        // eslint-disable-next-line @lwc/lwc/no-async-operation
        this.pending = setTimeout(() => this.refresh(), 250);
    }

    async refresh() {
        const rows = await getMessages({ threadId: this.recordId });
        this.messages = [...rows].reverse();
        if (document.visibilityState === 'visible') {
            await markRead({ threadId: this.recordId });
        }
    }
}

Subscribe with -1 (new events only) and load the thread before subscribing. Each logged-in user on empApi counts as one concurrent CometD client, and several tabs share that one connection (Common Platform Event Allocations).

How do signed-in site users subscribe?

Salesforce does not recommend empApi for apps or sites with a large number of users, such as Experience Cloud sites, because each user counts against the concurrent CometD client limit (Subscribe to Platform Event Notifications in a Lightning Component). On a site, load the CometD library from a static resource and authenticate the handshake as the signed-in user, with that user's own session. Salesforce's Streaming API guide notes that each logged-in site user gets their own Streaming API session (Using an Experience Cloud Site with Streaming API-Based Features).

public with sharing class ChatSiteSession {
    // Returns the signed-in site user's own session, which carries exactly
    // that user's access. Guests get nothing.
    @AuraEnabled
    public static String forSignedInUser() {
        if (Auth.CommunitiesUtil.isGuestUser()) {
            throw new AuraHandledException('Sign in to use live chat.');
        }
        return UserInfo.getSessionId();
    }
}
// lwc/chatWindow/chatWindow.js: Experience Cloud, signed-in users only
import { LightningElement, api } from 'lwc';
import { loadScript } from 'lightning/platformResourceLoader';
import COMETD from '@salesforce/resourceUrl/cometd'; // cometd.js on its own, not in a zip
import forSignedInUser from '@salesforce/apex/ChatSiteSession.forSignedInUser';
import getMessages from '@salesforce/apex/ChatService.getMessages';

const CHANNEL = '/event/Chat_Event__e';
const MAX_ATTEMPTS = 3;
const POLL_MS = 15000;

export default class ChatWindow extends LightningElement {
    @api threadId;
    messages = [];
    attempts = 0;
    cometd;
    poller;

    async connectedCallback() {
        await loadScript(this, COMETD);
        await this.refresh();
        this.connect();
    }

    async connect() {
        const session = await forSignedInUser();
        const cometd = new window.org.cometd.CometD();
        cometd.configure({
            // Sites served under a path prefix may need it here; test in a sandbox.
            url: `${window.location.origin}/cometd/68.0/`,
            requestHeaders: { Authorization: `OAuth ${session}` },
            appendMessageTypeToURL: false,
        });
        cometd.websocketEnabled = false;
        cometd.handshake((reply) => {
            if (!reply.successful) {
                this.retry();
                return;
            }
            this.attempts = 0;
            cometd.subscribe(CHANNEL, (event) => {
                if (event?.data?.payload?.Thread_Id__c === this.threadId) {
                    this.refresh();
                }
            });
        });
        this.cometd = cometd;
    }

    // Arrow functions keep `this`; the cap stops a loop that never ends.
    retry() {
        if (this.cometd) {
            this.cometd.disconnect();
        }
        this.attempts += 1;
        if (this.attempts > MAX_ATTEMPTS) {
            this.startPolling(); // includes "403::Organization total events daily limit exceeded"
            return;
        }
        // eslint-disable-next-line @lwc/lwc/no-async-operation
        setTimeout(() => this.connect(), 1000 * 2 ** this.attempts);
    }

    startPolling() {
        // eslint-disable-next-line @lwc/lwc/no-async-operation
        this.poller = setInterval(() => this.refresh(), POLL_MS);
    }

    async refresh() {
        const rows = await getMessages({ threadId: this.threadId });
        this.messages = [...rows].reverse();
    }

    disconnectedCallback() {
        if (this.cometd) {
            this.cometd.disconnect();
        }
        clearInterval(this.poller);
    }
}

Test the handshake on a sandbox copy of your site before you rely on it, including what happens when the session expires mid-chat. The polling fallback is there for that case and for the day the org runs out of delivery allocation.

Illustration: the site chat for a signed-in customer. Read and Delivered come from the read time on each message record. Mock screen with generic data.

Free consultation

Put your customer conversations on the record they belong to.

How do guests chat if they cannot sign in?

Guests chat without a browser subscription and without any shared credential in the page. A browser can only subscribe to the event bus with a token, and any token sent to a browser belongs to whoever is using that browser: they can open the developer tools, copy it and call the Salesforce API directly. If that token belongs to an integration user, they get the integration user's access to every object it can read or edit, well beyond one chat thread, until the token expires or is revoked. Checks work the same way. Anything the page script compares can be skipped by calling Apex directly, so identity checks and attempt limits run in Apex, with the counts stored on the server.

Two patterns keep guests out of the event bus and still feel live:

  • Token-guarded polling. The guest's link carries a random token. A narrow Apex method takes the token, compares its hash with the one stored on the thread, checks expiry and failed attempts, and returns that one thread's messages. The page polls it every few seconds while the tab is visible. The guest user profile needs no access to the chat objects.
  • A server-side relay. A small service you run subscribes to Chat_Event__e through the Pub/Sub API with credentials stored on that server. Browsers connect to the relay over WebSocket or server-sent events with a short-lived ticket that Apex issued after verifying the visitor, and the relay forwards only “thread changed” for the thread on that ticket. The browser then fetches messages through the token-guarded Apex method. The integration credentials never leave the server.
// Issue a link token: keep only its hash on the thread.
String token = EncodingUtil.convertToHex(Crypto.generateAesKey(256));
thread.Access_Token_Hash__c = EncodingUtil.convertToHex(
    Crypto.generateDigest('SHA-256', Blob.valueOf(token))
);
thread.Access_Token_Expires__c = System.now().addDays(7);
// Send the token in the link. Apex compares hashes on every request,
// counts failures on the thread record and rejects expired tokens.

How do replay IDs work, and does the chat need them?

Salesforce keeps high-volume platform events for 72 hours. Each delivered event carries a replay ID marking its position, and a subscriber can resubscribe from a stored one to receive what it missed (Message Durability, Streaming API Developer Guide, read 16 October 2026).

Replay options for a Platform Events subscriber
Replay valueWhat the subscriber receivesUse in this template
-1 (default)New events published after it subscribesBoth browser components, after loading the thread
-2Every stored event in the retention window, then new onesNot used. Salesforce advises using it sparingly because it can slow performance
A stored replay IDEvery stored event after that one, then new onesThe relay: store the last ID and resume from it after a disconnect

Store replay IDs as opaque strings. Salesforce says they are not guaranteed to be contiguous, that subscribers must not compute new ones from a stored value, and that the EventUuid field, not the replay ID, is the unique identifier for an event message. The browser components skip replay on purpose: a chat window that reconnects re-queries the thread, and the records are complete whether or not any event arrived.

Which Platform Event limits does a chat hit first?

Default Platform Event allocations by edition
AllocationPerformance and UnlimitedEnterpriseDeveloper
Event deliveries to CometD, empApi, Pub/Sub API and event relays, last 24 hours50,00025,00010,000
Events published per hour250,000250,00050,000
Concurrent CometD clients, all channels2,0001,00020
Platform event definitions100505

Sources, read 16 October 2026: Default Platform Event Allocations (the Enterprise column also covers Professional Edition with the API add-on for delivery and publishing) and Common Platform Event Allocations. Apex triggers and flows that subscribe do not count against the delivery allocation.

Delivery is the one a chat reaches first, because it is counted once for every subscribed client. Salesforce's own example: 20,000 events delivered to two clients uses 40,000 of the allocation. As a worked example, if 40 people have the chat open and each message produces two events (the message and its read receipt), each message costs 80 deliveries, and an Enterprise Edition org's 25,000 covers about 312 messages in 24 hours. Three things keep that in check:

  • Subscribe only where a thread is open, and unsubscribe in disconnectedCallback.
  • Watch usage with the PlatformEventUsageMetric object before users find the limit for you.
  • At larger scale, use the relay: one Pub/Sub API subscriber receives each event once and fans it out over your own connection.

When the allocation runs out, CometD subscribers are disconnected with 403::Organization total events daily limit exceeded. The site component above falls back to polling when that happens.

What breaks in production chat builds?

The failures below came up in the production build this template comes from, and in chat code we have reviewed elsewhere. Each one is handled in the code above.

Production failures and the fix in the template
What happenedFix
Every event refreshed every open chatCompare Thread_Id__c with the open thread before querying
A component on a related record published that record's ID, so the other side never matchedResolve the thread in Apex before publishing, and compare 18-character IDs
A failed handshake retried forever because the callback lost thisArrow functions, a cap of three attempts, backoff, then polling
The CometD library stopped loading from inside a zip static resourceUpload cometd.js as its own static resource
Double-clicks sent the same message twiceDisable Send during the call and upsert on a client message ID
The event went out before an attached file was savedPublish after the files are linked to the message, in the same Apex call
A message trigger queried and launched a flow once per recordKeep publishing out of the trigger, and bulkify whatever stays in it

Two features from that build are worth adding once the core works. Photos taken on iPhones arrive as HEIC, which many desktop browsers cannot display, so convert them to JPEG in the browser before upload. And a scheduled flow can send an SMS reminder when a customer leaves a message unread past a threshold, with consent checked first.

When does building chat on Platform Events make sense?

When Salesforce is already the system your team works in, when messages need to drive automation and reporting as records, and when the number of people online at once fits the allocations above. If conversations are standalone, if you need channels such as WhatsApp or SMS threads from day one, or if nobody on your side will own custom code, a messaging product is the better buy. The decision is worked through in the case study behind this template. For an AI layer in front of the same thread, see Salesforce AI chatbot architecture; for Platform Events as a buffer for high-volume integrations, see platform events as the shock absorber for webhooks; and for a second event-driven pattern on patient records, see who viewed this patient record.

Frequently asked questions

Can you build real-time chat in Salesforce without a messaging product?

Yes. Store messages as records, publish a Platform Event when one is saved, and subscribe from Lightning Web Components. The pattern suits care teams, account teams and support desks of modest size. Consumer-scale chat with thousands of people online at once hits the event delivery and concurrent client allocations, and a messaging product or a relay fits better there.

Should I use lightning/empApi or CometD?

For internal users in Lightning Experience, use lightning/empApi: it shares one CometD connection per user and handles the session for you. For signed-in Experience Cloud users, Salesforce does not recommend empApi on sites with many users because of the concurrent client limit, so a CometD client loaded from a static resource, authenticated as that user, is the usual route.

Can guest users subscribe to Platform Events?

Do not let them. A browser can only subscribe with a token, and any token a guest's browser holds can be copied. Give guests a token-guarded Apex method they poll, or a server-side relay that subscribes with credentials kept on the server and pushes only a thread changed signal to the browser.

Should the Platform Event carry the message text?

No. Every subscriber on the channel receives every event, and filtering happens in the browser after delivery. Send only the thread ID and have the component fetch the messages through Apex that enforces sharing, so a person only ever sees messages they are allowed to see.

What happens to messages sent while a subscriber is offline?

High-volume platform events are kept for 72 hours and can be replayed with a stored replay ID. For a chat window the simpler answer is to re-query the thread when the component connects, because the messages are records and the database already has them.

Continue the decision

View all resources →

Free consultation

Put your customer conversations on the record they belong to.

Free, 30 minutes, no obligation.

  • You leave with
  • Our first read on where your operation leaks time, live on the call
  • A straight answer on whether a full systems audit is worth it for you
  • If it is: the scope and the fixed price. If not: what to do instead