Build a 2026 Real-Time WebSocket Notification Engine with Bun.js, Elysia.js & PostgreSQL: A Scalable Tutorial for Indian Full-Stack Developers
Learn how to build a production-grade real-time notification engine using Bun.js, Elysia.js, and PostgreSQL — optimized for the Indian tech ecosystem and scalable to millions of concurrent connections.
Agentic Academy Labs
Author
Why Real-Time Notifications Matter in 2026
Real-time functionality is no longer a nice-to-have — it's the backbone of modern applications. Whether you're building a trading platform for Indian stock markets, a live order-tracking system for e-commerce, or a collaborative SaaS tool, WebSockets deliver the low-latency, bidirectional communication that users expect.
In this tutorial, we'll build a complete real-time notification engine using three tools that have reshaped the Node.js ecosystem in 2026:
- Bun.js — The blazing-fast JavaScript runtime that's now the default choice for many Indian startups
- Elysia.js — The lightweight, type-safe web framework that makes WebSocket integration effortless
- PostgreSQL — The battle-tested database powering everything from UPI-based fintech apps to enterprise SaaS platforms
By the end, you'll have a fully functional notification system that can handle thousands of concurrent connections with sub-50ms latency.
Prerequisites
Before diving in, make sure you have the following installed:
- Node.js 22+ or Bun 1.1+ (Bun is preferred for this tutorial)
- PostgreSQL 16+ running locally or via a managed service
- TypeScript knowledge (we'll write everything in TypeScript)
- Basic understanding of WebSocket protocols
If you're new to Bun, check out Bun's official docs — the runtime has matured significantly in 2026 with native SQLite, edge runtime support, and a dramatically improved DX.
Step 1: Project Setup
Create a new directory and initialize your project with Bun:
mkdir notification-engine && cd notification-engine
bun init -y
tbun add elysia @elysiajs/websocket pg @types/pg
bun add -d typescript @types/bun
Create a tsconfig.json for strict TypeScript:
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
Step 2: PostgreSQL Schema Design
A notification engine needs a robust database schema. Here's a production-ready design:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
body TEXT,
type VARCHAR(50) DEFAULT 'info',
is_read BOOLEAN DEFAULT FALSE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_unread ON notifications(user_id, is_read);
The JSONB column for metadata is critical — it lets you store arbitrary payloads (order IDs, transaction references, deep links) without schema migrations.
Step 3: Building the Elysia.js WebSocket Server
Elysia.js makes WebSocket integration remarkably clean. Here's our server:
// src/server.ts
import { Elysia, t } from "elysia";
import { websocket } from "@elysiajs/websocket";
import { Pool } from "pg";
const pool = new Pool({
connectionString: Bun.env.DATABASE_URL,
max: 20,
});
// Track active WebSocket connections per user
const activeConnections = new Map<number, Set<WebSocket>>();
const app = new Elysia()
.use(
websocket({
path: "/ws/notifications",
open(ws) {
console.log(`Client connected: ${ws.id}`);
},
message(ws, message) {
const data = JSON.parse(message.toString());
handleClientMessage(ws, data);
},
close(ws) {
cleanupConnection(ws);
console.log(`Client disconnected: ${ws.id}`);
},
// Handle ping/pong for connection health
ping(ws) {
ws.send(JSON.stringify({ type: "pong" }));
},
})
)
.get("/notifications/:userId", async ({ params }) => {
const { userId } = params;
const result = await pool.query(
`SELECT * FROM notifications WHERE user_id = $1 AND is_read = FALSE ORDER BY created_at DESC LIMIT 50`,
[userId]
);
return { notifications: result.rows };
})
.post("/notifications", async ({ body }) => {
const { userId, title, body: text, type, metadata } = body;
const result = await pool.query(
`INSERT INTO notifications (user_id, title, body, type, metadata)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[userId, title, text, type, JSON.stringify(metadata || {})]
);
// Broadcast to all connected WebSocket clients for this user
broadcastToUser(userId, {
type: "new_notification",
payload: result.rows[0],
});
return { success: true, notification: result.rows[0] };
})
.listen(3000);
function handleClientMessage(ws: WebSocket, data: any) {
if (data.type === "authenticate") {
ws.userId = data.userId;
if (!activeConnections.has(data.userId)) {
activeConnections.set(data.userId, new Set());
}
activeConnections.get(data.userId)!.add(ws);
}
}
function cleanupConnection(ws: WebSocket) {
for (const [userId, connections] of activeConnections.entries()) {
connections.delete(ws);
if (connections.size === 0) {
activeConnections.delete(userId);
}
}
}
function broadcastToUser(userId: number, message: any) {
const connections = activeConnections.get(userId);
if (connections) {
const payload = JSON.stringify(message);
connections.forEach((ws) => {
if (ws.readyState === 1) {
ws.send(payload);
}
});
}
}
console.log(`🚀 Notification engine running on port 3000`);
Step 4: Understanding the Architecture
Here's what makes this architecture powerful in 2026:
- Bun's native WebSocket support eliminates the need for external libraries like
wsorsocket.io— Bun handles the protocol natively with zero overhead - Elysia's type-safe routing means your API contracts are validated at compile time, catching bugs before deployment
- PostgreSQL's JSONB gives you schema flexibility for notification payloads without sacrificing query performance
- The
activeConnectionsMap provides O(1) lookup for broadcasting, which is critical at scale
Step 5: Client-Side Integration
Here's how a frontend client connects and listens for notifications:
// client.ts
class NotificationClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
connect(userId: number, token: string) {
this.ws = new WebSocket(`ws://localhost:3000/ws/notifications`);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.ws?.send(JSON.stringify({ type: "authenticate", userId }));
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data.toString());
this.handleMessage(data);
};
this.ws.onclose = () => {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect(userId, token);
}, delay);
}
};
}
private handleMessage(data: any) {
switch (data.type) {
case "new_notification":
this.showToast(data.payload);
break;
case "pong":
break;
}
}
private showToast(notification: any) {
// Integrate with your UI framework of choice
console.log(`🔔 ${notification.title}: ${notification.body}`);
}
}
The exponential backoff reconnection strategy ensures resilience — a critical pattern for production systems where network interruptions are common, especially on mobile networks across India's diverse connectivity landscape.
Scaling Strategies for Production in 2026
A single-server setup works for prototyping, but here's how to scale for production:
-
Horizontal Scaling with Redis Pub/Sub — When you run multiple Bun instances behind a load balancer, use Redis to broadcast notifications across all server instances. This ensures a user connected to Server B receives notifications published by Server A.
-
Connection Sharding by User ID — Distribute users across server instances using consistent hashing on
userId. This minimizes cross-instance communication. -
PostgreSQL Connection Pooling with PgBouncer — At scale, direct PostgreSQL connections from every WebSocket handler will exhaust your database. PgBouncer in transaction mode handles this elegantly.
-
Bun's Edge Runtime — Deploy your notification engine on Bun's edge runtime for global low-latency delivery. Indian users connecting from tier-2 and tier-3 cities will benefit significantly from edge deployment.
-
Notification Queue with BullMQ — For high-volume scenarios (think: flash sales on Indian e-commerce platforms), queue notifications through BullMQ backed by Redis. This prevents overwhelming your WebSocket layer during traffic spikes.
Why This Stack Wins for Indian Developers
The Bun + Elysia + PostgreSQL combination is particularly well-suited for Indian full-stack developers in 2026:
- Bun's speed means faster local development and CI/CD pipelines — critical when working with limited hardware
- Elysia's minimal boilerplate lets you focus on business logic rather than framework configuration
- PostgreSQL is free and open-source — no licensing costs for startups and freelancers
- The entire stack runs on affordable cloud instances — a ₹2,000/month DigitalOcean droplet can handle thousands of concurrent WebSocket connections
Conclusion
You now have a complete real-time notification engine that leverages the best of Bun.js, Elysia.js, and PostgreSQL. The patterns covered here — WebSocket connection management, type-safe routing, PostgreSQL-backed persistence, and exponential backoff reconnection — form the foundation of any production real-time system.
As you extend this project, consider adding notification preferences per user, WebSocket compression for bandwidth-constrained environments, and end-to-end encryption for sensitive notification payloads.
The Indian tech ecosystem is building the next generation of real-time applications, and with tools like Bun and Elysia making the stack faster and simpler than ever, there's never been a better time to start.
Happy coding! 🚀
Enjoyed this article?
Read More Articles