How to Build a Minimal Chat App End-to-End
Build and test a complete two-user chat with connection, history, text sending, live delivery, persistence, and disconnect handling.
A complete walkthrough for a minimal 1:1 chat: connect, load history, send text, receive live messages, and handle disconnect.
What You Will Build
Terminal / browser tab A (alice) Terminal / browser tab B (bob)
│ │
│ fetchChatHistory on start │
│ send text on Enter │
├──────────────────────────────────────────►│
│ │
│◄──────────────────────────────────────────┤
│ live + history in onMessage │Features Covered
| Step | API |
|---|---|
| Authenticate and connect | connect() |
| Load thread | fetchChatHistory() |
| Send message | send({ data: { type: "text" } }) |
| Receive live | onMessage |
| Persist messages | saveHistory: true (default) |
| Disconnect handling | onDisconnected |
Prerequisites
- A DNotifier application with an appId and secret.
- The SDK installed for your language.
- Two user IDs for testing, such as user-alice and user-bob.
Test Plan
- Start the Bob listener from the language section below.
- Start the Alice client in a second process.
- Send a message as Alice and confirm Bob's console shows it live.
- Restart Alice and confirm fetchChatHistory loads prior messages.
- Disconnect the network and verify your offline or error handling.
JavaScript / TypeScript
Full walkthrough: two-user 1:1 chat with history, send, and receive.
Bob — Receiver (Terminal 1)
Save as bob.js:
import WebSocket from "ws";
import { DNotifier } from "@dnotifier-realtime/dnotifier";
const MY_USER_ID = "user-bob";
const PEER_USER_ID = "user-alice";
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 () => {
console.log("Bob online");
await notifier.fetchChatHistory({
senderId: MY_USER_ID,
receiverIds: [PEER_USER_ID],
});
},
onMessage: (msg) => {
const body = msg.payload.toJSON();
if (Array.isArray(body?.messages)) {
body.messages.forEach((message) => messages.push(message));
console.log(`[history] ${body.messages.length} messages loaded`);
messages.forEach(printMessage);
return;
}
if (body?.type === "text") {
const entry = {
sender: msg.metadata.sender,
text: body.text,
timestamp: msg.metadata.timestamp,
};
messages.push(entry);
printMessage(entry);
}
},
onDisconnected: ({ reason }) => console.log("Bob offline:", reason),
});
function printMessage(message) {
console.log(` ${message.sender}: ${message.text}`);
}
await notifier.connect();Run
node bob.jsAlice — Sender With REPL (Terminal 2)
Save as alice.js:
import readline from "readline";
import WebSocket from "ws";
import { DNotifier } from "@dnotifier-realtime/dnotifier";
const MY_USER_ID = "user-alice";
const PEER_USER_ID = "user-bob";
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 () => {
console.log("Alice online — type a message and press Enter");
await notifier.fetchChatHistory({
senderId: MY_USER_ID,
receiverIds: [PEER_USER_ID],
});
},
onMessage: (msg) => {
const body = msg.payload.toJSON();
if (Array.isArray(body?.messages)) {
console.log(`[history] ${body.messages.length} messages`);
return;
}
if (body?.type === "text") {
console.log(`← ${msg.metadata.sender}: ${body.text}`);
}
},
onDisconnected: ({ reason }) => console.log("Alice offline:", reason),
});
await notifier.connect();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.on("line", async (line) => {
const text = line.trim();
if (!text) return;
try {
await notifier.send({
senderId: MY_USER_ID,
receiverId: PEER_USER_ID,
data: { type: "text", text },
});
console.log(`→ you: ${text}`);
} catch (error) {
console.error("Send failed:", error.message);
}
});Run
node alice.jsExpected Flow
- Start Bob — the receiver prints that Bob is online.
- Start Alice — the sender prints that Alice is online.
- Type "Hello Bob!" as Alice — Bob prints the live message.
- Restart Alice — the history response includes prior persisted messages.
Browser Variant (Single Page)
For a browser demo, bind a form to send() and render onMessage into a messages container. Omit WebSocketImpl and use your build tool's environment configuration for credentials.
Troubleshooting
| Issue | Fix |
|---|---|
| Bob never receives | Use the same appId and start Bob before Alice sends |
| Empty history | The first conversation has no history until a message is persisted |
| Not connected on send | Wait for onConnected |
Dart / Flutter
Full walkthrough: two-user 1:1 chat with history, send, and receive.
Bob — Receiver (Process 1)
Save as bin/bob.dart:
import 'package:dnotifier/dnotifier.dart';
const myUserId = 'user-bob';
const peerUserId = 'user-alice';
final messages = <Map<String, dynamic>>[];
Future<void> main() async {
late final DNotifier notifier;
notifier = DNotifier(
appId: 'your-app-id',
secret: 'your-app-secret',
transport: 'ws',
userId: myUserId,
onConnected: () async {
print('Bob online');
await notifier.fetchChatHistory(
senderId: myUserId,
receiverIds: [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(Map<String, dynamic>.from(item));
}
}
print(
'[history] ${(body['messages'] as List).length} messages loaded',
);
for (final message in messages) {
printMessage(message);
}
return;
}
if (body is Map && body['type'] == 'text') {
final entry = {
'sender': msg.metadata.sender,
'text': body['text'],
'timestamp': msg.metadata.timestamp,
};
messages.add(entry);
printMessage(entry);
}
},
onDisconnected: ({code, reason}) => print('Bob offline: $reason'),
);
await notifier.connect();
}
void printMessage(Map<String, dynamic> message) {
print(' ${message['sender']}: ${message['text']}');
}Run
dart run bin/bob.dartAlice — Sender With stdin (Process 2)
Save as bin/alice.dart:
import 'dart:io';
import 'package:dnotifier/dnotifier.dart';
const myUserId = 'user-alice';
const peerUserId = 'user-bob';
Future<void> main() async {
late final DNotifier notifier;
notifier = DNotifier(
appId: 'your-app-id',
secret: 'your-app-secret',
transport: 'ws',
userId: myUserId,
onConnected: () async {
print('Alice online — type a message and press Enter');
await notifier.fetchChatHistory(
senderId: myUserId,
receiverIds: [peerUserId],
);
},
onMessage: (msg) {
final body = msg.payload.toJSON();
if (body is Map && body['messages'] is List) {
print('[history] ${(body['messages'] as List).length} messages');
return;
}
if (body is Map && body['type'] == 'text') {
print('← ${msg.metadata.sender}: ${body['text']}');
}
},
onDisconnected: ({code, reason}) => print('Alice offline: $reason'),
);
await notifier.connect();
while (true) {
stdout.write('> ');
final line = stdin.readLineSync();
if (line == null) break;
final text = line.trim();
if (text.isEmpty) continue;
try {
await notifier.send(
senderId: myUserId,
receiverId: peerUserId,
data: {'type': 'text', 'text': text},
);
print('→ you: $text');
} catch (error) {
print('Send failed: $error');
}
}
}Run
dart run bin/alice.dartFlutter Minimal Screen
import 'package:flutter/material.dart';
import 'package:dnotifier/dnotifier.dart';
class MinimalChatScreen extends StatefulWidget {
const MinimalChatScreen({
super.key,
required this.notifier,
required this.peerId,
});
final DNotifier notifier;
final String peerId;
@override
State<MinimalChatScreen> createState() => _MinimalChatScreenState();
}
class _MinimalChatScreenState extends State<MinimalChatScreen> {
final _messages = <String>[];
final _controller = TextEditingController();
@override
void initState() {
super.initState();
widget.notifier.fetchChatHistory(
senderId: widget.notifier.userId,
receiverIds: [widget.peerId],
);
}
Future<void> _send() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
await widget.notifier.send(
senderId: widget.notifier.userId,
receiverId: widget.peerId,
data: {'type': 'text', 'text': text},
);
setState(() => _messages.add('you: $text'));
_controller.clear();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Chat with ${widget.peerId}')),
body: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _messages.length,
itemBuilder: (_, index) {
return ListTile(title: Text(_messages[index]));
},
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(child: TextField(controller: _controller)),
IconButton(
icon: const Icon(Icons.send),
onPressed: _send,
),
],
),
),
],
),
);
}
}Wire onMessage in your application root to update this screen's state when live or history messages arrive.
Expected Flow
- Start Bob — the receiver prints that Bob is online.
- Start Alice — the sender prints that Alice is online.
- Type "Hello Bob!" as Alice — Bob prints the live message.
- Restart Alice — the history response includes prior persisted messages.
Troubleshooting
| Issue | Fix |
|---|---|
| Bob never receives | Use the same appId and make sure Bob is running |
| Empty history | The first conversation has no history until the first send |
| StateError: Not connected | Await connect() before send() |
Python
Full walkthrough: two-user 1:1 chat with history, send, and receive.
Bob — Receiver (Process 1)
Save as bob.py:
import asyncio
from dnotifier import DNotifier
MY_USER_ID = "user-bob"
PEER_USER_ID = "user-alice"
messages: list[dict] = []
def print_message(message: dict):
print(f" {message.get('sender')}: {message.get('text')}")
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(item)
print(f"[history] {len(body['messages'])} messages loaded")
for message in messages:
print_message(message)
return
if isinstance(body, dict) and body.get("type") == "text":
entry = {
"sender": msg.metadata.sender,
"text": body.get("text"),
"timestamp": msg.metadata.timestamp,
}
messages.append(entry)
print_message(entry)
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(
notifier.fetch_chat_history(
sender_id=MY_USER_ID,
receiver_ids=[PEER_USER_ID],
)
),
on_message=on_message,
on_disconnected=lambda code=None, reason=None: print(
f"Bob offline: {reason}"
),
)
await notifier.connect()
print("Bob online")
await asyncio.sleep(3600)
asyncio.run(main())Run
python bob.pyAlice — Sender With stdin (Process 2)
Save as alice.py:
import asyncio
from dnotifier import DNotifier
MY_USER_ID = "user-alice"
PEER_USER_ID = "user-bob"
async def on_connected(notifier):
print("Alice online — type a message and press Enter")
await notifier.fetch_chat_history(
sender_id=MY_USER_ID,
receiver_ids=[PEER_USER_ID],
)
def on_message(msg):
body = msg.payload.to_json()
if isinstance(body, dict) and isinstance(body.get("messages"), list):
print(f"[history] {len(body['messages'])} messages")
return
if isinstance(body, dict) and body.get("type") == "text":
print(f"← {msg.metadata.sender}: {body.get('text')}")
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(on_connected(notifier)),
on_message=on_message,
on_disconnected=lambda code=None, reason=None: print(
f"Alice offline: {reason}"
),
)
await notifier.connect()
loop = asyncio.get_event_loop()
while True:
line = await loop.run_in_executor(None, input, "> ")
text = line.strip()
if not text:
continue
try:
await notifier.send(
sender_id=MY_USER_ID,
receiver_id=PEER_USER_ID,
data={"type": "text", "text": text},
)
print(f"→ you: {text}")
except Exception as error:
print(f"Send failed: {error}")
asyncio.run(main())Run
python alice.pyExpected Flow
- Start Bob — the receiver prints that Bob is online.
- Start Alice — the sender prints that Alice is online.
- Type "Hello Bob!" as Alice — Bob prints the live message.
- Restart Alice — the history response includes prior persisted messages.
Troubleshooting
| Issue | Fix |
|---|---|
| Bob never receives | Use the same app_id and make sure Bob is running |
| Empty history | The first conversation has no history until the first send |
| RuntimeError: Not connected | Await connect() before send() |