Tutorial 14 of 18

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.

20 min read

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

StepAPI
Authenticate and connectconnect()
Load threadfetchChatHistory()
Send messagesend({ data: { type: "text" } })
Receive liveonMessage
Persist messagessaveHistory: true (default)
Disconnect handlingonDisconnected

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.js

Alice — 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.js

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.

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

IssueFix
Bob never receivesUse the same appId and start Bob before Alice sends
Empty historyThe first conversation has no history until a message is persisted
Not connected on sendWait 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.dart

Alice — 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.dart

Flutter 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

IssueFix
Bob never receivesUse the same appId and make sure Bob is running
Empty historyThe first conversation has no history until the first send
StateError: Not connectedAwait 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.py

Alice — 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.py

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

IssueFix
Bob never receivesUse the same app_id and make sure Bob is running
Empty historyThe first conversation has no history until the first send
RuntimeError: Not connectedAwait connect() before send()