How to Build a 1:1 Chat Experience
Build a two-user chat thread with persisted history, live WebSocket messages, typing indicators, and reconnect recovery.
16 min read
A 1:1 chat thread connects two user IDs over WebSocket: load history when opening the thread, send new messages with send(), and append live deliveries from onMessage. Chat is not a separate transport — it uses send() for delivery and chat-specific APIs for history.
Core Building Blocks
connect (ws)
│
├── fetchChatHistory on thread open
│
├── send({ data, saveHistory: true }) → live delivery + persistence
│
├── onMessage → append incoming messages to UI
│
└── deleteChatHistoryMessage → moderation / user delete| API | Role |
|---|---|
| send() | Deliver a message to the peer; persist when saveHistory is true |
| fetchChatHistory() | Load past messages for a thread |
| deleteChatHistoryMessage() | Remove a stored message |
| onMessage | Receive live messages and history responses |
Thread Model
User A (user-alice) User B (user-bob)
│ │
│ fetchChatHistory([bob]) on open │
│ send({ receiverId: "user-bob" }) │
├──────────────────────────────────────────►│
│ │
│◄──────────────────────────────────────────┤
│ onMessage on both sides │- Thread key — pair (userA, userB); your app defines the sort order for fetchChatHistory.
- Same appId — both participants use the same DNotifier application.
- Stable userId — map your authenticated user to a consistent DNotifier ID.
Implementation Checklist
- Connect on login with transport: "ws" and userId set to the authenticated user.
- Open the thread with fetchChatHistory({ senderId, receiverIds: [peerId] }).
- Render history by handling the history response in onMessage.
- Send messages with send({ senderId, receiverId, data: { type: "text", text } }).
- Append live onMessage payloads to the thread view.
- Refetch history after reconnecting to fill delivery gaps.
Message Payload Convention
{
"type": "text",
"text": "Hello!"
}Extend this convention with image, audio, and doc payload types for rich chat.
JavaScript / TypeScript
End-to-end pattern for a two-user chat thread.
Chat Client Skeleton
import WebSocket from "ws";
import { DNotifier } from "@dnotifier-realtime/dnotifier";
const MY_USER_ID = "user-alice";
const PEER_USER_ID = "user-bob";
const messages = [];
const notifier = new DNotifier({
appId: process.env.DNOTIFIER_APP_ID,
secret: process.env.DNOTIFIER_SECRET,
transport: "ws",
userId: MY_USER_ID,
WebSocketImpl: WebSocket,
onConnected: async () => {
await openThread(PEER_USER_ID);
},
onMessage: (msg) => {
const body = msg.payload.toJSON();
// History response
if (Array.isArray(body?.messages)) {
messages.push(...body.messages);
renderThread(messages);
return;
}
// Live message
if (body?.type === "text") {
messages.push({
sender: msg.metadata.sender,
text: body.text,
timestamp: msg.metadata.timestamp,
id: msg.metadata.id,
});
renderThread(messages);
}
},
onDisconnected: () => showOfflineState(),
});
async function openThread(peerId) {
await notifier.fetchChatHistory({
senderId: MY_USER_ID,
receiverIds: [peerId],
});
}
async function sendText(text) {
await notifier.send({
senderId: MY_USER_ID,
receiverId: PEER_USER_ID,
data: { type: "text", text },
saveHistory: true,
});
}
await notifier.connect();Send on User Input
document.querySelector("#send-btn").addEventListener("click", async () => {
const input = document.querySelector("#message-input");
const text = input.value.trim();
if (!text || !notifier.isConnected) return;
await sendText(text);
input.value = "";
});Typing Indicator (Optional)
await notifier.send({
senderId: MY_USER_ID,
receiverId: PEER_USER_ID,
data: { type: "typing" },
saveHistory: false,
});Handle the transient event in onMessage:
if (body?.type === "typing") {
showTypingIndicator(msg.metadata.sender);
}After Reconnect
onDisconnected: () => {
showOfflineState();
scheduleReconnect(async () => {
await notifier.connect();
await openThread(PEER_USER_ID);
});
},Dart / Flutter
End-to-end pattern for a two-user chat thread.
Chat Client Skeleton
import 'package:dnotifier/dnotifier.dart';
const myUserId = 'user-alice';
const peerUserId = 'user-bob';
class ChatMessage {
ChatMessage({
required this.sender,
required this.text,
this.id,
this.timestamp,
});
final String sender;
final String text;
final String? id;
final int? timestamp;
}
final messages = <ChatMessage>[];
Future<void> main() async {
late final DNotifier notifier;
notifier = DNotifier(
appId: 'your-app-id',
secret: 'your-app-secret',
transport: 'ws',
userId: myUserId,
onConnected: () async {
await openThread(notifier, peerUserId);
},
onMessage: (msg) {
final body = msg.payload.toJSON();
if (body is Map && body['messages'] is List) {
for (final item in body['messages'] as List) {
if (item is Map) {
messages.add(ChatMessage(
sender: item['sender']?.toString() ?? '',
text: item['text']?.toString() ?? '',
id: item['id']?.toString(),
));
}
}
renderThread(messages);
return;
}
if (body is Map && body['type'] == 'text') {
messages.add(ChatMessage(
sender: msg.metadata.sender,
text: body['text']?.toString() ?? '',
id: msg.metadata.id,
timestamp: msg.metadata.timestamp,
));
renderThread(messages);
}
},
onDisconnected: ({code, reason}) => showOfflineState(),
);
await notifier.connect();
}
Future<void> openThread(DNotifier notifier, String peerId) async {
await notifier.fetchChatHistory(
senderId: myUserId,
receiverIds: [peerId],
);
}
Future<void> sendText(DNotifier notifier, String text) async {
await notifier.send(
senderId: myUserId,
receiverId: peerUserId,
data: {'type': 'text', 'text': text},
saveHistory: true,
);
}
void renderThread(List<ChatMessage> items) {
// Bind to your UI.
}Flutter Send Field
class ChatInput extends StatefulWidget {
const ChatInput({super.key, required this.notifier});
final DNotifier notifier;
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: TextField(controller: _controller),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () async {
final text = _controller.text.trim();
if (text.isEmpty || !widget.notifier.isConnected) return;
await sendText(widget.notifier, text);
_controller.clear();
},
),
],
);
}
}Typing Indicator (Optional)
await notifier.send(
senderId: myUserId,
receiverId: peerUserId,
data: {'type': 'typing'},
saveHistory: false,
);After Reconnect
onDisconnected: ({code, reason}) async {
showOfflineState();
await reconnectWithBackoff(notifier);
await openThread(notifier, peerUserId);
},Python
End-to-end pattern for a two-user chat thread.
Chat Client Skeleton
import asyncio
from dataclasses import dataclass
from typing import Optional
from dnotifier import DNotifier
MY_USER_ID = "user-alice"
PEER_USER_ID = "user-bob"
@dataclass
class ChatMessage:
sender: str
text: str
id: Optional[str] = None
timestamp: Optional[int] = None
messages: list[ChatMessage] = []
async def open_thread(notifier: DNotifier, peer_id: str):
await notifier.fetch_chat_history(
sender_id=MY_USER_ID,
receiver_ids=[peer_id],
)
async def send_text(notifier: DNotifier, text: str):
await notifier.send(
sender_id=MY_USER_ID,
receiver_id=PEER_USER_ID,
data={"type": "text", "text": text},
save_history=True,
)
def on_message(msg):
body = msg.payload.to_json()
if isinstance(body, dict) and isinstance(body.get("messages"), list):
for item in body["messages"]:
if isinstance(item, dict):
messages.append(ChatMessage(
sender=str(item.get("sender", "")),
text=str(item.get("text", "")),
id=item.get("id"),
))
render_thread(messages)
return
if isinstance(body, dict) and body.get("type") == "text":
messages.append(ChatMessage(
sender=msg.metadata.sender,
text=str(body.get("text", "")),
id=msg.metadata.id,
timestamp=msg.metadata.timestamp,
))
render_thread(messages)
async def main():
notifier = DNotifier(
app_id="your-app-id",
secret="your-app-secret",
transport="ws",
user_id=MY_USER_ID,
on_connected=lambda: asyncio.create_task(
open_thread(notifier, PEER_USER_ID)
),
on_message=on_message,
on_disconnected=lambda code=None, reason=None: show_offline_state(),
)
await notifier.connect()
await asyncio.sleep(3600)
def render_thread(items: list[ChatMessage]):
# Bind to your UI.
pass
def show_offline_state():
pass
asyncio.run(main())CLI Send Loop
async def chat_loop(notifier: DNotifier):
while True:
text = input("> ").strip()
if not text:
continue
await send_text(notifier, text)Typing Indicator (Optional)
await notifier.send(
sender_id=MY_USER_ID,
receiver_id=PEER_USER_ID,
data={"type": "typing"},
save_history=False,
)After Reconnect
async def on_reconnect(notifier: DNotifier):
await notifier.connect()
await open_thread(notifier, PEER_USER_ID)