Tutorial 12 of 18

How to Add Persistent Chat History

Load persisted thread messages, merge them safely with live deliveries, and refetch after reconnecting to fill gaps.

14 min read

Load past messages for a conversation thread with fetchChatHistory. On WebSocket clients, the response arrives via onMessage — not as the return value of the method call.

When to Fetch

MomentAction
User opens a threadFetch history for the peer ID or IDs
App returns from the backgroundRefetch or merge with live messages since the last view
After reconnectingRefetch to fill gaps from the offline period

API Contract

FieldDescription
senderIdThe requesting user; must match the connected userId
receiverIdsArray of peer user IDs defining the thread
await notifier.fetchChatHistory({
  senderId: "user-alice",
  receiverIds: ["user-bob"],
});
// Response delivered to onMessage

For 1:1 chat, pass a single peer ID in receiverIds. The server returns messages exchanged between the participants.

Handling the Response

History payloads arrive in onMessage. Their shape is application-dependent but commonly includes a messages array:

onMessage: (msg) => {
  const body = msg.payload.toJSON();
  if (Array.isArray(body?.messages)) {
    setThreadMessages(body.messages);
  }
},

Distinguish history responses from live messages by checking for the messages array or a metadata hint your app recognizes.

HTTP Transport Note

With transport: "http", fetchChatHistory may return the response directly from the async call instead of routing it through onMessage. For chat UIs, prefer ws for unified live and history handling.

JavaScript / TypeScript

Fetch and render chat history with fetchChatHistory.

Fetch on Thread Open

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 () => {
    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(`Loaded ${body.messages.length} history messages`);
      renderHistory(body.messages);
      return;
    }

    if (body?.type === "text") {
      appendLiveMessage(msg.metadata, body);
    }
  },
  onDisconnected: () => {},
});

await notifier.connect();

Merge History With Live Messages

const messageMap = new Map();

function upsertMessage(msg) {
  const id = msg.id ?? `${msg.sender}_${msg.timestamp}`;
  messageMap.set(id, msg);
  renderSorted([...messageMap.values()]);
}

onMessage: (msg) => {
  const body = msg.payload.toJSON();

  if (Array.isArray(body?.messages)) {
    body.messages.forEach(upsertMessage);
    return;
  }

  if (body?.type === "text") {
    upsertMessage({
      id: msg.metadata.id,
      sender: msg.metadata.sender,
      text: body.text,
      timestamp: msg.metadata.timestamp,
    });
  }
},

Refetch After Reconnect

async function onReconnect() {
  await notifier.connect();
  await notifier.fetchChatHistory({
    senderId: MY_USER_ID,
    receiverIds: [PEER_USER_ID],
  });
}

Troubleshooting

IssueCheck
No history in onMessageConfirm saveHistory: true was used when messages were sent
Empty arrayThe thread may have no persisted messages yet
Not connectedCall after connect() succeeds
Duplicate messagesDeduplicate by metadata.id when merging history and live messages

Dart / Flutter

Fetch and render chat history with fetchChatHistory.

Fetch on Thread Open

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 {
      await notifier.fetchChatHistory(
        senderId: myUserId,
        receiverIds: [peerUserId],
      );
    },
    onMessage: (msg) {
      final body = msg.payload.toJSON();

      if (body is Map && body['messages'] is List) {
        final list = body['messages'] as List;
        print('Loaded ${list.length} history messages');
        renderHistory(list);
        return;
      }

      if (body is Map && body['type'] == 'text') {
        appendLiveMessage(msg.metadata, body);
      }
    },
  );

  await notifier.connect();
}

Merge History With Live Messages

final messageMap = <String, Map<String, dynamic>>{};

void upsertMessage(Map<String, dynamic> msg) {
  final id =
      msg['id']?.toString() ?? '${msg['sender']}_${msg['timestamp']}';
  messageMap[id] = msg;
  renderSorted(messageMap.values.toList());
}

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) {
        upsertMessage(Map<String, dynamic>.from(item));
      }
    }
    return;
  }

  if (body is Map && body['type'] == 'text') {
    upsertMessage({
      'id': msg.metadata.id,
      'sender': msg.metadata.sender,
      'text': body['text'],
      'timestamp': msg.metadata.timestamp,
    });
  }
},

Refetch After Reconnect

Future<void> onReconnect(DNotifier notifier) async {
  await notifier.connect();
  await notifier.fetchChatHistory(
    senderId: myUserId,
    receiverIds: [peerUserId],
  );
}

Troubleshooting

IssueCheck
No history in onMessageConfirm saveHistory: true when messages were sent
Empty listThe thread may have no persisted messages yet
StateError: Not connectedCall after connect() succeeds
Duplicate messagesDeduplicate by metadata.id

Python

Fetch and render chat history with fetch_chat_history.

Fetch on Thread Open

import asyncio
from dnotifier import DNotifier

MY_USER_ID = "user-alice"
PEER_USER_ID = "user-bob"

def on_message(msg):
    body = msg.payload.to_json()

    if isinstance(body, dict) and isinstance(body.get("messages"), list):
        print(f"Loaded {len(body['messages'])} history messages")
        render_history(body["messages"])
        return

    if isinstance(body, dict) and body.get("type") == "text":
        append_live_message(msg.metadata, body)

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,
    )
    await notifier.connect()
    await asyncio.sleep(3600)

asyncio.run(main())

Merge History With Live Messages

message_map: dict[str, dict] = {}

def upsert_message(msg: dict):
    msg_id = str(
        msg.get("id") or f"{msg.get('sender')}_{msg.get('timestamp')}"
    )
    message_map[msg_id] = msg
    render_sorted(list(message_map.values()))

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):
                upsert_message(item)
        return

    if isinstance(body, dict) and body.get("type") == "text":
        upsert_message({
            "id": msg.metadata.id,
            "sender": msg.metadata.sender,
            "text": body.get("text"),
            "timestamp": msg.metadata.timestamp,
        })

Refetch After Reconnect

async def on_reconnect(notifier: DNotifier):
    await notifier.connect()
    await notifier.fetch_chat_history(
        sender_id=MY_USER_ID,
        receiver_ids=[PEER_USER_ID],
    )

Troubleshooting

IssueCheck
No history in on_messageConfirm save_history=True when messages were sent
Empty listThe thread may have no persisted messages yet
RuntimeError: Not connectedCall after connect() succeeds
Duplicate messagesDeduplicate by metadata.id