DEV Community

Alair Joao Tavares
Alair Joao Tavares

Posted on • Originally published at activi.dev

Building Real-time Notifications: A Full-Stack Guide with Django Channels, React, and WebSockets

In today's web applications, user experience is paramount. Users expect dynamic, interactive interfaces that update instantly without needing to refresh the page. Whether it's a new message alert, a live sports score, or a progress update on a background task, real-time notifications are no longer a luxury—they're a core feature.

Traditionally, developers relied on techniques like short-polling, where the client repeatedly asks the server for new data. This is inefficient, creating unnecessary network traffic and server load. The modern solution is WebSockets, a protocol that provides a persistent, two-way communication channel between a client and a server.

This guide will walk you through building a complete, full-stack notification system. We'll leverage the power of Django Channels on the backend to manage WebSocket connections and broadcast messages, and we'll build a responsive frontend with React and TypeScript to consume and display these notifications in real-time. By the end, you'll have a solid foundation for adding real-time features to any Django-React application.

Part 1: Setting Up the Backend with Django Channels

Standard Django is built around a synchronous request-response model, which is perfect for traditional HTTP but not for long-lived connections like WebSockets. This is where Django Channels comes in. Channels extends Django's capabilities to handle asynchronous protocols, running alongside the standard Django framework on an ASGI (Asynchronous Server Gateway Interface) server instead of WSGI.

Step 1: Installation and Configuration

First, let's add Channels to our Django project. We'll also install channels_redis to use Redis as our channel layer. A channel layer is a communication backend that allows multiple Django instances and consumers to talk to each other. While you can use an in-memory layer for development, a robust backend like Redis is essential for production.

pip install channels channels-redis
Enter fullscreen mode Exit fullscreen mode

Next, we need to configure our Django project. In your settings.py file:

  1. Add 'channels' to your INSTALLED_APPS.
  2. Point ASGI_APPLICATION to your project's ASGI configuration.
  3. Configure the CHANNEL_LAYERS to use Redis.
# project/settings.py

INSTALLED_APPS = [
    # ... other apps
    'django.contrib.staticfiles',
    'channels', # Add channels
    'rest_framework',
    'example_app',
]

# ...

# Channels configuration
ASGI_APPLICATION = 'project.asgi.application'

CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Creating the ASGI Application

Now, create a file named asgi.py in your main project directory (alongside settings.py and wsgi.py). This file is the entry point for an ASGI-compatible application server.

# project/asgi.py

import os

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
import example_app.routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(
        URLRouter(
            example_app.routing.websocket_urlpatterns
        )
    ),
})
Enter fullscreen mode Exit fullscreen mode

Here's what this code does:

  • get_asgi_application() provides the standard Django HTTP handling.
  • ProtocolTypeRouter inspects the connection type. If it's a standard HTTP request, it's handled by Django. If it's a WebSocket request, we route it to our WebSocket-specific configuration.
  • AuthMiddlewareStack populates the connection's scope with the currently authenticated user, similar to how Django's AuthenticationMiddleware works for HTTP requests. This is incredibly useful for securing your WebSockets.
  • URLRouter maps WebSocket connection URLs to specific consumer functions, which we'll create next.

Part 2: Handling Connections with Consumers

A consumer is the Channels equivalent of a Django view. It's a piece of code that handles events for a WebSocket connection.

Let's create a file example_app/consumers.py and define a consumer to handle our notifications.

# example_app/consumers.py

import json
from channels.generic.websocket import AsyncWebsocketConsumer

class NotificationConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # Define a group name for general notifications
        self.group_name = 'public_notifications'

        # Join the group
        await self.channel_layer.group_add(
            self.group_name,
            self.channel_name
        )

        await self.accept()

    async def disconnect(self, close_code):
        # Leave the group
        await self.channel_layer.group_discard(
            self.group_name,
            self.channel_name
        )

    # This method is not used for broadcasting, but for receiving messages from a client.
    # We'll leave it here for completeness.
    async def receive(self, text_data):
        pass

    # This method is called when a message is sent to the group.
    # The name of the method corresponds to the 'type' in the group_send call.
    async def notification_message(self, event):
        message = event['message']

        # Send message to WebSocket
        await self.send(text_data=json.dumps({
            'message': message
        }))
Enter fullscreen mode Exit fullscreen mode

Key Concepts:

  • connect(): Called when a client initiates a WebSocket connection. Here, we add the connection to a group named 'public_notifications'. A group is a collection of channels that can be broadcasted to. Every connected user will be in this group.
  • disconnect(): Called when the connection closes. We remove the channel from the group to stop sending messages to it.
  • notification_message(self, event): This is a custom method. When we broadcast a message to the 'public_notifications' group with a type of 'notification.message', Channels will automatically invoke this method on every consumer in the group.

Now, we need to route WebSocket traffic to this consumer. Create example_app/routing.py:

# example_app/routing.py

from django.urls import re_path

from . import consumers

websocket_urlpatterns = [
    re_path(r'ws/notifications/$', consumers.NotificationConsumer.as_asgi()),
]
Enter fullscreen mode Exit fullscreen mode

This is analogous to Django's urls.py but for WebSockets. We're telling Channels that any WebSocket connection to the path ws/notifications/ should be handled by our NotificationConsumer.

Part 3: Broadcasting Messages from Django

The real power of this system comes from triggering notifications from standard Django code—like a view or a model signal.

Let's say we have a Django REST Framework view that creates a new Post object. We want to notify all connected clients that a new post has been published.

# example_app/views.py

from rest_framework import generics
from .models import Post
from .serializers import PostSerializer

from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

