DEV Community

Cover image for Messages Need a Protocol Before They Need a Chat UI
Daniel Romitelli
Daniel Romitelli

Posted on • Edited on • Originally published at craftedbydaniel.com

Messages Need a Protocol Before They Need a Chat UI

A patient taps a kiosk and asks for help. A staff member replies from another screen. The same message may appear in the app, trigger a push notification, fall back to Short Message Service (SMS), and later show as read.

If each screen decides what those events mean, the thread turns into a rumor mill. One client marks the message as sent. Another treats a push attempt as delivery. A third retries after a network pause. In a clinic, that ambiguity creates audit gaps, extra staff work, and a patient expectation problem: sent must mean something actionable.

The rule I wanted was simple: every message has a durable state, every transition has an owner, and urgent communication can leave the app channel when policy allows it.

That rule is why I built BidirectionalMessagingService as the coordination layer for kiosk messaging, rather than treating chat as a screen-level feature.

1. The invariant comes before the interface

The kiosk already had services for push, SMS, email, and templates. Those services deliver through channels. They do not decide the truth of the thread.

BidirectionalMessagingService owns the message lifecycle: creation, delivery policy, acknowledgement handling, read receipts, fallback decisions, and teardown. Push, SMS, and email remain transports.

flowchart TD
patient[Patient Kiosk] --> service[BidirectionalMessagingService]
staff[Staff Client] --> service
service --> thread[Message Record]
service --> push[Push Notification]
service --> sms[SMS Fallback]
service --> email[Email Delivery]
service --> receipt[Read Receipt]
Enter fullscreen mode Exit fullscreen mode

The cost is ceremony. A basic widget can append text and look finished. This version asks callers to provide role, priority, permitted channels, and acknowledgement behavior. That extra shape buys one authority for changing the record.

2. The row carries the user-facing truth

The message model stores identity, direction, status, timestamps, and attachments. The durable status set is intentionally small:

status: 'sent' | 'delivered' | 'read' | 'failed';
sentAt: Date;
deliveredAt?: Date;
readAt?: Date;
Enter fullscreen mode Exit fullscreen mode

Four states, and a timestamp for three of them. There is no queued, no pending, no retrying. Every one of those describes what a worker is doing rather than what happened to the patient, and a status a patient cannot act on has no business in a column they can see.

Queue membership stays in memory while work is active. It never becomes a database value. A queued item is a worker condition, not a patient-visible fact. Persisting it would force every client to explain whether the item is safe, blocked, or retrying.

Delivery options are policy, rather than a single flag:

Field What it decides Cost
channels Which transports may carry the message More combinations to test
urgency How aggressively the system alerts users Greater risk of alert fatigue
requireDeliveryConfirmation Whether delivery needs acknowledgement More bookkeeping
fallbackToSMS Whether the message may leave the app channel Higher external dependency surface
translationLanguage Whether content needs language adaptation More transformation risk

Timeouts live in the same policy layer. The clock starts with the active delivery attempt, and every permitted transport races against it:

const results = await Promise.allSettled(deliveryPromises);
const successfulDelivery = results.some(r => r.status === 'fulfilled' && r.value.success);

await supabase.from('messages').update({
  status: successfulDelivery ? 'delivered' : 'failed',
  deliveredAt: successfulDelivery ? new Date() : null,
}).eq('id', message.id);
Enter fullscreen mode Exit fullscreen mode

allSettled rather than all is the whole point. One transport failing is not the message failing. Push can time out while SMS gets through, and the row should say delivered because a human received it. The status collapses many attempts into the single fact a patient and a nurse both need. Presence can influence routing, but it never creates another stored status.

3. Receipts and teardown are side effects

A read action moves the message to read. Notifying the sender is a side effect of that transition. If the sender notification fails, the patient still read the message. Mixing those facts would make the history less reliable.

The same ownership applies to runtime resources. Realtime subscriptions and pending work need an explicit end, so the service owns cleanup instead of scattering it across screens:

cleanup(): void {
  for (const [_, channel] of this.activeChannels) {
    channel.unsubscribe();
  }
  this.activeChannels.clear();
  this.messageQueue.clear();
}
Enter fullscreen mode Exit fullscreen mode

Three lines of teardown, and the queue is cleared alongside the subscriptions on purpose. Anything still waiting to send belonged to the session that just ended. Draining it into the next one would deliver a previous patient's message to whoever is standing at the kiosk now.

That matters in a shared device flow. One patient can walk away, another can begin check-in, and an old subscription should have no vote in the new session.

4. The review table

Once the lifecycle is explicit, each operation has one test: it changes the durable record, triggers a side effect, or both.

Event Owner Durable status outcome Side effects
Staff sends message Messaging service sent Channel delivery attempts
Transport confirms delivery Channel adapter delivered Optional confirmation notice
Patient opens message Messaging service read Read receipt notification
Active attempt expires Messaging service failed Optional fallback path before failure
Sender receives receipt notice Receipt handler No new message status User interface acknowledgement

Consumer chat can tolerate fuzzy indicators. A clinic kiosk has less room for soft meaning because staff coordinate care through the thread and patients expect a reply to reach someone. With explicit states and owners, one status record survives channel retries, fallback routing, and client differences. The interface can vary; the thread still tells one story.


๐ŸŽง Listen to the audiobook โ€” Spotify ยท Google Play ยท All platforms
๐ŸŽฌ Watch the visual overviews on YouTube
๐Ÿ“– Read the full 13-part series

Top comments (0)