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.
Written by

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