Online Now: Building Real-Time Presence and Notification Systems
Knowing who's online and alerting them instantly changes how people use a product. This covers the full stack for presence tracking and live notifications, from Socket.io events to the Browser Notifications API and push with service workers.
Real-time notifications and user presence are essential features of modern applications. From showing who's online in a chat app to delivering instant push alerts, these features create engaging, responsive user experiences. This guide covers the fundamentals behind real-time systems, then dives into the actual implementation from ft_transcendence, a multiplayer Pong game with friend requests, game invitations, and chat notifications.
Fundamentals of Real-Time Notifications
Before implementing notifications, it's essential to understand the architectural patterns that power real-time systems. The choice between push and pull models, combined with event-driven architecture, determines how responsive and scalable your notification system will be.
Push vs Pull Models
Notification systems fundamentally follow one of two patterns:
PULL MODEL (Polling) ┌────────┐ ┌────────┐ │ Client │ ─── "Any updates?" ─────▶ │ Server │ │ │ ◀── "No" ──────────────── │ │ │ │ ─── "Any updates?" ─────▶ │ │ │ │ ◀── "Yes: new message" ── │ │ └────────┘ └────────┘ ⚠️ Wasteful requests, delayed delivery PUSH MODEL (WebSockets) ┌────────┐ ┌────────┐ │ Client │ ════ Persistent ═════════ │ Server │ │ │ Connection │ │ │ │ ◀── "Friend request!" ─── │ │ │ │ ◀── "Game invitation!" ── │ │ │ │ ◀── "New message!" ────── │ │ └────────┘ └────────┘ ✅ Instant delivery, efficient
Event-Driven Architecture
Real-time notifications rely on the Observer pattern, clients subscribe to events, and the server broadcasts when something happens. Socket.io implements this with named events and room-based targeting.
ft_transcendence NOTIFICATION ARCHITECTURE
┌────────────────────────────────────────────────────────────────────┐
│ NestJS Backend │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ AppGateway │ │ ChatGateway │ │ GameGateway │ │
│ │ /appGateway │ │ /chatGateway │ │ /game │ │
│ │ │ │ │ │ │ │
│ │ • Friend Req │ │ • Messages │ │ • Invitations │ │
│ │ • Block/Unblock │ │ • Join Room │ │ • Accept/Reject │ │
│ │ • Invitations │ │ • Refresh │ │ • Game Start │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └──────────┬─────────┴────────────────────┘ │
│ │ │
│ ┌────────────▼────────────┐ │
│ │ ConnectedClientsService │ ◀── Maps socketId → userId │
│ └─────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Client A │ │ Client B │ │ Client C │
│ (receives │ │ (receives │ │ (filtered │
│ notifs) │ │ notifs) │ │ out) │
└────────────┘ └────────────┘ └────────────┘
User Presence System
Presence tracking answers: "Who's online right now?" and "Is this user available to play?" In ft_transcendence, users have three states that affect game matchmaking and chat availability.
Presence States
| Status | Meaning | Can Receive Invites? |
|---|---|---|
| ONLINE | User is connected and available | Yes |
| INAGAME | User is currently playing a match | No |
| OFFLINE | User disconnected | No |
Database Schema
enum Status {
ONLINE
OFFLINE
INAGAME
}
model User {
id String @id @default(uuid())
name String? @unique
email String @unique
Avatar String?
status Status @default(ONLINE)
// ... other fields
}
Status Update Service
// users.service.ts
async updateUserStatus(id: string, Status: 'ONLINE' | 'OFFLINE' | 'INAGAME'): Promise<User> {
try {
return await this.prisma.user.update({
where: { id },
data: { status: Status },
});
} catch (error) {
throw new HttpException({
status: HttpStatus.INTERNAL_SERVER_ERROR,
error: 'InternalServerErrorException',
}, HttpStatus.INTERNAL_SERVER_ERROR, { cause: error });
}
}
Status Update on Logout
// Sidebar.tsx - Frontend
const updateData = { status: "OFFLINE" };
const handleLogOut = async () => {
const res = await fetch(
`${process.env.NEXT_PUBLIC_APP_URI}:3000/users/${userId}/userStatus`,
{
method: "PUT",
credentials: "include",
body: JSON.stringify(updateData),
}
);
// Clear cookies and redirect...
};
Connected Clients Tracking
To send targeted notifications, the server maintains a mapping between Socket IDs and User IDs. This enables cross-gateway notifications, for example, sending a message notification through the app gateway when a chat message arrives.
// connected-clients.service.ts
export const connectedClients: Map<string, string> = new Map();
@Injectable()
export class ConnectedClientsService {
private connectedClientsInChat: Map<string, string> = new Map();
constructor(private prisma: PrismaService) {}
addClient(client: Socket) {
const userId = client.handshake.auth.userId as string;
if (userId && client.id) {
if (!connectedClients.has(client.id)) {
connectedClients.set(client.id, userId);
}
}
}
getAllClients(): Map<string, string> {
return connectedClients;
}
removeClient(client: Socket) {
connectedClients.delete(client.id);
}
addClientInchat(client: Socket) {
const userId = client.handshake.auth.userId as string;
if (userId && client.id) {
if (!this.connectedClientsInChat.has(client.id)) {
this.connectedClientsInChat.set(client.id, userId);
}
}
}
getAllClientsFromChat(): Map<string, string> {
return this.connectedClientsInChat;
}
removeClientFromChat(client: Socket) {
this.connectedClientsInChat.delete(client.id);
}
}
App Gateway, Notification Hub
The App Gateway handles social notifications: friend requests, blocks, and targeted invitations. It broadcasts events to all connected clients or targets specific users.
// app.gateway.ts
@WebSocketGateway({
namespace: 'appGateway',
cors: { origin: `${process.env.APP_URI}:5173` },
})
export class AppGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
constructor(
private readonly usersService: UsersService,
private readonly connectedClientsService: ConnectedClientsService
) {}
@WebSocketServer() server: Server;
private logger: Logger = new Logger('APP Gateway Log');
handleConnection(client: Socket) {
this.connectedClientsService.addClient(client);
this.logger.log(`Client connected to APP server: ${client.id}`);
}
@SubscribeMessage('RequestFriendShip')
handleFriendRequest(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
this.server.emit('RequestFriendShip', data);
}
@SubscribeMessage('AcceptRequest')
handleFriendCreate(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
this.server.emit('AcceptRequest', data);
}
@SubscribeMessage('DeleteFriendShip')
handleFriendShipDelete(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
this.server.emit('DeleteFriendShip', data);
}
@SubscribeMessage('BlockFriend')
handleBlockfriend(@MessageBody() data: any, @ConnectedSocket() client: Socket) {
this.server.emit('BlockFriend', data);
}
// Targeted invitation — only notify the specific user
@SubscribeMessage('inviteUser')
async handleInviteUser(@MessageBody() payload: CreateFriendDto, @ConnectedSocket() socket: Socket) {
const user = await this.usersService.findOne(payload.userId);
for (const [key, val] of this.connectedClientsService.getAllClients()) {
if (val === payload.friendId) {
this.server.to(key).emit('invitation', `${user.name} sent you a friend request`);
}
}
await this.usersService.createFriend(payload);
}
handleDisconnect(client: Socket) {
this.connectedClientsService.removeClient(client);
this.logger.log(`Client disconnected from APP server: ${client.id}`);
}
}
Chat Gateway, Cross-Namespace Notifications
When a message is sent, the Chat Gateway notifies the receiver through the App Gateway. This pattern enables notifications to appear even when the user isn't actively in the chat view.
// chat.gateway.ts
@WebSocketGateway({
namespace: 'chatGateway',
cors: { origin: `${process.env.APP_URI}:5173/chat` },
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
constructor(
private readonly chatService: ChatService,
private readonly connectedClientsService: ConnectedClientsService,
private readonly usersService: UsersService,
private prisma: PrismaService,
private appGateway: AppGateway // Inject App Gateway for cross-namespace notifications
) {}
@WebSocketServer() server: Server;
handleConnection(client: Socket): void {
this.connectedClientsService.addClientInchat(client);
}
@SubscribeMessage('message')
async handleEvent(@MessageBody() payload: CreateChatDto, @ConnectedSocket() socket: Socket) {
// Check if sender is blocked by receiver
const blockedUser = await this.usersService.findOneBlockedUser(payload.receiverId, payload.senderId);
if (blockedUser) return;
const hashedRoomName = await this.chatService.generateHashedRoomId(payload.senderId, payload.receiverId);
if (!socket.rooms.has(hashedRoomName)) {
socket.join(hashedRoomName);
}
// Emit to chat room participants
this.server.to(hashedRoomName).emit('getMessage', {
senderId: payload.senderId,
receiverId: payload.receiverId,
text: payload.content,
room: hashedRoomName
});
await this.chatService.createChat(payload);
// Refresh receiver's chat list
for (const [key, val] of this.connectedClientsService.getAllClientsFromChat()) {
if (val === payload.receiverId) {
this.server.to(key).emit('refresh');
}
}
// Cross-namespace notification via App Gateway
for (const [key, val] of this.connectedClientsService.getAllClients()) {
if (val === payload.receiverId) {
this.appGateway.server.to(key).emit('notifMessage', payload);
}
}
}
handleDisconnect(client: Socket): void {
this.connectedClientsService.removeClientFromChat(client);
}
}
Frontend Notification Component
The Notification component listens to multiple socket events and aggregates friend requests, messages, and game invitations into a unified notification panel.
Socket Connection
// Notification.tsx
import { io } from "socket.io-client";
import Cookies from "universal-cookie";
const cookies = new Cookies();
export const socket = io(`${process.env.NEXT_PUBLIC_APP_URI}:3000/appGateway`, {
auth: { userId: cookies.get("id") },
});
Listening to Events
// Notification.tsx
function Notification({ userId, userSession }: props) {
const [toggle_notif, setToggle_notif] = useState<boolean>(false);
const [notification, setNotification] = useState<string>("");
const [friendShip, setFriendShip] = useState<friendShip[]>([]);
const [GameNotificat, setGameNoticat] = useState<notification[]>([]);
const gameSocket = useSocket();
const router = useRouter();
// Listen for real-time events
useEffect(() => {
// Game invitation received
gameSocket.on("gameInvitation", () => getGameInvitation());
// Game accepted — redirect to game
gameSocket.on('AcceptedGame', data => {
const { gameId } = data;
router.push(`/game/${gameId}`);
});
// Friend request events
socket.on("RequestFriendShip", (data) => {
setNotification(data.userId + data.stats + data.friendId);
});
socket.on("AcceptRequest", (data) => {
setNotification(data.userId + data.stats + data.friendId);
});
socket.on("DeleteFriendShip", (data) => {
setNotification(data.userId + data.stats + data.friendId);
});
// Message notification
socket.on("notifMessage", (data) => {
setNotification(data.senderId + data.content + data.receiverId);
});
return () => {
gameSocket.off("gameInvitation");
gameSocket.off("AcceptedGame");
socket.off("RequestFriendShip");
socket.off("AcceptRequest");
socket.off("DeleteFriendShip");
socket.off("notifMessage");
};
}, [notification]);
// ... render notification UI
}
Badge Counters
The notification badge shows a red dot when there are pending items. Unread messages are aggregated by sender with a count.
Badge Indicator
// Notification.tsx - Badge dot
<div className="relative" onClick={() => setToggle_notif(!toggle_notif)}>
<Image src={notification_b} width={40} alt="notifications" />
{/* Red badge dot when there are pending notifications */}
{((pendingUsers && pendingUsers.length > 0) ||
(pendingMessages && pendingMessages.length > 0) ||
(GameNotificat && GameNotificat.length > 0)) && (
<div className="absolute w-[12px] h-[12px] top-1 right-1 bg-red-400 rounded-full"></div>
)}
</div>
Message Count Aggregation
// utils.tsx - Aggregate unread messages by sender
export function usePendingMessage(MessageData: MessageData[]): notifMessage[] | null {
const [notifMessages, setNotifMessages] = useState<notifMessage[] | null>([]);
const notifMessagesMap: Map<string, notifMessage> = new Map();
useEffect(() => {
const senderIds = MessageData.map((msg) => msg.senderId);
const fetchUsers = async () => {
const userResponses = await Promise.all(
senderIds.map((senderId) =>
axios.get(`${process.env.NEXT_PUBLIC_APP_URI}:3000/users/${senderId}`)
)
);
const pendingUsersData = userResponses.map((response) => response.data);
MessageData.map((message, index) => {
const { senderId, content } = message;
if (notifMessagesMap.has(senderId)) {
// Increment count for existing sender
const existingMessage = notifMessagesMap.get(senderId);
if (existingMessage) existingMessage.numberOfMsg++;
} else {
// New sender
notifMessagesMap.set(senderId, {
user: pendingUsersData[index],
numberOfMsg: 1,
content,
});
}
});
setNotifMessages(Array.from(notifMessagesMap.values()));
};
if (senderIds.length > 0) fetchUsers();
}, [MessageData]);
return notifMessages;
}
Rendering Message Notifications with Badge
// Notification.tsx - Message notification item
{pendingMessages?.map((msg, index) => (
<li key={index} className="flex justify-between items-center">
<div className="flex items-center gap-[10px]">
<img src={msg.user.Avatar} alt="avatar" className="rounded-full w-[50px] h-[50px]" />
<div className="flex flex-col">
<p className="text-primary font-semibold">{msg.user.name}</p>
<p className="text-[#464646] w-[250px] truncate">{msg.content}</p>
</div>
</div>
{/* Unread count badge */}
<div className="bg-red-500 h-[30px] w-[30px] rounded-full flex justify-center items-center font-semibold text-white">
{msg.numberOfMsg}
</div>
</li>
))}
Game Invitation Notifications
Game invitations appear in the notification panel with accept/reject actions. Accepting redirects both players to the game.
// GameNotification.tsx
interface Props {
userId: string;
notification: { id: string; message: string; userImg: string };
}
function Challenge({ notification, userId }: Props) {
const socket = useSocket();
return (
<div className="flex justify-between items-center">
<div className="flex items-center gap-[10px]">
<img src={notification.userImg} alt="avatar" className="rounded-full w-[40px] h-[40px]" />
<p className="text-primary font-semibold text-xs">{notification.message}</p>
</div>
<div className="flex gap-[10px] items-center">
<button
className="bg-blue-500 text-white py-[5px] px-[10px] rounded-2xl"
onClick={() => socket.emit("acceptInvitation", { invitationId: notification.id, userId })}
>
challenge
</button>
<Image
src={close_r}
width={30}
alt="decline"
className="cursor-pointer"
onClick={() => socket.emit("rejectInvitation", { invitationId: notification.id, userId })}
/>
</div>
</div>
);
}
Friend Request Actions
Friend requests include accept/decline buttons that emit socket events to update both users' UI in real-time.
// Notification.tsx - Friend request actions
{pendingUsers?.map((user, index) => (
<li key={index} className="flex justify-between items-center">
<div className="flex items-center gap-[10px]">
<img src={user.Avatar} alt="avatar" className="rounded-full w-[50px] h-[50px]" />
<p className="text-primary font-semibold">{user.name}</p>
</div>
<div className="flex gap-[10px] items-center">
<button
className="bg-blue-500 text-white py-[5px] px-[10px] rounded-2xl"
onClick={async () => {
await updateFriend(user.id, userSession);
socket.emit("AcceptRequest", {
userId: user.id,
friendId: userId,
status: "AcceptRequest",
});
}}
>
Accept
</button>
<Image
src={close_r}
width={30}
alt="decline"
onClick={async () => {
await deleteFriend(user.id, userSession);
socket.emit("DeleteRequest", {
userId: user.id,
friendId: userId,
status: "DeleteRequest",
});
}}
/>
</div>
</li>
))}
Conclusion
The ft_transcendence notification system demonstrates key patterns for real-time applications: multi-namespace Socket.io gateways, cross-namespace event forwarding, connected client tracking, and aggregated notification badges.
Key takeaways:
- Namespace separation: Dedicated gateways for app, chat, and game keep concerns isolated
- Connected client maps: Track socket-to-user mappings for targeted notifications
- Cross-namespace events: Chat gateway notifies through app gateway for unified notifications
- Presence states: ONLINE, OFFLINE, INAGAME control matchmaking and availability
- Badge aggregation: Group messages by sender with unread counts
- Real-time actions: Accept/reject buttons emit socket events for instant UI updates
Explore the Code
Check out the ft_transcendence repository on GitHub to see the complete notification system implementation.
Working on something similar?
I'm a Technical Lead & AI Engineer building LLM-powered SaaS in production.
NestJS, Next.js and Django on Azure, from model integration to the architecture around it. If your team is working through the same problems, I'm happy to compare notes.
Get in touchWritten by

Technical Lead at iAgency (Casablanca), previously Technical Lead leading a 5-engineer team at Fygurs on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.