NOTE
Keeping the conversation inside the workflow — building a real-time chat
How an integrated communication channel became a reusable real-time service for two organisations, multiple users, and the maritime work connecting them.
23 Jul 2026
At Vsltec, shipping companies created requests for maritime services and service providers responded with offers.
The request, the offers, and the work that followed were managed inside the platform.
The conversation needed to stay there as well.
Without an integrated communication channel, it would have been easy for discussions to move into personal email threads, WhatsApp messages, phone calls, and files exchanged outside the service case.
That would make communication quicker for one person in the moment.
It would also separate it from the work it concerned.
Other users from the shipping company or the service provider might not see what had been discussed. Attachments could lose their context. Decisions could become visible only to the people included in a particular email thread.
For a platform coordinating work between two organisations, that was not only inconvenient.
It reduced transparency.
The communication needed to remain visible to the people involved and connected to the request, offer, and service it concerned.
So the initial requirement sounded simple:
Could we add chat to the platform?
We could.
The more useful question was what “chat” meant when the conversation belonged to a business workflow rather than two individual users.
The room belonged to the work
The simplest chat model starts with two accounts.
User A talks to User B.
That was not the model here.
A conversation existed between a shipping company and a service provider, around a particular service request and the offers and work that followed.
Several users from either organisation could participate.
One person might create the request. Another might evaluate the offers. Someone else might coordinate the actual service. A colleague joining later still needed access to the same conversation and attachments.
The room belonged to the work, not to two individual users.
Its identity carried information such as:
- the shipping company,
- the service provider,
- the service case,
- the requested service,
- and other identifiers from the surrounding workflow.
The business application decided when a room should exist and which organisations were involved.
The chat service managed what happened inside it.
It did not need to understand how a maritime service was priced, selected, or approved.
It did need to know which conversation belonged to which piece of work, who could participate, and when that conversation was no longer active.
Without that connection, we would have built messaging next to the product.
The goal was to build communication into the product.
A useful chat is mostly state
I designed and implemented the core Service.Chat system from beginning to end.
The visible result was familiar enough:
- real-time messages,
- typing indicators,
- read status,
- conversation history,
- attachments,
- participant updates,
- and rooms that could eventually close.
Behind that interface, the service had to manage several different kinds of state:
connection state
→ who is online
room session state
→ which connection has joined which conversation
typing state
→ who is writing right now
read state
→ how far each participant has seen
message state
→ what belongs in the permanent history
attachment state
→ which files are temporary and which belong to a sent message
room state
→ whether the conversation is still open
Later, another kind was added:
notification state
→ who has unread messages but is no longer present
SignalR could move events between the client and the server.
It could not decide what any of those events meant.
That was the actual work.
Connected was not the same as joined
A user could be signed in and connected to the platform without currently viewing a particular conversation.
They were online.
They had not necessarily joined that room.
This mattered because the same user could have access to several service cases while actively looking at only one of them.
The service therefore maintained an explicit session model in Redis.
It tracked:
- the active connections for each user,
- which service instance owned each connection,
- the rooms each connection had joined,
- the participant represented by that connection,
- and the active connections inside each room.
This was a deliberate alternative to relying only on SignalR Groups.
Groups are useful when the question is:
Which connections should receive this event?
The product also needed to answer:
Is this participant online but currently outside this conversation?
When a new message arrived, the service could identify users who were connected to the platform but had not joined the relevant room.
It sent those connections a joinrequested event.
The frontend could then join the room and begin receiving its updates.
The normal flow was:
user connects
→ connection is registered
→ user opens a conversation
→ connection joins the room
→ room events are delivered
The less obvious flow was:
user is already connected
→ user has not joined this room
→ a new message arrives
→ joinrequested is sent
→ the frontend joins the room
A connection told us that a browser was present.
The room session told us what that browser was currently participating in.
Those are different things, despite both eventually becoming a small green dot somewhere in the interface.
Typing was allowed to disappear
The typing indicator was one of the simpler features, mainly because it was allowed to remain temporary.
When someone started or stopped writing, the frontend called the room’s Typing operation with the current state.
The service verified that the connection had joined the room and forwarded the event to the other active participants.
user starts typing
→ frontend sends Typing(room, true)
→ service validates the room session
→ the other participants see the indicator
And later:
user stops typing
→ frontend sends Typing(room, false)
→ the indicator disappears
The event was not stored.
It did not pass through Azure Service Bus.
It did not become part of the conversation history.
Nobody needed a permanent record proving that someone had started writing at 14:32, paused, deleted everything, and eventually replied with “OK”.
Typing mattered now.
It had no value later.
SignalR was the right path precisely because the information was temporary.
Read status used a high-water mark
Read receipts could have been implemented with a separate record for every participant and every message:
MessageRead
- MessageID
- ParticipantID
- ReadAt
That model grows quickly.
A conversation with many messages and several participants creates a large number of records whose main purpose is to say that everything before a certain point has already been seen.
The service used a simpler model.
Each participant had a LastActive timestamp inside the room.
When the user opened or focused the conversation, the frontend called Focus.
The backend updated that participant’s LastActive value and sent a ParticipantLastActiveChanged event to the other active clients.
They could determine read status by comparing timestamps:
message.Timestamp <= participant.LastActive
→ the participant has read the message
The timestamp acted as a read high-water mark.
One update could mark the entire conversation up to that point as read, without creating one record for every participant-message combination.
When someone sent a message, their own LastActive value was updated as well.
Sending a reply was fairly strong evidence that they had seen the conversation they were replying to.
The interface could still display familiar read information.
The database did not need to create a small accounting system for ticks.
The room had a past and an end
Real-time events only described what was happening now.
Someone joining the conversation later still needed everything that had happened before.
Messages were stored in timestamp order and exposed through continuation-based pagination.
The frontend could load the latest part of the conversation and request older pages as the user moved backwards through the history.
The live and historical paths remained separate:
SignalR
→ events happening now
Cosmos DB
→ messages that already happened
A newly joined connection did not need the entire history pushed through the real-time channel.
It loaded the existing conversation first and then received subsequent changes through SignalR.
The room also had a lifecycle.
It could be open or closed.
When the surrounding service workflow ended, an internal event closed the room, updated its state, and informed the active participants.
The history remained available.
New messages were no longer accepted.
The work had ended, so the chat box stopped pretending otherwise.
Delivery did not wait for the database
When someone sent a message, I did not want its delivery to wait for the complete persistence workflow.
That was a deliberate decision based on the behaviour of the product.
For a payment, the transaction must be stored successfully before the system confirms the result.
For this chat, the first responsibility was to deliver the message to the other participants.
The interactive operation therefore started two separate paths:
send
├─→ deliver to active participants through SignalR
└─→ publish the persistence message through Azure Service Bus
→ idempotent consumer
→ store in Cosmos DB
→ update the related state
MassTransit handled the application messaging over Azure Service Bus.
Each queued persistence message was processed by one consumer instance.
The consumer was idempotent, so retries or broker redelivery could repeat the processing attempt without creating another stored message.
The database write was still essential.
It simply did not need to block real-time delivery.
The interface said “send”.
Internally, that meant at least two things.
Attachments needed their own lifecycle
Users also needed to exchange documents, spreadsheets, photographs, and other files related to the service.
The obvious upload flow would have been:
upload file
→ store permanently
→ attach it to a message
The problem appears when the final step never happens.
The user may close the tab.
They may remove the file before sending.
The message may fail validation.
The system would then have a permanent file with no message and no useful business context.
The chat used Storo, a shared internal storage service whose design I helped oversee.
Storo provided file operations and pre-signed links, allowing consuming services to use storage without managing the underlying provider directly.
For chat attachments, a file began as a draft:
request upload link
→ upload to temporary storage
→ send the message
→ promote the file to permanent storage
→ collect its final metadata
→ update the message
The browser uploaded the file directly through the pre-signed link rather than passing it through the chat service.
After the message had been sent and persisted, the attachment consumer moved the file from temporary to permanent storage.
It then collected information such as its size and content type, updated the stored message, and sent a MessageUpdated event to active clients.
The frontend could display the message immediately and receive the completed attachment information afterwards.
Uploading the file was not the permanent business action. Sending the message was.
A file became part of the conversation only when there was a conversation entry for it to belong to.
Redis had two different jobs
The service was designed to support multiple running instances from the beginning.
A SignalR client is physically connected to one service instance.
The event intended for that client may originate from another.
Redis was used in two distinct ways.
First, it provided the SignalR backplane.
When one Service.Chat instance emitted a hub event, the Redis backplane propagated it across the running instances so the instance holding the relevant client connection could deliver it.
Second, the application stored its own chat state in Redis.
Redis backplane
→ SignalR communication across service instances
application-managed Redis state
→ users, connections, room sessions and instance ownership
The backplane solved cross-instance delivery.
It did not tell the application which online user had joined which business conversation.
The custom session state did.
Each instance also tracked the connections it owned and cleared stale machine-specific state when it started again.
This reduced the chance that a restarted instance would leave old sessions behind and convince the rest of the system that disconnected users were still present.
Multiple instances were not added after a dramatic production incident involving a small mountain of stranded WebSockets.
They were part of the original design.
The service belonged to a distributed platform, so cross-instance delivery and shared connection state were normal requirements rather than future surprises.
The service had clear internal boundaries
The chat did not decide when a room should exist.
That belonged to the application managing service requests and offers.
Trusted internal services used network-protected gRPC operations to create rooms, retrieve them, and update their metadata.
The browser communicated through the authorised SignalR hub.
The business applications controlled the existence and context of conversations.
Service.Chat controlled participation and communication inside them.
Storo controlled file storage.
Azure Service Bus carried asynchronous work.
This separation was one of the reasons chat became an independent service rather than a collection of methods inside the request API.
Its responsibility was specific enough to define.
Its runtime needs were different enough to isolate.
And its capabilities were general enough to reuse in future applications.
Real time ends when the user leaves
The original service handled users who were present in the platform.
Later, another feature addressed the users who were not.
Someone could receive a message in an active service case and not return to the application for several hours.
The typing indicator would not help.
Neither would the Redis backplane.
A later inactivity detector used the room’s activity and each participant’s LastActive value to identify users who still had unread messages after a configured period.
It avoided repeatedly notifying the same person and grouped the relevant conversations for the same user and organisation.
The wider notification flow then sent an email explaining that unread messages were waiting.
new message
→ participant remains inactive
→ unread threshold is reached
→ notification event is published
→ email is sent
That feature was not part of my original core implementation, but it completed an important product loop.
Real-time communication works while people are present. A business system also needs a way to bring them back.
The difficult part was not sending messages
The service ran in production and supported real communication between shipping companies and service providers.
It allowed multiple users from both organisations to participate in the same service conversation.
It provided:
- real-time delivery,
- typing indicators,
- participant-level read status,
- paginated history,
- attachments,
- participant join events,
- open and closed room states,
- connected-but-not-joined detection,
- cross-instance delivery,
- and later, unread-message email notifications.
SignalR was important.
It was not the main design problem.
The difficult part was deciding what the chat needed to know.
A room belonged to a service workflow rather than two people.
Being connected was different from joining a conversation.
Typing was temporary.
Read status was a high-water mark rather than a record per message.
Delivery and persistence followed separate paths.
Uploads remained temporary until a sent message gave them a permanent reason to exist.
And several running service instances had to behave like one system from the beginning.
The original goal was to stop the conversation from escaping into email and WhatsApp.
Doing that properly required more than placing chat next to the request and offer.
The conversation had to inherit the context, participants, lifecycle, and reliability of the work itself.
That turned a familiar interface feature into an independent real-time service.
Which was considerably more useful than another chat window.
AI note: This post was also written with the help of Claude Code and ChatGPT. Claude Code inspected the repository and produced the technical context report. ChatGPT helped turn that report, my explanations, and several corrections into this article. The architecture and implementation decisions were mine; the writing was AI-assisted and human-directed.