Realtime Features - nself-org/nchat GitHub Wiki
Complete guide to real-time features in nself-chat, including WebSocket connections, presence tracking, typing indicators, and message delivery.
- Overview
- WebSocket Connection
- Presence System
- Typing Indicators
- Connection Status
- Message Delivery
- API Reference
- Examples
nself-chat provides production-ready real-time features built on Socket.io with:
- WebSocket Optimization - Connection pooling, message batching, compression
- Auto-Reconnection - Exponential backoff with connection quality monitoring
- Presence Tracking - Online/away/DND status with last seen timestamps
- Typing Indicators - Debounced, throttled typing events
- Message Delivery - Sent/delivered/read receipts
- Connection Quality - Latency monitoring and quality indicators
The WebSocket layer uses a singleton pattern with connection pooling:
import { socketManager } from '@/lib/realtime'
// Connect (returns existing connection if available)
const socket = socketManager.connect(authToken)
// Emit events
socketManager.emit('message:new', { channelId, content })
// Subscribe to events
const unsubscribe = socketManager.on('message:new', (data) => {
console.log('New message:', data)
})
// Disconnect
socketManager.disconnect()- Maintains pool of idle connections for instant reconnect
- Round-robin load balancing for multiple connections
- Automatic cleanup of idle connections (5-minute timeout)
- Batches messages sent within 50ms window
- Maximum batch size: 10 messages
- Reduces network overhead for rapid operations
- Per-message deflate compression for payloads > 1KB
- Reduces bandwidth usage by ~60% for text messages
- 30-second heartbeat interval to keep connections alive
- Latency measurement via ping/pong
- Automatic reconnection on missed heartbeats
- Initial delay: 1000ms
- Max delay: 5000ms
- Max attempts: 5
- Auto-reconnect on unexpected disconnect
import { useSocket } from '@/hooks/use-socket'
function ChatComponent() {
const { isConnected, emit, subscribe, socketId } = useSocket()
useEffect(() => {
if (!isConnected) return
const unsubscribe = subscribe('message:new', (message) => {
console.log('New message:', message)
})
return unsubscribe
}, [isConnected, subscribe])
const sendMessage = () => {
emit('message:new', { content: 'Hello!' })
}
}Track user online status with automatic away detection and last seen timestamps.
- online - User is active and available
- away - User is inactive (auto-set after 5 minutes)
- dnd - Do Not Disturb (manually set)
- offline - User is disconnected
import { usePresence } from '@/hooks/use-presence'
function PresenceDemo() {
const {
presence,
getPresence,
setOnline,
setAway,
setDnd,
setOffline,
setCustomStatus,
currentStatus,
} = usePresence(['user-1', 'user-2', 'user-3'])
// Get presence for specific user
const user1Presence = getPresence('user-1')
console.log(user1Presence.status) // 'online' | 'away' | 'dnd' | 'offline'
console.log(user1Presence.lastSeen) // ISO timestamp
// Set own status
return (
<div>
<button onClick={setOnline}>Set Online</button>
<button onClick={setAway}>Set Away</button>
<button onClick={setDnd}>Set DND</button>
<button onClick={() => setCustomStatus('In a meeting')}>
Set Custom Status
</button>
</div>
)
}Automatically sets status to "away" after inactivity:
- Default timeout: 5 minutes
- Activity events: mousedown, keydown, touchstart, mousemove
- Visibility tracking: Sets away when tab is hidden
- Online/offline: Automatically updates on network changes
const { recordActivity } = usePresence(userIds, {
autoAwayTimeout: 300000, // 5 minutes
heartbeatInterval: 30000, // 30 seconds
trackLastSeen: true,
})
// Manually record activity
recordActivity()import { PresenceIndicator } from '@/components/user/PresenceIndicator'
;<PresenceIndicator
userId="user-123"
size="md"
position="bottom-right"
showTooltip
showLastSeen
animate
/>import { PresenceBadge } from '@/components/user/PresenceIndicator'
;<PresenceBadge userId="user-123" showLabel showCustomStatus />import { PresenceSelector } from '@/components/user/PresenceIndicator'
;<PresenceSelector value={currentStatus} onChange={(status) => updateOwnPresence(status)} />Show who is typing with debounced, throttled indicators.
- Debouncing - 300ms debounce before sending typing event
- Throttling - 2-second minimum between typing events
- Auto-timeout - Removes typing indicator after 5 seconds of inactivity
- Multi-user - Shows up to 3 users typing, with overflow count
import { useTyping } from '@/hooks/use-typing'
function MessageInput({ channelId }) {
const {
typingUsers,
handleTyping,
forceStopTyping,
} = useTyping(channelId)
const handleChange = (e) => {
const value = e.target.value
setValue(value)
// Trigger typing indicator on every keystroke
if (value.trim()) {
handleTyping()
} else {
forceStopTyping()
}
}
const handleSend = () => {
// Stop typing immediately on send
forceStopTyping()
sendMessage()
}
return (
<div>
<input onChange={handleChange} />
<TypingIndicator users={typingUsers} />
</div>
)
}import { TypingIndicator } from '@/components/chat/typing-indicator'
;<TypingIndicator users={typingUsers} maxAvatars={3} />Output: "Alice, Bob, and Charlie are typing..."
import { InlineTypingIndicator } from '@/components/chat/typing-indicator'
;<InlineTypingIndicator users={typingUsers} />Shows avatar(s) with animated typing bubble.
import { MessageInputWithTyping } from '@/components/chat/MessageInputWithTyping'
;<MessageInputWithTyping
channelId="channel-123"
onSendMessage={(content) => sendMessage(content)}
placeholder="Type a message..."
maxLength={2000}
showCharCount
autoFocus
/>Display WebSocket connection state with quality indicators.
import { ConnectionStatus } from '@/components/realtime/ConnectionStatus'
;<ConnectionStatus show={true} position="top-right" showStats compact={false} />import { InlineConnectionStatus } from '@/components/realtime/ConnectionStatus'
;<InlineConnectionStatus showLabel />import { ConnectionQualityBar } from '@/components/realtime/ConnectionStatus'
;<ConnectionQualityBar />Shows 4 bars indicating connection quality:
- 4 bars (green): Excellent (< 100ms)
- 3 bars (yellow): Good (100-300ms)
- 2 bars (orange): Poor (> 300ms)
- 0 bars: Offline
import { ConnectionStatusCard } from '@/components/realtime/ConnectionStatus'
;<ConnectionStatusCard />Full card showing:
- Connection status
- Quality indicator
- Latency
- Uptime
- Socket ID
import { useRealtime } from '@/contexts/realtime-context'
function Component() {
const {
connectionState, // 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
isConnected,
socketId,
reconnectAttempts,
lastError,
reconnect,
disconnect,
clearError,
} = useRealtime()
return (
<div>
<p>Status: {connectionState}</p>
<p>Attempts: {reconnectAttempts}</p>
{lastError && <p>Error: {lastError.message}</p>}
<button onClick={reconnect}>Reconnect</button>
</div>
)
}Track message delivery status with sent/delivered/read receipts.
import { SOCKET_EVENTS } from '@/lib/realtime'
// Message sent acknowledgement
socket.on(SOCKET_EVENTS.MESSAGE_SENT, (data) => {
console.log('Message sent:', data.messageId)
})
// Message delivered (received by recipient)
socket.on(SOCKET_EVENTS.MESSAGE_DELIVERED, (data) => {
console.log('Delivered to:', data.deliveredCount, 'users')
})
// Message read
socket.on(SOCKET_EVENTS.MESSAGE_READ, (data) => {
console.log('Read by:', data.userId)
})
// Message failed
socket.on(SOCKET_EVENTS.MESSAGE_FAILED, (data) => {
console.error('Failed:', data.errorMessage)
if (data.retryable) {
// Retry sending
}
})interface MessageSentPayload {
clientMessageId: string // Client-side ID for optimistic updates
messageId: string // Server-assigned ID
sentAt: string // ISO timestamp
}
interface MessageDeliveredPayload {
messageId: string
deliveredCount?: number // For group chats
totalRecipients?: number
deliveredAt: string
}
interface MessageReadPayload {
messageId: string
userId: string // User who read it
readCount?: number // For group chats
totalRecipients?: number
readAt: string
}
interface MessageFailedPayload {
clientMessageId: string
errorCode: string
errorMessage: string
retryable: boolean
}export const SOCKET_EVENTS = {
// Connection
CONNECT: 'connect',
DISCONNECT: 'disconnect',
ERROR: 'error',
// Messages
MESSAGE_NEW: 'message:new',
MESSAGE_UPDATE: 'message:update',
MESSAGE_DELETE: 'message:delete',
MESSAGE_TYPING: 'message:typing',
// Message Delivery
MESSAGE_SENT: 'message:sent',
MESSAGE_DELIVERED: 'message:delivered',
MESSAGE_READ: 'message:read',
MESSAGE_FAILED: 'message:failed',
MESSAGE_ACK: 'message:ack',
// Presence
PRESENCE_UPDATE: 'presence:update',
PRESENCE_SUBSCRIBE: 'presence:subscribe',
// Channels
CHANNEL_JOIN: 'channel:join',
CHANNEL_LEAVE: 'channel:leave',
CHANNEL_UPDATE: 'channel:update',
// Reactions
REACTION_ADD: 'reaction:add',
REACTION_REMOVE: 'reaction:remove',
}// WebSocket config
export const SOCKET_CONFIG = {
url: process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3001',
options: {
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
timeout: 10000,
},
}
// Typing config
const typingConfig = {
debounceMs: 300, // Debounce before sending
timeoutMs: 5000, // Remove indicator after timeout
throttleMs: 2000, // Min time between events
}
// Presence config
const presenceConfig = {
autoAwayTimeout: 300000, // 5 minutes
heartbeatInterval: 30000, // 30 seconds
trackLastSeen: true,
}import { useSocket } from '@/hooks/use-socket'
import { useTyping } from '@/hooks/use-typing'
import { usePresence } from '@/hooks/use-presence'
import { MessageInputWithTyping } from '@/components/chat/MessageInputWithTyping'
import { TypingIndicator } from '@/components/chat/typing-indicator'
import { PresenceIndicator } from '@/components/user/PresenceIndicator'
import { ConnectionStatus } from '@/components/realtime/ConnectionStatus'
function ChatRoom({ channelId, members }) {
const [messages, setMessages] = useState([])
const { isConnected, subscribe, emit } = useSocket()
const { typingUsers } = useTyping(channelId)
const { getPresence } = usePresence(members.map((m) => m.id))
// Subscribe to new messages
useEffect(() => {
if (!isConnected) return
const unsubscribe = subscribe('message:new', (message) => {
setMessages((prev) => [...prev, message])
})
return unsubscribe
}, [isConnected, subscribe])
const handleSendMessage = async (content) => {
emit('message:new', {
channelId,
content,
clientMessageId: generateId(),
})
}
return (
<div className="flex h-full flex-col">
<ConnectionStatus position="top-right" />
{/* Header with members */}
<div className="flex items-center gap-2 border-b p-4">
{members.map((member) => (
<div key={member.id} className="relative">
<img src={member.avatar} className="h-8 w-8 rounded-full" />
<PresenceIndicator userId={member.id} size="sm" position="bottom-right" showTooltip />
</div>
))}
</div>
{/* Messages */}
<div className="flex-1 space-y-2 overflow-y-auto p-4">
{messages.map((msg) => (
<div key={msg.id} className="flex items-start gap-2">
<div className="relative">
<img src={msg.author.avatar} className="h-8 w-8 rounded-full" />
<PresenceIndicator userId={msg.author.id} size="xs" position="bottom-right" />
</div>
<div>
<div className="font-medium">{msg.author.name}</div>
<div className="text-sm">{msg.content}</div>
</div>
</div>
))}
</div>
{/* Typing indicator */}
<TypingIndicator users={typingUsers} />
{/* Input */}
<div className="border-t p-4">
<MessageInputWithTyping
channelId={channelId}
onSendMessage={handleSendMessage}
maxLength={2000}
showCharCount
/>
</div>
</div>
)
}import { usePresence } from '@/hooks/use-presence'
import { PresenceSelector } from '@/components/user/PresenceIndicator'
function UserSettings() {
const { currentStatus, setOnline, setAway, setDnd, setOffline, setCustomStatus } = usePresence([])
return (
<div className="space-y-4">
<h2>Presence Settings</h2>
<PresenceSelector
value={currentStatus}
onChange={(status) => {
switch (status) {
case 'online':
setOnline()
break
case 'away':
setAway()
break
case 'dnd':
setDnd()
break
case 'offline':
setOffline()
break
}
}}
/>
<input
placeholder="Custom status message"
onChange={(e) => setCustomStatus(e.target.value)}
/>
</div>
)
}-
Always clean up subscriptions
useEffect(() => { const unsubscribe = subscribe('event', handler) return unsubscribe // Cleanup on unmount }, [subscribe])
-
Use forceStopTyping on message send
const handleSend = () => { forceStopTyping() // Stop immediately sendMessage() }
-
Check connection before emitting
if (isConnected) { emit('message:new', data) }
-
Handle reconnection gracefully
useEffect(() => { if (isConnected) { // Re-subscribe to channels // Fetch missed messages } }, [isConnected])
-
Debounce expensive operations
const debouncedUpdate = useMemo(() => debounce(updatePresence, 1000), [])
Problem: WebSocket won't connect
Solutions:
- Check
NEXT_PUBLIC_SOCKET_URLenvironment variable - Verify backend is running
- Check browser console for errors
- Test with
curl http://localhost:3001/socket.io/
Problem: Typing events not being received
Solutions:
- Ensure
channelIdis correct - Check that other user is connected
- Verify event subscription is active
- Look for throttling (2-second minimum between events)
Problem: User status not changing
Solutions:
- Check auto-away timeout (default 5 minutes)
- Verify user activity events are firing
- Check that presence subscription includes user ID
- Look for connection issues
Problem: Slow message delivery
Solutions:
- Check network connection quality
- Disable message batching for critical messages
- Use
emitImmediate()instead ofemit() - Monitor connection quality indicator
- Connection time: < 500ms
- Message latency: < 100ms (local), < 300ms (remote)
- Typing indicator delay: 300ms debounce
- Presence update: 30-second heartbeat
- Auto-reconnect: 1-5 seconds exponential backoff
- Use connection pooling for multiple simultaneous connections
- Enable message batching for bulk operations
- Enable compression for large payloads
- Throttle typing events to reduce network traffic
- Debounce presence updates to avoid excessive updates
MIT - See LICENSE file for details