Create a Chat App Using Django
Building a chat app is one of those projects that quietly teaches you almost everything important about modern web development. You touch authentication, database design, real-time communication, front-end interaction, asynchronous programming, and deployment. And unlike toy examples that disappear the moment the page reloads, a chat app feels alive. Messages appear instantly. Users connect and disconnect. Rooms form around topics, teams, friendships, support, and communities. It is a small product with a surprisingly rich set of moving parts, which makes it a perfect project for learning Django in a practical way.
In this article, we will build a chat application using Django from the ground up. We will not stop at static pages or fake “refresh to see new messages” behavior. Instead, we will use Django Channels and WebSockets so users can send and receive messages in real time. We will also use Redis as a channel layer, because real-time apps need a fast message broker to coordinate events efficiently. Along the way, we will build the database models, create login support, design chat rooms, handle message history, and add a clean frontend that feels responsive and usable. By the end, you will have a solid foundation for a real chat product that can be expanded into private messaging, group rooms, typing indicators, read receipts, file sharing, notifications, and more.
A good chat app is not just about moving text from one browser to another. It is about trust, speed, and flow. A user should be able to log in quickly, join a room naturally, and see messages arrive without friction. The page should not fight them. The code should be easy to understand six months later. And the structure should leave room for growth, because once people start using a chat system, they always ask for just one more thing. Today it is “can we have direct messages?”, tomorrow it is “can we see who is online?”, and by the end of the week it is “can we make notifications work on mobile too?”. We will build with that reality in mind.
What We Are Building
We are going to create a multi-room chat application with the following features:
Users will be able to register and log in. They will join specific chat rooms. Messages will appear instantly using WebSockets. Message history will be stored in the database. The application will distinguish between different users and show who sent each message. Rooms will be easy to create and display. The frontend will use Django templates for server-rendered pages, while JavaScript will handle the live interaction. Finally, we will prepare the project for production using Redis and a simple deployment strategy.
If you have used Django before, this will feel comfortable. If you have not, this project is still approachable because Django gives us a strong structure from the start. The main difference is that chat is not purely request-response. A normal Django view responds to a request and finishes. A chat app must keep a connection open so messages can flow both ways. That is where Django Channels comes in.
Why Django Channels Matters
Django on its own is excellent for standard web applications. It handles forms, authentication, database access, templates, and admin features beautifully. But traditional HTTP is not built for always-on two-way communication. For chat, we want the browser and server to keep a persistent connection open. WebSockets are designed for this. Django Channels extends Django so it can speak WebSockets and asynchronous protocols without fighting the framework.
The architecture is simple enough to explain in plain language. The browser opens a WebSocket connection to the server. When a user sends a message, JavaScript pushes the message through that connection. The server receives it, stores it if needed, and then broadcasts it to other users connected to the same room. Redis acts as the shared message broker that helps multiple server processes coordinate these events. This setup is what allows a chat room to feel instant and shared rather than delayed and isolated.
Project Setup
We will start by creating a Django project and installing the needed packages. The stack will include Django, Channels, Redis support for Channels, and a couple of standard helpers. You can use a virtual environment, which is always a good habit.
python -m venv env
source env/bin/activate # On Windows: env\Scripts\activate
pip install django channels channels-redis
Now create the project and an app for chat functionality.
django-admin startproject config .
python manage.py startapp chat
A clean project structure is helpful. At this point, your directory should look roughly like this:
project-root/
config/
__init__.py
asgi.py
settings.py
urls.py
wsgi.py
chat/
migrations/
__init__.py
admin.py
apps.py
models.py
routing.py
consumers.py
views.py
templates/
chat/
room.html
room_list.html
manage.py
That structure may look familiar, but there are two files that are especially important for real-time work: asgi.py and routing.py. We will return to them soon.
Configure Django Settings
Open config/settings.py and update the installed apps and channel layer configuration. Since this is a real-time app, we need to tell Django Channels how to handle asynchronous communication. We also need to tell Django where our templates and static files live, though the defaults may already be enough depending on your project layout.
# config/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'channels',
'chat',
]
ASGI_APPLICATION = 'config.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [('127.0.0.1', 6379)],
},
},
}
The ASGI_APPLICATION setting tells Django to use ASGI rather than only WSGI. ASGI is the interface that supports asynchronous features such as WebSockets. The CHANNEL_LAYERS section tells Channels to use Redis. In development, Redis can run locally. In production, you would point to your hosted Redis service.
If you are using a local Redis server, make sure it is running before testing the project. The chat application cannot broadcast messages correctly without it.
Create the Chat Models
Now let us design the database. A chat app usually needs at least two core models: one for rooms and one for messages. Rooms group conversations together, and messages belong to both a room and a user. The model design should stay simple enough to be flexible later.
# chat/models.py
from django.conf import settings
from django.db import models
class Room(models.Model):
name = models.CharField(max_length=255, unique=True)
slug = models.SlugField(max_length=255, unique=True)
def __str__(self):
return self.name
class Message(models.Model):
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='messages')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f'{self.user.username}: {self.content[:50]}'
This model pair gives us a clean starting point. Room has a human-readable name and a slug that can appear in URLs. Message links to the room and the author, stores the message text, and timestamps it automatically. Ordering messages by created_at ensures that old messages appear before new ones when we load room history.
Migrations come next.
python manage.py makemigrations
python manage.py migrate
You now have the database foundation for a working chat system. That may not seem exciting yet, but this is the point where a project starts becoming real. The most important apps are rarely complex at first. They are usually small systems that grow through careful layering.
Add the Models to Admin
Django’s admin is a gift. For a project like this, it lets you inspect rooms and messages quickly, which is useful during development.
# chat/admin.py
from django.contrib import admin
from .models import Room, Message
@admin.register(Room)
class RoomAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
prepopulated_fields = {'slug': ('name',)}
@admin.register(Message)
class MessageAdmin(admin.ModelAdmin):
list_display = ('room', 'user', 'created_at')
list_filter = ('room', 'created_at')
search_fields = ('content', 'user__username', 'room__name')
This is not required for the chat app to function, but it makes development much easier. If you are building anything serious with Django, the admin interface is often one of the fastest ways to validate your data and test changes.
Routing and ASGI Configuration
Django Channels needs an ASGI entry point. This is what allows HTTP and WebSocket traffic to coexist in the same project. Open config/asgi.py and configure it to use Channels routing.
# config/asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
import chat.routing
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": AuthMiddlewareStack(
URLRouter(
chat.routing.websocket_urlpatterns
)
),
})
This file is where the project decides how to handle each protocol. HTTP requests go through Django as usual. WebSocket requests go through the Channels routing system. The AuthMiddlewareStack is especially useful because it brings Django’s session-based authentication into WebSocket connections. That means we can know which logged-in user is sending a message.
Now create chat/routing.py.
# chat/routing.py
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/chat/(?P<room_name>[\w-]+)/$', consumers.ChatConsumer.as_asgi()),
]
This pattern tells Channels that any WebSocket connection going to a URL like /ws/chat/general/ should be handled by ChatConsumer. That consumer is the brain of the chat connection.
Build the Consumer
The consumer is where the real-time action happens. A consumer is similar to a Django view, but for asynchronous connections. It receives events, responds to them, and keeps the connection alive. We will create an asynchronous WebSocket consumer that handles joining a room, receiving a message, saving it, and broadcasting it.
# chat/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth import get_user_model
from .models import Room, Message
User = get_user_model()
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.room_name = self.scope['url_route']['kwargs']['room_name']
self.room_group_name = f'chat_{self.room_name}'
user = self.scope["user"]
if user.is_anonymous:
await self.close()
return
try:
self.room = await Room.objects.aget(slug=self.room_name)
except Room.DoesNotExist:
await self.close()
return
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
async def receive(self, text_data):
data = json.loads(text_data)
message = data.get('message', '').strip()
if not message:
return
user = self.scope["user"]
await Message.objects.acreate(
room=self.room,
user=user,
content=message
)
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'chat_message',
'message': message,
'username': user.username,
'user_id': user.id,
}
)
async def chat_message(self, event):
await self.send(text_data=json.dumps({
'message': event['message'],
'username': event['username'],
'user_id': event['user_id'],
}))
This consumer does several important things. When a user connects, it checks whether they are authenticated. If not, the connection is closed. Then it loads the room, adds the socket to the room group, and accepts the connection. When a message arrives, it validates the content, stores it in the database, and broadcasts it to everyone in the same room.
One small but important detail: the database methods aget and acreate are asynchronous versions. They are the correct choice inside an async consumer. Mixing synchronous database code into an async consumer can create performance issues and warnings, so this pattern matters.
Create Views for Rooms and Room Lists
We still need ordinary Django views to display pages. The WebSocket handles live messages, but users still need a room list and a room page to render in the browser. We will create a simple view to list available rooms and another to open a specific room.
# chat/views.py
from django.shortcuts import render, get_object_or_404
from .models import Room
def room_list(request):
rooms = Room.objects.all()
return render(request, 'chat/room_list.html', {'rooms': rooms})
def room(request, room_name):
room_obj = get_object_or_404(Room, slug=room_name)
messages = room_obj.messages.select_related('user').all()
return render(request, 'chat/room.html', {
'room': room_obj,
'messages': messages,
})
The room_list view provides a starting point. The room view loads the room and its messages. Notice that we use select_related('user') to reduce database queries when displaying message authors. This is a small optimization, but it matters when the room starts filling up.
Map the URLs
Now connect the views to URL routes.
# chat/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.room_list, name='room_list'),
path('room/<slug:room_name>/', views.room, name='room'),
]
Include these URLs in the project-level config/urls.py.
# config/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('chat.urls')),
]
This makes the chat app accessible from the site root. Later, you can add separate authentication routes, profile pages, or a dashboard without changing the chat architecture.
Create the Templates
Now we will design the user-facing pages. The templates do not need to be flashy, but they should be pleasant and functional. A chat interface should feel calm and readable rather than crowded.
Start with a room list template.
<!-- chat/templates/chat/room_list.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Rooms</title>
</head>
<body>
<h1>Available Chat Rooms</h1>
<ul>
{% for room in rooms %}
<li>
<a href="{% url 'room' room.name %}">{{ room.name }}</a>
</li>
{% empty %}
<li>No rooms available yet.</li>
{% endfor %}
</ul>
</body>
</html>
This is intentionally simple. A room list is mostly navigation. The real experience happens inside a room.
Now build the room template, where the live chat interface lives.
<!-- chat/templates/chat/room.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ room.name }}</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
background: #f5f7fb;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.chat-box {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
#chat-log {
height: 400px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
background: #fafafa;
}
.message {
margin-bottom: 12px;
padding: 10px 12px;
background: #eef4ff;
border-radius: 8px;
}
.meta {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.input-row {
display: flex;
gap: 10px;
}
#chat-message-input {
flex: 1;
padding: 12px;
border-radius: 8px;
border: 1px solid #ccc;
}
#chat-message-submit {
padding: 12px 18px;
border: none;
border-radius: 8px;
background: #2563eb;
color: #fff;
cursor: pointer;
}
</style>
</head>
<body>
<div class="container">
<h1>{{ room.name }}</h1>
<div class="chat-box">
<div id="chat-log">
{% for message in messages %}
<div class="message">
<div class="meta">{{ message.user.username }} • {{ message.created_at }}</div>
<div>{{ message.content }}</div>
</div>
{% endfor %}
</div>
<div class="input-row">
<input id="chat-message-input" type="text" placeholder="Type your message here..." autocomplete="off">
<button id="chat-message-submit">Send</button>
</div>
</div>
</div>
<script>
const roomName = "{{ room.name }}";
const chatSocket = new WebSocket(
'ws://' + window.location.host + '/ws/chat/' + roomName + '/'
);
const chatLog = document.querySelector('#chat-log');
const messageInput = document.querySelector('#chat-message-input');
const messageSubmit = document.querySelector('#chat-message-submit');
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
const messageDiv = document.createElement('div');
messageDiv.className = 'message';
const metaDiv = document.createElement('div');
metaDiv.className = 'meta';
metaDiv.textContent = data.username;
const contentDiv = document.createElement('div');
contentDiv.textContent = data.message;
messageDiv.appendChild(metaDiv);
messageDiv.appendChild(contentDiv);
chatLog.appendChild(messageDiv);
chatLog.scrollTop = chatLog.scrollHeight;
};
chatSocket.onclose = function(e) {
console.error('Chat socket closed unexpectedly');
};
function sendMessage() {
const message = messageInput.value.trim();
if (message) {
chatSocket.send(JSON.stringify({
'message': message
}));
messageInput.value = '';
}
}
messageSubmit.onclick = sendMessage;
messageInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
</body>
</html>
This template gives us a usable chat interface. Messages appear in the log, and new ones are sent over WebSockets. The browser appends incoming messages instantly, which is exactly the kind of response users expect from a modern chat app.
At this stage, the chat works in a basic form, but there is one important issue: the WebSocket URL is using ws://. In production, you will need wss:// on secure connections. We can make the JavaScript more robust by adapting to the current protocol.
<script>
const roomName = "{{ room.name }}";
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const chatSocket = new WebSocket(
protocol + '://' + window.location.host + '/ws/chat/' + roomName + '/'
);
</script>
That small change saves you from awkward deployment bugs later.
Add Authentication
A chat app usually needs authenticated users so messages can be attributed correctly. Django already gives us most of this. The simplest approach is to use the built-in authentication views and templates.
Add login and logout URLs.
# config/urls.py
from django.contrib import admin
from django.urls import path, include
from django.contrib.auth import views as auth_views
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('chat.urls')),
path('login/', auth_views.LoginView.as_view(template_name='registration/login.html'), name='login'),
path('logout/', auth_views.LogoutView.as_view(next_page='room_list'), name='logout'),
]
Then create a login template.
<!-- templates/registration/login.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Login</button>
</form>
</body>
</html>
You may also want a registration flow. Django does not force you into one structure, which is nice because it lets you choose simple or advanced patterns. For a small app, a custom signup view is enough.
# chat/views.py
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import login
from django.shortcuts import render, redirect
def signup(request):
if request.method == 'POST':
form = UserCreationForm(request.POST)
if form.is_valid():
user = form.save()
login(request, user)
return redirect('room_list')
else:
form = UserCreationForm()
return render(request, 'registration/signup.html', {'form': form})
And the URL:
# chat/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.room_list, name='room_list'),
path('room/<slug:room_name>/', views.room, name='room'),
path('signup/', views.signup, name='signup'),
]
With this in place, you have user accounts and session-based identity, which are essential for anything beyond a demo.
Make Room Slugs Work Smoothly
Earlier, we used room.name in the template URL. That works if the name is slug-safe, but it is cleaner to use the slug field. Update the room list template and any references so URLs are built from slugs.
<a href="{% url 'room' room.slug %}">{{ room.name }}</a>
And in the room view, use the slug when querying. We already did that. This makes URLs stable, readable, and safe for spaces or special characters in the room name.
Create Sample Rooms
You can add rooms through admin, or you can seed them manually in the shell. Here is one simple way:
python manage.py shell
from chat.models import Room
Room.objects.create(name='General', slug='general')
Room.objects.create(name='Support', slug='support')
Room.objects.create(name='Random', slug='random')
Now you have real rooms to test. This might seem like a small step, but a chat app without room data does not really feel like a chat app. It feels like an empty frame. A few seed records make development much more satisfying.
Improve the Chat Experience
A functional chat app is good. A chat app that feels pleasant is better. Let us make a few improvements that matter in real use.
First, auto-scroll to the newest message. We already set scrollTop = scrollHeight after appending a message. That is good, but it is also helpful to scroll after rendering initial history.
Second, display the sender’s name and timestamp more clearly. This helps users follow conversations when multiple people are active. A simple structure in the template can make a big difference.
<div class="message">
<div class="meta">
<strong>{{ message.user.username }}</strong>
<span>{{ message.created_at|date:"H:i" }}</span>
</div>
<div>{{ message.content }}</div>
</div>
Third, style your chat boxes so they feel less like form fields and more like conversation bubbles. Even basic visual polish changes how trustworthy the app feels. People judge a chat app very quickly. If the interface feels clumsy, they will assume the app is clumsy. If it feels calm and fast, they will relax into the conversation.
Add CSRF-Free Real-Time Sending Correctly
WebSocket messages are not normal form submissions. They do not need CSRF tokens in the same way standard HTTP POST requests do. That said, you should still validate and sanitize content on the server. Never trust message data just because it came from your own JavaScript. Users can open the browser console and send whatever they want.
Our consumer already strips the message and ignores empty input. That is a good starting point. You could also add limits:
async def receive(self, text_data):
data = json.loads(text_data)
message = data.get('message', '').strip()
if not message:
return
if len(message) > 1000:
return
This protects the app from absurdly long submissions and helps keep the database tidy. In a real application, you might return an error event to the client instead of silently ignoring it, but the pattern is the same.
Handle Anonymous Users Carefully
We closed the socket for anonymous users in the consumer. That is useful because many chat apps require login. But maybe you want public visitors to read messages without posting. In that case, you can allow anonymous access to the room page while preventing anonymous sending. The consumer can be adjusted depending on your product goals.
For a fully public chat room, the application might accept anonymous sockets but assign a guest name. That is a different design decision and comes with moderation concerns. For a typical app, authentication is the safest choice because it gives you accountability and better user history.
Add Presence and “Who Is Online”
Once the basic chat works, the next feature people often ask for is presence. They want to know whether someone is online, typing, or recently active. Django Channels can support this too. The general idea is to use the connection lifecycle to track when a user joins and leaves. You can store active users in Redis, a cache, or a dedicated presence table depending on your scale.
A simple approach is to maintain a set of active users per room in Redis and broadcast updates whenever the set changes. This is slightly more advanced, but the pattern fits naturally into the same architecture. The consumer’s connect and disconnect methods are the right place to manage this.
A conceptual example looks like this:
# pseudo-pattern, not a full implementation
async def connect(self):
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
await self.accept()
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'presence_update',
'username': self.scope["user"].username,
'status': 'joined',
}
)
async def disconnect(self, close_code):
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'presence_update',
'username': self.scope["user"].username,
'status': 'left',
}
)
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
This is one of those features that seems cosmetic at first but ends up defining the personality of the product. A room with presence feels alive. A room without it feels like a message archive.
Add Typing Indicators
Typing indicators are another classic feature. Someone starts typing, and the room shows a subtle hint such as “Alice is typing…”. This is mostly a frontend event that sends small WebSocket messages to the group without saving anything to the database. The consumer can relay typing events just like normal messages, but the client should display them differently and clear them after a short delay.
Because typing events are ephemeral, they should not be stored in the Message model. This is an important design distinction. Not every event belongs in the database. Some things are state, some are history, and some are simply signals.
Add Private Direct Messages
Rooms are great, but users often want one-to-one conversations too. Direct messaging usually needs a different model design. You might create a Conversation model that connects two users, then store messages under that conversation. The concept is the same, but the routing and permission checks become more important.
For example:
class Conversation(models.Model):
user1 = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='conversations_as_user1')
user2 = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='conversations_as_user2')
created_at = models.DateTimeField(auto_now_add=True)
class DirectMessage(models.Model):
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='messages')
sender = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
This is a natural next step once your room-based chat is stable. Many real-world applications begin with rooms and later add private messages without changing the core communication layer very much.
Add Better Frontend Behavior
The client-side JavaScript can be improved in several practical ways. You can disable the send button while disconnected, show connection status, and handle errors more gracefully. These improvements do not change the core architecture, but they make the app feel more polished and less mysterious when something goes wrong.
Here is a more careful version of the frontend logic:
<script>
const roomName = "{{ room.slug }}";
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const chatSocket = new WebSocket(
protocol + '://' + window.location.host + '/ws/chat/' + roomName + '/'
);
const chatLog = document.querySelector('#chat-log');
const messageInput = document.querySelector('#chat-message-input');
const messageSubmit = document.querySelector('#chat-message-submit');
function appendMessage(username, message) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message';
const metaDiv = document.createElement('div');
metaDiv.className = 'meta';
metaDiv.textContent = username;
const contentDiv = document.createElement('div');
contentDiv.textContent = message;
messageDiv.appendChild(metaDiv);
messageDiv.appendChild(contentDiv);
chatLog.appendChild(messageDiv);
chatLog.scrollTop = chatLog.scrollHeight;
}
chatSocket.onopen = function() {
messageSubmit.disabled = false;
};
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
appendMessage(data.username, data.message);
};
chatSocket.onerror = function() {
console.error('WebSocket error');
};
chatSocket.onclose = function() {
console.error('Chat socket closed unexpectedly');
messageSubmit.disabled = true;
};
function sendMessage() {
const message = messageInput.value.trim();
if (!message) return;
chatSocket.send(JSON.stringify({ message }));
messageInput.value = '';
}
messageSubmit.onclick = sendMessage;
messageInput.addEventListener('keyup', function(e) {
if (e.key === 'Enter') {
sendMessage();
}
});
</script>
Notice how the code is still simple, but it now communicates better with the user. A good app does not merely function. It explains itself through behavior.
Use a Better Layout with CSS
If you want the app to feel modern, spend a little time on layout. Chat is about speed, but it is also about comfort. A readable message area, enough whitespace, and clear hierarchy can make a huge difference.
You can move your CSS into a static file later, but even with inline styling, a few principles help. Keep the message area scrollable. Let the input stay visible. Use enough contrast for readability. Avoid cramming too much into the top of the page. And make sure the send button looks clickable.
A more refined layout could use a centered panel with rounded corners, subtle shadows, and color-coded metadata. Those details are small, but they quietly tell users that the app was made with care.
Testing the Chat App
Testing a chat app involves both Django behavior and WebSocket behavior. You can test models with standard Django tests, and you can test consumers using Channels’ testing utilities. Even if you do not write a complete test suite immediately, think about what should be covered.
At minimum, test that rooms can be created, messages save correctly, anonymous users are rejected if that is your rule, and authenticated users can send messages. Also verify that messages appear in order. If you later add private rooms or moderation rules, those need tests too.
A simple model test might look like this:
# chat/tests.py
from django.test import TestCase
from django.contrib.auth import get_user_model
from .models import Room, Message
User = get_user_model()
class ChatModelTests(TestCase):
def test_message_creation(self):
user = User.objects.create_user(username='alice', password='password123')
room = Room.objects.create(name='General', slug='general')
message = Message.objects.create(room=room, user=user, content='Hello world')
self.assertEqual(str(message), 'alice: Hello world')
self.assertEqual(room.messages.count(), 1)
That may look modest, but tests like this catch accidental regressions when you start adding more features.
Common Mistakes to Avoid
One common mistake is trying to use synchronous ORM calls directly inside async consumers without care. Another is forgetting Redis and assuming WebSockets will “just work” with plain Django. A third mistake is writing the frontend before thinking about room structure and permissions. The most painful bugs in chat apps are often not in the message display but in the edges: who can connect, who can post, and what happens when a room does not exist.
It is also easy to overlook secure deployment. A chat app should almost always use HTTPS and WSS in production. If you serve WebSockets over plain ws:// on a public site, you are creating avoidable risk. Secure the transport, validate access, and treat user input carefully.
Another mistake is making the interface too complicated too early. A chat app can become cluttered fast. Resist the urge to add every trendy feature on day one. Start with reliable messaging, then layer in presence, search, attachments, and notifications once the core is stable.
Deploying the Chat App
Deployment is where a project becomes useful to other people. For Django Channels, you need an ASGI-capable server. Daphne or Uvicorn are common choices. You also need Redis accessible from the production environment. Your static files should be served properly, and your environment variables should control secrets and settings.
A typical production stack might look like this:
Django running with ASGI. Redis for channel layers. PostgreSQL for persistent storage. Nginx as a reverse proxy. A secure domain with SSL/TLS enabled. Environment variables for settings like SECRET_KEY, DEBUG, database URL, and Redis host.
The exact deployment commands vary depending on your hosting provider, but the shape is always similar. The key thing is that chat apps are stateful in the sense of live connections, even though the framework is still Django. Your deployment platform must support that.
If you use a platform that abstracts some of this away, check whether it supports WebSockets and background connections. That detail matters more than it does for a standard blog or CRUD app.
Scaling the Chat App
Once your chat app gets users, scaling becomes a real concern. The main issues are concurrent connections, database writes, and message broadcast speed. Redis helps manage group communication across workers, but you still need to think about query efficiency and message retention.
Some useful scaling strategies include limiting message payload size, paginating history, adding indexes to frequently queried fields, using read replicas where appropriate, and archiving old messages for large rooms. You may also choose to move some data, such as typing indicators or online status, into Redis instead of the main database because those are transient events.
If your chat room becomes very active, loading thousands of messages into a single page is a bad idea. A better approach is to show recent messages first and fetch older history in pages or through lazy loading. This keeps the interface responsive and reduces memory usage in the browser.
Adding Search and Message History
Search is one of the most useful upgrades for any chat system. Users often need to find a message from last week, last month, or last year. Django makes this possible with standard ORM queries.
def search_messages(request):
query = request.GET.get('q', '')
results = Message.objects.filter(content__icontains=query).select_related('user', 'room')
return render(request, 'chat/search.html', {
'query': query,
'results': results,
})
This basic pattern works well for smaller apps. For larger systems, you may eventually use a dedicated search engine. But for many internal apps, built-in database search is more than enough to begin with.
You can also build a room message history view, allowing users to see older conversations in a structured way. That makes the app feel less like a stream and more like an archive when needed.
Practical Production Tips
A few practical habits will save you time later. Keep your consumer logic small and readable. Move complicated logic into helper functions or service classes. Avoid putting too much business logic directly into templates or JavaScript. Use select_related and prefetch_related where sensible. Be careful with user-generated content. Set clear limits on message length. And remember to verify that Redis, database migrations, and environment variables are correct before going live.
You should also think about moderation from the beginning, even if you only implement it later. A chat app can become noisy or abusive very quickly. Features like message deletion, user reporting, room moderation, and admin controls are not glamorous, but they are part of building a responsible product.
A More Complete Consumer Example
To close the loop, here is a more polished consumer that includes basic validation and clearer event handling.
# chat/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from .models import Room, Message
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.room_name = self.scope['url_route']['kwargs']['room_name']
self.room_group_name = f'chat_{self.room_name}'
user = self.scope["user"]
if user.is_anonymous:
await self.close()
return
try:
self.room = await Room.objects.aget(slug=self.room_name)
except Room.DoesNotExist:
await self.close()
return
await self.channel_layer.group_add(self.room_group_name, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
if hasattr(self, 'room_group_name'):
await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
async def receive(self, text_data):
try:
data = json.loads(text_data)
except json.JSONDecodeError:
return
message = data.get('message', '').strip()
if not message or len(message) > 1000:
return
user = self.scope["user"]
saved_message = await Message.objects.acreate(
room=self.room,
user=user,
content=message
)
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'chat_message',
'message': saved_message.content,
'username': user.username,
'created_at': saved_message.created_at.isoformat(),
}
)
async def chat_message(self, event):
await self.send(text_data=json.dumps({
'message': event['message'],
'username': event['username'],
'created_at': event['created_at'],
}))
This version adds a bit more safety and keeps the event data slightly richer. Even if your frontend does not use created_at immediately, sending it makes future enhancements easier.
Final Thoughts
Creating a chat app using Django is one of the best ways to move from “I know the framework” to “I understand how real web products work.” You start with a simple concept, but by the time the app is functioning, you have learned about database design, authentication, async programming, WebSockets, message brokering, templating, frontend interactivity, and deployment. That is a lot of value from a single project.
The best part is that the structure we built is not a dead end. It is a foundation. You can add direct messages, group moderation, emoji reactions, typing indicators, read receipts, notifications, search, attachments, and even voice or video integrations later. The core architecture stays useful because Django gives you a stable base and Channels gives you real-time power without forcing you to abandon Django’s strengths.
There is something satisfying about seeing a message appear instantly in another browser window. It feels simple when it works, but behind that simplicity is a lot of thoughtful engineering. That is the kind of project that teaches not just syntax, but judgment. And in web development, judgment is often the difference between a demo and a real product.