class PostCreateView(generics.CreateAPIView):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    def perform_create(self, serializer):
        # Save the new Post instance
        post = serializer.save()

        # Prepare the notification message
        message = f"New post published: '{post.title}'"
        channel_layer = get_channel_layer()

        # Broadcast the message to the group
        async_to_sync(channel_layer.group_send)(
            'public_notifications', # The group name we used in the consumer
            {
                'type': 'notification.message', # The handler method name in the consumer
                'message': message
            }
        )
Enter fullscreen mode Exit fullscreen mode

Here's the breakdown:

  1. get_channel_layer(): We get access to the channel layer we configured in settings.py.
  2. async_to_sync(): Since we're calling an async function (group_send) from a synchronous context (a Django view), we need this adapter from the asgiref library (which Channels installs).
  3. group_send(): This is the magic. We tell the channel layer to send an event to every member of the 'public_notifications' group.
    • The first argument is the group name.
    • The second argument is the event dictionary. The 'type' key is crucial; it dictates which method on the consumer will be called. Channels converts notification.message into notification_message.

Now, whenever a POST request is made to this view, our backend will broadcast a message to every connected client.

Part 4: Building the Real-time Frontend with React & TypeScript

With the backend ready, let's build a React component to connect to our WebSocket and display notifications.

Step 1: Defining the Notification Type

Using TypeScript, we can start by defining the shape of our notification data for type safety.

// src/types.ts

export interface NotificationPayload {
  message: string;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Creating a Notification Component

Let's create a component that establishes the WebSocket connection and manages a list of incoming notifications.

// src/components/NotificationBell.tsx

import React, { useState, useEffect } from 'react';
import { NotificationPayload } from '../types';

const NotificationBell: React.FC = () => {
  const [notifications, setNotifications] = useState<string[]>([]);
  const [showNotifications, setShowNotifications] = useState<boolean>(false);

  useEffect(() => {
    // Note: Use 'ws://' for development and 'wss://' for production with HTTPS
    const wsUrl = `ws://${window.location.host}/ws/notifications/`;
    const socket = new WebSocket(wsUrl);

    socket.onopen = () => {
      console.log('WebSocket connected');
    };

    socket.onmessage = (event) => {
      try {
        const data: NotificationPayload = JSON.parse(event.data);
        console.log('Received notification:', data.message);
        setNotifications(prevNotifications => [data.message, ...prevNotifications]);
      } catch (error) {
        console.error('Error parsing notification data:', error);
      }
    };

    socket.onclose = () => {
      console.log('WebSocket disconnected');
      // Optionally, you can implement reconnection logic here
    };

    socket.onerror = (error) => {
      console.error('WebSocket error:', error);
    };

    // Cleanup function to close the socket when the component unmounts
    return () => {
      socket.close();
    };
  }, []); // Empty dependency array ensures this effect runs only once on mount

  return (
    <div className="notification-container">
      <button onClick={() => setShowNotifications(!showNotifications)} className="bell-icon">
        🔔
        {notifications.length > 0 && <span className="badge">{notifications.length}</span>}
      </button>
      {showNotifications && (
        <div className="notification-list">
          {notifications.length > 0 ? (
            <ul>
              {notifications.map((msg, index) => (
                <li key={index}>{msg}</li>
              ))}
            </ul>
          ) : (
            <p>No new notifications.</p>
          )}
        </div>
      )}
    </div>
  );
};

export default NotificationBell;

Enter fullscreen mode Exit fullscreen mode

In this component:

  • We use the useState hook to store an array of notification messages.
  • The useEffect hook is the perfect place to manage the WebSocket lifecycle. It runs when the component mounts.
  • We instantiate a new WebSocket object, pointing it to the endpoint we defined in our Django routing.
  • socket.onmessage is the event handler for incoming messages. We parse the JSON data, verify it matches our NotificationPayload type, and update our component's state.
  • The return function inside useEffect is a cleanup function. It's called when the component unmounts, ensuring we close the WebSocket connection gracefully.
  • The JSX renders a simple bell icon with a badge showing the notification count and a dropdown to view the messages.

Practical Tips and Best Practices

  • Authentication: Our AuthMiddlewareStack in asgi.py already makes the user object available in self.scope['user']. In your consumer's connect method, you can check for an authenticated user and reject the connection if they are not logged in.

    # In your consumer's connect method
    async def connect(self):
        self.user = self.scope['user']
        if not self.user.is_authenticated:
            await self.close()
            return
        # ... continue with connection logic ...
    
  • User-Specific Notifications: To send notifications to a specific user, you can create a unique group name for them, like f'user_{self.user.id}', and add them to that group upon connection.

  • Scalability: Using Redis as your CHANNEL_LAYERS backend is the first and most important step for scalability. It allows your Django application to be horizontally scaled across multiple servers, and they can all communicate over the shared Redis message bus.

  • Frontend Libraries: While the native WebSocket API is powerful, libraries like react-use-websocket can simplify connection management, automatic reconnection, and message queueing.

Conclusion

We've successfully built a full-stack real-time notification system. By combining Django Channels' robust backend capabilities with React's dynamic frontend rendering, we've created a seamless and efficient solution.

The key takeaways are:

  1. Django Channels extends Django to handle asynchronous protocols like WebSockets via ASGI.
  2. Consumers are the equivalent of views for managing WebSocket connections.
  3. Channel Layers and Groups are the mechanism for broadcasting messages to multiple clients from anywhere in your Django application.
  4. React's useEffect hook is ideal for managing the WebSocket connection's lifecycle on the frontend.

This architecture is not only powerful but also scalable, providing a solid foundation for building a wide range of real-time features into your applications.

Top comments (0)