Building a Real-time Notification System with Node.js and Redis Pub/Sub: Rescuing the Database at 2 AM

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Real-world Scenario: When ‘setInterval’ Silently Kills Your Database

Exactly at 2:00 AM, my phone started vibrating uncontrollably. Prometheus alerts were pouring in: the RDS database CPU was pinned at 98-100%. Users started shouting for the admin in community groups because notifications were lagging, or worse, disappearing entirely. After digging through the logs, I found a very familiar culprit: HTTP Polling.

To implement the ‘ping’ feature whenever someone likes or comments, the dev team had previously chosen a ‘quick-and-dirty’ solution for their RESTful APIs: having the Frontend call the GET /notifications API every 5 seconds. With 500 users, the system barely broke a sweat. But upon reaching 10,000 concurrent online users, the database had to shoulder over 120,000 requests per minute just to check if there was new data. This is a performance disaster that anyone working on large systems will likely encounter at least once.

Why Polling Isn’t the Answer to Scalability?

Fundamentally, HTTP is a traditional request-response protocol. The client asks, and then the server answers. In the context of real-time notifications, constant ‘checking in’ causes three major headaches:

  • Extremely Wasteful: 99% of requests return an empty [] array. However, the server still has to authenticate, query the DB, and package the JSON.
  • Frustrating Latency: If you set polling to 10 seconds, a user might have to wait exactly those 10 seconds before seeing a notification.
  • Bandwidth Hog: While HTTP request headers are small, when multiplied by millions of calls, the total volume will make your Cloud bill skyrocket.

I realized the system needed a proactive ‘Push’ mechanism for building real-time applications. The server should be the one to speak up as soon as an event occurs.

Approaches I Considered

At that time, I quickly reviewed the most viable options:

1. Pure WebSockets (Socket.io)

Open a full-duplex communication pipe between the client and server. When there is a new message, the server simply calls socket.emit(). This is extremely fast but hits a major wall: Difficulty in Horizontal Scaling.

Suppose I run 2 Node.js instances (Server A and Server B) behind a Load Balancer. User 1 connects to Server A, but the notification processing logic resides on Server B. At this point, Server B has no way to find User 1 to send the socket. As a result, the notification gets completely ‘lost’.

2. Server-Sent Events (SSE)

This solution is lighter than WebSockets because it only streams one-way from Server to Client. However, it still fails when it comes to sharing state between instances in a cluster.

The Savior Appears: Node.js + Socket.io + Redis Pub/Sub

To solve the horizontal scaling problem, I needed a ‘middleman messenger’ that every Node.js instance could listen to and speak with. Redis Pub/Sub (Publish/Subscribe) was the perfect fit. The workflow operates as follows:

  1. The user connects via Socket.io to any instance (A, B, or C) as directed by the Load Balancer.
  2. When a new event occurs, the Backend Publishes a message to a shared ‘channel’ on Redis.
  3. All Node.js instances Subscribing to that channel receive the message simultaneously.
  4. The instance currently holding the connection with the target User will emit the data to the browser.

Thanks to this mechanism, the system runs smoothly whether you are running 2 or 200 Node.js servers.

Implementation: From Idea to Code

First, you need a Redis server. The fastest way is to use Docker to spin up the environment in 30 seconds:

docker run -d --name redis-notify -p 6379:6379 redis

Next, initialize the Node.js project and install the standard libraries. I am using ioredis here for its stability and excellent Cluster support:

npm install express socket.io ioredis

Here is the simplified server.js file I used to ‘put out the fire’ that night:


const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const Redis = require('ioredis');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// Redis connections: One for publishing, one for listening
const redisPub = new Redis(); 
const redisSub = new Redis(); 

// Subscribe to the 'notifications' channel
redisSub.subscribe('notifications', (err) => {
    if (err) console.error('Redis connection failed:', err);
});

// Receive message from Redis and push to the correct client
redisSub.on('message', (channel, message) => {
    if (channel === 'notifications') {
        const data = JSON.parse(message);
        // Send notification to the user's private room
        io.to(`user:${data.userId}`).emit('new_notification', data.content);
    }
});

io.on('connection', (socket) => {
    const userId = socket.handshake.query.userId;
    if (userId) {
        socket.join(`user:${userId}`);
        console.log(`User ${userId} is online.`);
    }
});

// Simulated endpoint for events from another backend
app.get('/test-notify', (req, res) => {
    const { userId, message } = req.query;
    const payload = { userId, content: message, time: Date.now() };
    
    redisPub.publish('notifications', JSON.stringify(payload));
    res.send('Notification pushed to Redis!');
});

server.listen(3000, () => console.log('System ready at port 3000'));

During complex data processing between services, I often use toolcraft.app/en/tools/developer/json-formatter to quickly check JSON structures. It helps me catch missing fields or incorrect data types immediately without installing heavy extensions or typing messy terminal commands.

Results: Why Is This Approach So Effective?

After deployment, the database CPU graph plummeted from 100% to around 5-7%. We achieved three major goals:

  • Eliminated Polling: The database no longer has to answer millions of mindless questions every minute.
  • Resource Optimization: Code only runs when there is actually new data pushed into Redis.
  • Loose Coupling: The notification system is now completely decoupled. You can use Python, Go, or PHP to publish to Redis, and Node.js will still receive and push the socket as usual.

If you are struggling with a slow system, don’t rush into expensive hardware upgrades. Sometimes, just changing your mindset about data communication with Redis and Socket.io is enough to let you sleep soundly without worrying about midnight emergency calls.

Share: