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?
| Piece | API name | Job |
|---|---|---|
| Thread object | Chat_Thread__c | One conversation: owner (agent or queue), customer contact, status, last message time |
| Message object | Chat_Message__c | Master-detail to the thread; body, direction (Inbound, Outbound, System), sender name, read time, and a unique client message ID |
| Platform event | Chat_Event__e | High volume, Publish After Commit; two fields, Thread_Id__c and Kind__c |
| Apex service | ChatService | With sharing; saves, reads and marks messages read, and publishes the event in the same call |
| Agent component | chatConsole | LWC on the thread record page; subscribes with lightning/empApi |
| Site component | chatWindow | LWC for signed-in Experience Cloud users; subscribes with CometD |
| Static resource | cometd | The CometD JavaScript file uploaded on its own |
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.
The same sequence, step by step:
- The agent console calls
ChatService.sendMessagewith the thread ID and the text. - Apex checks the user can see the thread and upserts the message on its client message ID.
- Apex publishes
Chat_Event__ewithThread_Id__c; the bus delivers it after the transaction commits. - Every subscriber on the channel receives the event.
- Each component compares the thread ID with the thread it has open and ignores the rest.
- The matching component calls
getMessages, which returns only rows the user may see. - When the reader has the thread on screen, the component calls
markRead, which stamps the read time and publishes areadevent. - 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.
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__ethrough 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 value | What the subscriber receives | Use in this template |
|---|---|---|
-1 (default) | New events published after it subscribes | Both browser components, after loading the thread |
-2 | Every stored event in the retention window, then new ones | Not used. Salesforce advises using it sparingly because it can slow performance |
| A stored replay ID | Every stored event after that one, then new ones | The 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?
| Allocation | Performance and Unlimited | Enterprise | Developer |
|---|---|---|---|
| Event deliveries to CometD, empApi, Pub/Sub API and event relays, last 24 hours | 50,000 | 25,000 | 10,000 |
| Events published per hour | 250,000 | 250,000 | 50,000 |
| Concurrent CometD clients, all channels | 2,000 | 1,000 | 20 |
| Platform event definitions | 100 | 50 | 5 |
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
PlatformEventUsageMetricobject 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.
| What happened | Fix |
|---|---|
| Every event refreshed every open chat | Compare 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 matched | Resolve the thread in Apex before publishing, and compare 18-character IDs |
A failed handshake retried forever because the callback lost this | Arrow functions, a cap of three attempts, backoff, then polling |
| The CometD library stopped loading from inside a zip static resource | Upload cometd.js as its own static resource |
| Double-clicks sent the same message twice | Disable Send during the call and upsert on a client message ID |
| The event went out before an attached file was saved | Publish after the files are linked to the message, in the same Apex call |
| A message trigger queried and launched a flow once per record | Keep 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.