Tuesday, 16 December 2025

Building low-cost real-time Chat + In‑App Notifications with Azure Web PubSub (and “no missed messages” persistence)

 Real-time features often get postponed because WebSockets can feel “expensive”:

Why Azure Web PubSub is “low cost” in practice

The core building block: “client access URL” issuance

Generic flow

Browser/App
|
| POST /realtime/clientAccessUrl (authenticated)
| body: { userId, hubName, groupName }
v
Backend (Function/API)
|
| validates identity + authorization
| generates signed Web PubSub client URL (short TTL)
v
Browser/App receives:
{ url }
|
v
WebPubSubClient(url).start()
; joinGroup(groupName)

Example (pseudocode)

// client
async function connectToHub({ hubName, groupName, userId }) {
const { url } = await fetchJson('/realtime/clientAccessUrl', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({ hubName, groupName, userId })
});
const client = new WebPubSubClient(url);
registerHandlers(client);
await client.start();
await client.joinGroup(groupName);
return client;
}

Part 1 — Chat architecture (real-time + durable)

Key idea: persist first, then broadcast

Example send path (pseudocode)

async function sendMessage({ conversationId, text }) {
// 1) persist
const saved = await postJson('/chat/reply', { conversationId, message: text });
// saved includes messageId, timestamp, sender, etc.
// 2) broadcast (best-effort)
await pubsubClient.sendToGroup(
saved.conversationContext, // groupName
{ conversationId, message: saved }, // payload
'json'
);
}

Conversation group model

Receiving messages

client.on('group-message', (e) => {
const msg = e.message.data;
appendToUI(msg);
});

Reconnects that don’t melt your system

async function reconnectWithBackoff(makeClient, attempts = 5) {
for (let i = 1; i <= attempts; i++) {
try {
return await makeClient();
} catch (e) {
if (i === attempts) throw e;
await sleep(backoff(i) + jitter());
}
}
}

Part 2 — Real-time in-app notifications using the same Web PubSub approach

Notification fanout patterns (choose one)

Example (pseudocode)

class NotificationService {
listeners = [];
async start({ userId }) {
this.client = await connectToHub({
hubName: 'notifications',
groupName: `notifications:user:${userId}`,
userId
});
this.client.on('group-message', (e) => {
const notif = normalizeNotification(e.message.data);
this.listeners.forEach(fn => fn(notif));
});
}
}

Hybrid model: API load + real-time updates (recommended)

Part 3 — The missing piece: “no missed messages” with SQL Server

Reliability goal

Best-practice backend: Store → Outbox → Publish

Client
|
| 1) POST /chat/reply
v
API Service
| 2) Begin DB transaction
| - insert message row (SQL Server)
| - insert outbox event row (same transaction)
| - commit
v
Outbox Processor (background worker)
| 3) Reads unprocessed outbox rows
| 4) Publishes to Web PubSub group
| 5) Marks outbox row processed (idempotent)
v
Web PubSub -> connected clients

Suggested SQL Server tables (minimum viable)

Idempotency and dedupe (do this even if you think you don’t need it)

Catch-up strategy (how clients avoid gaps)

Better ways / improvements over a basic implementation

1) Use ID-based group names (not free text)

2) Don’t broadcast from the request thread (use outbox + worker)

3) Add delivery semantics explicitly

4) Use push notifications for offline delivery

5) Handle attachments properly

6) Security hardening

Closing notes

No comments:

Post a Comment