Chat with typing indicators and online status
Support chat, marketplace messaging and team rooms: messages that arrive at once, “typing…” hints between browsers, and an online list that stays correct.
Chat is where people feel latency most. A message that shows up a second late, or a "typing…" hint that never appears, makes a product feel unattended. Support chat, marketplace messaging between buyers and sellers, team rooms and in-game chat all need the same three things: messages that arrive at once, typing indicators, and an honest online list.
What your users get
- Messages that appear for everyone in the room the moment they are sent, without a refresh.
- "Ann is typing…" while someone writes a reply.
- A green dot next to the people who are in the conversation right now.
Built with
- Presence channels (
presence-), which keep the list of members in a room correct across every node. - Client events (
client-*, Laravel Echowhisper) for typing indicators that go from browser to browser without touching your server. - Socket exclusion (
socket_id, LaraveltoOthers()), so the sender does not receive their own message twice. - Webhooks (
member_added,member_removed) when your server should react to people joining or leaving.
Step 1: authorize the room
Only members of a conversation may join its channel. Your authorization route decides, and returns the user's details for the member list:
app.post('/pusher/auth', requireLogin, express.urlencoded({ extended: false }), async (req, res) => {
const { socket_id, channel_name } = req.body;
const room = channel_name.replace(/^presence-room-/, '');
if (!(await db.rooms.isMember(room, req.user.id))) return res.sendStatus(403);
res.json(pusher.authorizeChannel(socket_id, channel_name, {
user_id: String(req.user.id),
user_info: { name: req.user.name, avatar: req.user.avatarUrl },
}));
});Step 2: join, list members and show typing
Turn on Allow client events in the app settings, then:
const room = pusher.subscribe('presence-room-42');
room.bind('pusher:subscription_succeeded', (members) => {
members.each((m) => online.add(m.id, m.info));
});
room.bind('pusher:member_added', (m) => online.add(m.id, m.info));
room.bind('pusher:member_removed', (m) => online.remove(m.id));
// Typing: at most one event every 2 seconds while the user writes
let last = 0;
input.addEventListener('input', () => {
if (Date.now() - last < 2000) return;
last = Date.now();
room.trigger('client-typing', { name: me.name });
});
room.bind('client-typing', ({ name }) => typing.show(name, 3000));
room.bind('message', (m) => messages.append(m));Step 3: send messages through your server
Messages go through your server, so they are saved, moderated and only then broadcast. Pass the sender's socket ID so their own tab, which already shows the message, is skipped:
app.post('/rooms/:id/messages', requireLogin, async (req, res) => {
const msg = await db.messages.create({ room: req.params.id, user: req.user.id, text: req.body.text });
await pusher.trigger('presence-room-' + req.params.id, 'message', msg, { socket_id: req.body.socket_id });
res.json(msg);
});The browser sends pusher.connection.socket_id with the request.
The same in Laravel
// routes/channels.php
Broadcast::channel('room.{id}', function ($user, $id) {
return $user->rooms()->whereKey($id)->exists() ? ['id' => $user->id, 'name' => $user->name] : false;
});
// In the controller
broadcast(new MessageSent($message))->toOthers();Echo.join(`room.${roomId}`)
.here((users) => online.set(users))
.joining((user) => online.add(user))
.leaving((user) => online.remove(user))
.listen('MessageSent', (e) => messages.append(e.message))
.listenForWhisper('typing', (e) => typing.show(e.name, 3000));
Echo.join(`room.${roomId}`).whisper('typing', { name: me.name });Tips
- Presence channels hold up to 100 members. For bigger rooms, such as a live stream chat, see live audiences.
- Throttle client events in the browser: they are rate limited per connection, and each one counts as a message.
- To notify people who have left the room, combine this with in-app notifications and push.