Building a chat UI is easy.
Building a chat application that actually behaves like the messaging apps we use every day is a completely different challenge.
I’m Ehan Siddique, a full-stack web developer, and recently I built ChatLoop, one of my biggest personal projects so far.
ChatLoop started as an idea for a simple real-time messaging application, but I kept asking myself:
What would I need to build if this were a real communication product instead of just another portfolio project?
That question changed the entire project.
I ended up building real-time messaging, reactions, read receipts, voice notes, file sharing, presence tracking, audio/video calls, screen sharing, friend requests, an AI assistant, reporting and moderation, account suspensions, and a complete admin dashboard.
In this article, I want to share how I approached the project, some of the engineering decisions behind it, and what I learned while building it.
What is ChatLoop?
ChatLoop is a real-time communication web application built primarily with:
- React 18
- Vite
- Tailwind CSS
- Firebase Authentication
- Cloud Firestore
- Supabase Storage
- WebRTC
- Google Gemini API
- Framer Motion
The goal wasn't simply to let two users send messages.
I wanted the application to handle many of the smaller details that make a messaging product actually feel complete.
That includes things like:
- typing indicators
- read receipts
- message reactions
- unread counters
- voice messages
- online/away/last-seen status
- audio and video calls
- missed and rejected call states
- screen sharing
- user reporting
- account suspension
- admin announcements
- AI conversations
And importantly, these features needed to work together.
1. Real-Time Messaging Was More Than onSnapshot()
Firebase makes listening for real-time data surprisingly simple.
But displaying messages in real time is only one part of building a messaging system.
Every conversation in ChatLoop needs information such as:
participants
lastMessage
lastMessageTime
lastMessageSender
unreadCounts
while the actual messages are stored separately.
A simplified version of the structure looks like:
chats/{chatId}
messages/{chatId}/messages/{messageId}
When someone sends a message, ChatLoop doesn't only create the message.
It also updates the conversation metadata.
I handle those operations together in a batch.
Conceptually:
writeBatch(db)
// Create message
// Update last message
// Update timestamp
// Update sender
// Increment receiver unread count
batch.commit()
Why?
Imagine the message succeeds but updating lastMessage fails.
The receiver could have a new message inside the conversation while their sidebar still displays the previous one.
Keeping related operations together prevents that inconsistent state.
Firestore also applies local writes before receiving confirmation from the server.
That allowed me to make a newly sent message appear immediately while displaying a small pending state until the server confirms it.
That tiny detail makes messaging feel much faster.
2. I Didn't Want Hundreds of Unnecessary Listeners
Real-time applications can become expensive and complicated when everything becomes a listener.
Initially, it is tempting to listen to everything:
Conversation A messages
Conversation B messages
Conversation C messages
Conversation D messages
...
But the dashboard doesn't actually need every message from every conversation.
It mainly needs conversation metadata.
So ChatLoop's dashboard listens to chat documents.
When lastMessageTime changes and another user was the sender, the application knows that a new message arrived.
It can then:
- update the conversation preview
- update unread counts
- play a notification sound
- show a desktop notification when appropriate
Only the currently opened conversation needs its messages actively displayed.
This taught me an important lesson:
Real-time doesn't mean everything needs to be real-time.
You still need to think about what data the UI actually needs.
3. Building User Presence Was Surprisingly Interesting
I wanted ChatLoop to display:
Online
Away
Last seen...
Offline
A simple solution would be:
online: true
when the application opens and:
online: false
when it closes.
Unfortunately, browsers aren't that reliable.
What happens if:
- the browser crashes?
- the laptop loses internet?
- the phone kills the browser?
- the user closes the process unexpectedly?
The application might never get the chance to write:
online: false
So ChatLoop uses a heartbeat system.
The application periodically updates the user's presence.
If an online status becomes older than the allowed threshold, ChatLoop treats that user as offline.
I also listen for tab visibility.
When the tab becomes hidden:
online → away
and when the user returns:
away → online
There is also a privacy setting allowing users to hide their active status.
It seems like a small feature, but building it taught me a lot about how unreliable client state can be.
4. Then I Added WebRTC Calls
This was probably one of the most interesting parts of the project.
ChatLoop supports:
- audio calls
- video calls
- microphone mute/unmute
- camera controls
- screen sharing
- missed calls
- rejected calls
- reconnect attempts
WebRTC handles the actual peer-to-peer communication.
But two browsers still need a way to find each other and exchange connection information.
For that, I use Firestore as the signalling layer.
The basic process looks like this:
Caller
↓
Create call
↓
Create WebRTC offer
↓
Store signalling data in Firestore
↓
Receiver
↓
Create WebRTC answer
↓
Exchange ICE candidates
↓
Peer-to-peer connection
The call itself has lifecycle states such as:
calling
↓
active
↓
ended
and additional final states:
missed
rejected
5. Race Conditions Matter
One interesting problem happens when both users hang up almost simultaneously.
Both clients could potentially try to write the final call state and create the same "call ended" event.
I solved this using a transaction for final call states.
That way, only one final call event wins.
This was one of those situations where building the feature teaches you more than reading about the concept.
You start noticing race conditions because suddenly there are two real clients modifying the same state.
6. Opening the Call Screen Immediately
I didn't want this:
Click call
↓
Wait
↓
Wait for Firestore
↓
Open calling UI
It feels slow.
Instead, the call ID can be generated on the client.
The calling screen can therefore open immediately while the Firestore document is being created in the background.
This makes the experience feel significantly more responsive.
However, it creates another problem.
What if the caller hangs up before the Firestore write finishes?
The other person's device could potentially receive the call afterward and keep ringing.
So the hang-up flow needs to account for the initial call write before completing cleanup.
These are the kinds of problems I didn't think much about before building a calling system.
7. Network Changes Are Another Problem
WebRTC connections aren't always stable.
Users can move between:
Wi-Fi → mobile data
mobile data → Wi-Fi
or temporarily lose connectivity.
My WebRTC service handles an ICE restart attempt when the network changes before completely giving up on the connection.
I also learned why TURN servers matter.
Peer-to-peer WebRTC connections don't always work directly between restrictive networks.
For production-level reliability, a TURN server becomes important.
8. Voice Notes and File Uploads
ChatLoop also supports voice messages.
Users can record a message directly from the chat input and send it like a normal message.
I wanted voice notes to feel like an actual messaging feature instead of just displaying an HTML audio element, so the player includes a waveform-style interface.
Users can also send:
- images
- documents
- screenshots
and can drag files into the chat or paste screenshots directly.
I use Supabase Storage for these uploads while Firestore stores the related message information.
This gave me a useful separation:
Firestore
→ application/message data
Supabase
→ actual uploaded files
9. Friend Requests Became Their Own System
Users can discover other people using:
- name
- username
- four-digit ID
Then they can:
Send request
Accept
Decline
Cancel
Remove friend
The user document's friend list is ultimately the source of truth.
When a friend request is accepted, I update the request and both users' friend lists together.
Again, atomic operations become important.
You don't want:
User A says B is their friend
but
User B doesn't have A
because one database write failed.
10. I Added Gemini AI as Another Conversation
I also wanted ChatLoop to include an AI assistant.
Instead of making it feel like a completely separate feature, ChatLoop AI appears directly in the chat list.
Each user has their own AI conversation history.
The structure is roughly:
aiChats/{uid}/messages/{id}
When requesting a response, recent messages are provided as context to Gemini.
I don't send the user's entire lifetime conversation every time.
The service uses the most recent messages as context.
I also implemented a fallback model.
If the main Gemini model becomes overloaded, the application can attempt to use a lighter model instead.
One decision I particularly like is how errors are handled.
Instead of silently failing, an error can become part of the conversation so the user understands what happened.
11. Markdown Without dangerouslySetInnerHTML
AI responses often contain Markdown.
I wanted ChatLoop AI to render those responses nicely, but I didn't want to simply inject arbitrary HTML into the application.
So the Markdown rendering system doesn't rely on:
dangerouslySetInnerHTML
It's a small implementation detail, but security decisions like this become increasingly important once your application starts displaying generated or user-controlled content.
12. Building the Admin Panel Changed the Project
This was one of the biggest differences between ChatLoop and some of my earlier projects.
I started thinking:
If real users were using this application, how would I actually manage it?
That resulted in a complete /admin interface.
The dashboard displays information including:
- total users
- users currently online
- active users
- registrations
- conversations
- messages
- friendships
- reports
- suspended users
Administrators can:
- search users
- edit selected profile information
- suspend users
- remove suspensions
- review reports
- dismiss reports
- delete reported messages
- publish announcements
13. I Also Built a Read-Only Guest Admin
I wanted people visiting my portfolio or GitHub repository to actually see the administration system.
But obviously I couldn't give random visitors admin privileges.
So I created a guest administrator.
The guest can view the real dashboard but cannot perform administrative actions.
And importantly, this isn't just:
if (guest) {
disableButton()
}
The Firestore security rules also prevent guest writes.
That distinction is important.
14. The UI Is Not Security
This was probably one of the most valuable security lessons from this project.
ChatLoop doesn't have a traditional custom backend server.
That means Firestore Security Rules effectively become part of the backend security layer.
The rules enforce things such as:
Users can only modify permitted profile fields.
Only conversation participants can access private chats.
Only conversation participants can access messages.
Suspended users cannot send messages.
Suspended users cannot initiate calls.
Suspended users cannot send friend requests.
Guest administrators cannot modify admin data.
The frontend can hide a button.
But someone can modify frontend JavaScript.
They cannot simply override properly configured Firestore rules from the browser.
My rule became:
UI permissions determine what the user sees. Security rules determine what the user is actually allowed to do.
15. Reporting and Suspensions
I also wanted to explore moderation.
Users can report individual messages or entire accounts.
A message report can preserve information about the reported message so an administrator has context when reviewing it.
Administrators can then take actions such as removing the reported message or suspending the user.
Suspension also needed to affect the rest of the application.
When someone is suspended:
They see a suspension screen.
Friends see "Account suspended."
Messaging becomes unavailable.
Calling becomes unavailable.
Friend requests become unavailable.
And again, Firestore rules enforce those restrictions.
16. Admin Announcements
Admins can publish announcements that appear to users like notifications.
But I didn't want the same announcement to appear every time someone refreshed the application.
So ChatLoop creates a small version identifier based on the announcement content.
Once a user sees it, that version is stored.
If the administrator changes the announcement, the version changes and users receive the updated announcement.
It's a relatively small feature, but I enjoyed solving it.
17. Organizing the Codebase
As the application grew, architecture became increasingly important.
One rule I adopted was:
Components should not directly contain Firestore implementation logic.
Instead, Firebase-related functionality lives inside dedicated files.
A simplified structure looks like:
src/
components/
auth/
chat/
admin/
common/
firebase/
messaging
users
friends
reports
admin
service/
presence
WebRTC
hooks/
utils/
So if a component needs to send a message, it calls a function from the relevant Firebase service rather than implementing database operations directly inside the component.
That made the project significantly easier to reason about as it grew.
18. Some Things I Would Change at Larger Scale
ChatLoop isn't perfect.
There are several things I would approach differently if I were scaling it to a very large user base.
User search
The current friend search can load users and filter them client-side.
That's acceptable for a relatively small number of users.
It would not be my solution for hundreds of thousands or millions of accounts.
At that point, I'd move search to a proper indexing/search solution.
TURN infrastructure
WebRTC connections can fail when users are behind restrictive NATs or networks.
A production deployment should have reliable TURN infrastructure.
Group conversations
ChatLoop currently focuses on one-to-one communication.
Group messaging and group calling would require additional architecture.
19. What I Learned
ChatLoop taught me much more than simply how to build a chat interface.
I had to think about:
Real-time architecture
What actually needs a listener and what doesn't?
Consistency
What happens when one write succeeds and another fails?
Race conditions
What happens when two clients update the same state simultaneously?
Presence
How do you know someone is really online?
Networking
What happens when a WebRTC connection fails or the network changes?
Security
What prevents someone from bypassing the frontend?
Moderation
How would administrators handle abusive users?
Performance
How much data should remain live at once?
UX
How do you make operations feel immediate even when network requests are happening?
Those questions made this project much more valuable to me than simply creating another CRUD application.
Final Thoughts
ChatLoop started as a personal project.
But it became an opportunity for me to explore several areas of modern web development inside one application:
React
+
Firebase
+
Real-Time Data
+
WebRTC
+
Supabase
+
Gemini AI
+
Security
+
Moderation
+
Admin Tools
There are still many things I want to improve, and that's part of why I enjoy building projects like this.
Every new feature creates another engineering problem to solve.
I'm Ehan Siddique, and I build full-stack web applications while continuing to explore real-time systems, React, Next.js, Firebase and modern web technologies.
If you'd like to explore ChatLoop, you can find the project, source code and a more detailed case study through my portfolio and GitHub.
Portfolio: https://ehansiddique.com/projects/chatloop
GitHub: https://github.com/Fehan999/Chat-Loop
Live Link: https://chatloop-mu.vercel.app
If you're working on a real-time application yourself, I'd also be interested to hear what architecture and technologies you're using.
Top comments (1)