Culver is a real-time chat application, and I have been working mainly on the backend side of the product. My work so far has touched the database structure, user endpoints, message persistence, conversation history, Socket.io message events, message status tracking, and offline notification queueing.
It has been a very practical experience because I was not just reading about how these things work. I was actually building them, running into issues, debugging them, and learning how the different parts of the system connect.
My first major task was setting up the database layer with PostgreSQL and Knex.
We already had a DBML schema that described the tables we needed for the app, but I had to turn that schema into actual Knex migrations. This included tables like users, devices, sessions, contacts, conversations, conversation_members, messages, message_status, attachments, and notifications.
One of the first things I had to pay attention to was the order of the migrations. Some tables depend on others, so I could not just create them randomly. For example, the users table had to exist before tables like sessions, contacts, and messages, because those tables reference users. The same thing applied to conversations, which had to exist before conversation_members and messages.
That helped me understand foreign key relationships better in a real project. It is one thing to know that tables can reference each other, but it is different when you are actually writing the migrations and the order starts to matter.
I also added indexes where they were needed, especially for fields that would be queried often. One important example was messages. In a chat app, messages need to be fetched by conversation and sorted by time, so I added indexing around conversation_id and sent_at to support that.
After the migrations, I added seed data so we could test locally with sample users, conversations, and messages. This made development easier because we were not always working with an empty database.
After the tables were created, I worked on the Knex model layer.
At first, it is tempting to just write database queries directly inside route handlers, but I started to see why that can get messy quickly. So I worked with a cleaner structure where each table had its own model file.
For example, the user-related queries were handled in the users model, message-related queries in the messages model, conversation-related queries in the conversations model, and so on.
This made the code easier to reason about because the routes and socket handlers did not need to know all the details of the database queries. They could just call a model method like “find this user” or “create this message.”
That was one of the parts that helped me understand separation of concerns better. The route should not be doing everything. The socket handler should not be doing everything. Each part of the backend should have its own responsibility.
I also worked on some user endpoints, including:
GET /api/v1/users/:id
PATCH /api/v1/users/me
The GET /api/v1/users/:id endpoint fetches a user profile by UUID, while PATCH /api/v1/users/me allows the current user to update their profile information, such as their name, avatar URL, and bio.
At that point, the full authentication middleware was not ready yet, so for local development, I used a temporary x-user-id header to identify the current user. That was not meant to be the final solution, but it allowed the user profile feature to move forward while the authentication part was still being worked on.
That also taught me something about working in a team project, where I can create a temporary bridge so one part of the work does not block another.
The next major part I worked on was message routing and persistence.
I had to think about what happens when a user sends a message, how that message is saved, how other users receive it, and how the frontend can fetch old messages.
I added support for fetching message history through:
GET /api/v1/conversations/:id/messages
This endpoint returns messages for a conversation, but before it does that, it checks that the authenticated user is actually a member of that conversation. That check is important because users should not be able to fetch messages from conversations they do not belong to.
I also added pagination using a before timestamp and limit. This means the frontend does not need to load every single message in a conversation at once. It can load the latest messages first, then request older messages when the user scrolls up.
That made me understand why pagination is so important. A chat may look small at the beginning, but over time a conversation can have hundreds or thousands of messages. If the backend always returns everything, it will eventually become slow and inefficient.
I also worked on the conversation list endpoint:
GET /api/v1/conversations
This endpoint returns the conversations the authenticated user belongs to.
I also added model logic to fetch each conversation with its latest message and unread count.
This part made me appreciate how much work goes into something that looks simple on the UI. On most chat apps, the conversation list looks very basic, name, last message, time, unread count. But on the backend, you have to join the right tables, find the latest message, and calculate unread messages correctly for that specific user.
After working on REST endpoints, I moved into the Socket.io side of message handling.
I worked on the message:send event. The idea is that when a user sends a message, the client sends an encrypted payload to the backend. The backend does not need to decrypt the message. It only validates the payload, checks the sender’s membership in the conversation, saves the encrypted ciphertext, creates message status rows for the recipients, and emits the message to the conversation room.
This helped me understand that even if Socket.io is handling the real-time part, the message still has to be persisted properly. If the server only emits the message and does not save it, then the message history would be incomplete.
One coding issue I had to think through was message status.
At first, it may seem reasonable to mark a message as delivered once the server emits it to the room. But when I thought about it more, I realized that is not completely accurate. The server emitting a message does not guarantee that the recipient’s device actually received it.
So I followed a more accurate lifecycle:
sent → delivered → read
When the message is saved by the server, the recipient status starts as sent. It should only become delivered when the recipient client confirms that it received the message. Then it becomes read when the recipient actually reads it.
I also worked on the message:status event for delivered and read acknowledgements. One edge case I had to handle was making sure a user could not update the status of a message from another conversation. So before updating the status, the backend checks that the message actually belongs to the conversation being passed.
Another part I worked on was offline message queueing with BullMQ.
The idea is that if a recipient is offline when a message is sent, we should be able to queue a notification job for that user. The team already had a notification queue structure in the project, so instead of creating a new queue from scratch, I reused the existing queueNotification helper.
I had to pull the latest code, inspect what already existed, and adjust my implementation instead of duplicating work.
The flow became:
message is sent
message is saved
recipient statuses are created as sent
message is emitted to the conversation room
offline recipients are detected
notification jobs are queued for offline recipients
For offline recipients, the system queues one notification job per recipient. I think this makes sense because each recipient may have their own devices, notification settings, and retry behavior.
One thing that stood out to me this week was running yarn typecheck after pulling the latest main branch and seeing a lot of errors.
My first reaction was honestly that I may have broken something. But after looking more carefully, I realized the errors were coming from other parts of the project, like Cloudinary, Multer upload routes etc.
To confirm, I ran:
git diff --name-only
and saw that my current change was only in:
src/socket/handlers/messageHandlers.ts
That helped me separate my actual work from existing issues in the branch. I think that was an important moment for me because it reminded me not to panic when errors appear.
What I Learnt
Working on Culver has helped me understand backend development in a more practical way.
I learnt that a chat app is not just about sending messages. A proper chat system has to think about database structure, authentication, conversation membership, message persistence, real-time communication, message status, pagination, offline users, queues, and notifications.
I also learnt that small decisions matter. For example, starting a message status as sent instead of delivered seems small, but it makes the system more accurate. Checking that a user belongs to a conversation before returning messages seems simple, but it is important for security. Queueing notifications per offline recipient also makes the system easier to manage later.
Another big lesson was learning how to work inside a team codebase. Sometimes your work depends on what someone else has built. Sometimes another person’s changes affect your branch. So it is important to pull the latest code, inspect the structure, ask questions when needed, and avoid duplicating work.
Building this part of Culver has made me more confident as a backend developer.
Before this, I understood some of the tools individually, PostgreSQL, Knex, Express, Socket.io, Redis, and BullMQ. But working on this project helped me see how they come together to support one real product flow.
Now, when I send a message in a chat app, I think differently about what is happening behind the scenes. I think about how the message is validated, saved, emitted, tracked, delivered, read, and possibly queued for an offline user.
Top comments (1)
This was enlightening.