I wrote this tutorial to show you the simplest way to add async, real-time events with Django. This includes:
- no heavy dependencies. No Redis. No extra Django channels installation.
- reading from the Django database in real-time ๐๏ธ. We use the new async Django features
- using the lightest available setup (one
pip
command to install Daphne).- Daphne is fully-integrated into Django.
- Daphne is very easy to deploy in production: 2 lines (see here).
- fast to do โฐ I want to learn things as fast as possible. You probably do too.
Our finished product will look like this:
I've made a easy-to-follow video guide (featuring me ๐๐ฟ) that goes along with the step-by-step instructions. Here's the video:
Motivation:
Whenever I've looked online in the past for a nice example about adding real-time asynchronous events with Django, I've only found lengthy, complex articles - weighed down with dependencies and heavy services to install (like Redis).
Lots of steps.
Not any more.
This guide shows you the simplest way to add real-time events to Django ๐ฎ
Let's go ๐
0. Setup Django and Daphne
pip install django daphne
django-admin startproject core .
python manage.py startapp sim
Note: Make sure to use >=Django 4.2 . Otherwise, you will get errors like TypeError: async_generator object is not iterable
when using async views.
(Thanks to Daniel for pointing this out in the Youtube comments)
Add 'daphne' and your app to INSTALLED_APPS
in core/settings.py
# core/settings.py
INSTALLED_APPS = [
'daphne', # Add this at the top.
# ...
'sim',
# ...
]
Set ASGI_APPLICATION
in core/settings.py
- Add this line anywhere in the file.
# core/settings.py
ASGI_APPLICATION = 'core.asgi.application'
2. Add async and sync Django views to stream data
- Add the below to
sim/views.py
:
from datetime import datetime
import asyncio
from typing import AsyncGenerator
from django.shortcuts import render, redirect
from django.http import HttpRequest, StreamingHttpResponse, HttpResponse
from . import models
import json
import random
def lobby(request: HttpRequest) -> HttpResponse:
if request.method == 'POST':
username = request.POST.get('username')
if username:
request.session['username'] = username
else:
names = [
"Horatio", "Benvolio", "Mercutio", "Lysander", "Demetrius", "Sebastian", "Orsino",
"Malvolio", "Hero", "Bianca", "Gratiano", "Feste", "Antonio", "Lucius", "Puck", "Lucio",
"Goneril", "Edgar", "Edmund", "Oswald"
]
request.session['username'] = f"{random.choice(names)}-{hash(datetime.now().timestamp())}"
return redirect('chat')
else:
return render(request, 'lobby.html')
def chat(request: HttpRequest) -> HttpResponse:
if not request.session.get('username'):
return redirect('lobby')
return render(request, 'chat.html')
def create_message(request: HttpRequest) -> HttpResponse:
content = request.POST.get("content")
username = request.session.get("username")
if not username:
return HttpResponse(status=403)
author, _ = models.Author.objects.get_or_create(name=username)
if content:
models.Message.objects.create(author=author, content=content)
return HttpResponse(status=201)
else:
return HttpResponse(status=200)
async def stream_chat_messages(request: HttpRequest) -> StreamingHttpResponse:
"""
Streams chat messages to the client as we create messages.
"""
async def event_stream():
"""
We use this function to send a continuous stream of data
to the connected clients.
"""
async for message in get_existing_messages():
yield message
last_id = await get_last_message_id()
# Continuously check for new messages
while True:
new_messages = models.Message.objects.filter(id__gt=last_id).order_by('created_at').values(
'id', 'author__name', 'content'
)
async for message in new_messages:
yield f"data: {json.dumps(message)}\n\n"
last_id = message['id']
await asyncio.sleep(0.1) # Adjust sleep time as needed to reduce db queries.
async def get_existing_messages() -> AsyncGenerator:
messages = models.Message.objects.all().order_by('created_at').values(
'id', 'author__name', 'content'
)
async for message in messages:
yield f"data: {json.dumps(message)}\n\n"
async def get_last_message_id() -> int:
last_message = await models.Message.objects.all().alast()
return last_message.id if last_message else 0
return StreamingHttpResponse(event_stream(), content_type='text/event-stream')
3. Add URLs for your async and sync views
- Create
sim/urls.py
and add the following:
from django.urls import path
from . import views
urlpatterns = [
path('lobby/', views.lobby, name='lobby'),
path('', views.chat, name='chat'),
path('create-message/', views.create_message, name='create-message'),
path('stream-chat-messages/', views.stream_chat_messages, name='stream-chat-messages'),
]
Update core/urls.py
to include the app's URLs
# core/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('sim.urls')),
]
4. Add your templates, including an EventSource script to receive your server-sent events from Django
- Create a directory named
templates
in thesim
directory. - Create a file named
chat.html
in thetemplates
directory.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat</title>
</head>
<body>
<div class="header">
<h1>Welcome {{ request.session.username }}</h1>
</div>
<div class="container">
<div class="messages">
<div id="sse-data"></div>
</div>
<form x-cloak
@submit.prevent="submit" x-data="{state: 'composing', errors: {}}">
<div>
<textarea name="content" @input="state = 'composing'" autofocus placeholder="Your next message..."></textarea>
<button class="button">
Send
</button>
</div>
<div x-show="state === 'error'">
<p>
Error sending your message โ
</p>
</div>
</form>
<form action="/lobby/" method="get">
<button type="submit">Return to Lobby</button>
</form>
</div>
<script>
let eventSource;
const sseData = document.getElementById('sse-data');
function startSSE() {
eventSource = new EventSource('/stream-chat-messages/');
eventSource.onmessage = event => {
const data = JSON.parse(event.data);
const messageHTML = `
<div class="message-box">
<div class="message-author">${data.author__name}</div>
<div class="message-content">${data.content}</div>
</div>`;
sseData.innerHTML += messageHTML;
};
}
// On load, start SSE if the browser supports it.
if (typeof(EventSource) !== 'undefined') {
startSSE();
} else {
sseData.innerHTML = 'Whoops! Your browser doesn\'t receive server-sent events.';
}
</script>
<script>
function submit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const endpointUrl = "/create-message/"
fetch(endpointUrl, {
method: "post",
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token }}',
},
})
.then(response => {
this.state = response.ok ? 'success' : 'error';
return response.json();
})
.then(data => {
this.errors = data.errors || {};
});
}
</script>
<script defer="" src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.3/dist/cdn.min.js"></script>
</body>
</html>
Add a lobby, where users choose a name
- Create a file named
lobby.html
in thetemplates
directory.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign-in Page</title>
<style>
body {
font-family: 'Helvetica Neue', sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #e8eff1;
margin: 0;
color: #333;
}
.sign-in-container {
background: #ffffff;
padding: 40px 50px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
width: 300px;
}
.sign-in-container h2 {
text-align: center;
margin-bottom: 30px;
font-size: 24px;
color: #0a3d62;
}
.sign-in-container form {
display: flex;
flex-direction: column;
}
.sign-in-container input {
margin-bottom: 15px;
padding: 15px;
border: 1px solid #ced6e0;
border-radius: 6px;
font-size: 16px;
}
.sign-in-container button {
padding: 15px;
background-color: #2ecc71;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.sign-in-container button:hover {
background-color: #27ae60;
}
</style>
</head>
<body>
<div class="sign-in-container">
<h2>Enter your chat name:</h2>
<form method="post">
{% csrf_token %}
<input type="text" name="username" placeholder="Username" required>
<button type="submit">Join the chat</button>
</form>
</div>
</body>
</html>
6. Create Django models to store data to send in real-time
- Add the below to
sim/models.py
:
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=500)
class Message(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
- Run migrations to create the database table for the new model.
python manage.py makemigrations
python manage.py migrate
7. Run ๐โโ๏ธ
- Run the Django app:
python manage.py runserver
You should see something similar to the examples given below. Note that the Daphne server is working:
Django version 4.2.7, using settings 'core.settings'
Starting ASGI/Daphne version 4.0.0 development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
Access your Django application to see the realtime server-sent events
- Visit
http://127.0.0.1:8000/
in your web browser.
8. Bonus: Add styling to your chat interface
- Add styling to the
chat.html
template to include the chat interface and styling. Here's the full template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat</title>
<style>
body {
font-family: 'Arial', sans-serif;
background-color: #e8eff1;
margin: 0;
padding: 0;
color: #333;
}
.header {
color: #022c22;
font-size: 14px;
text-align: center;
}
.container {
max-width: 60%;
margin: auto;
}
.messages {
background: #ffffff;
border-radius: 8px;
padding: 20px;
margin-bottom: 30px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
font-size: 16px;
height: 50vh;
overflow-y: scroll;
}
.message {
border-bottom: 1px solid #ced6e0;
padding: 15px 0;
}
.message:last-child {
border-bottom: none;
}
form {
display: flex;
flex-direction: column;
}
textarea, input, button {
margin-bottom: 15px;
padding: 15px;
border: 1px solid #ced6e0;
border-radius: 6px;
font-size: 16px;
}
.button {
background-color: #2ecc71;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.3s;
}
.button:hover {
background-color: #27ae60;
}
.message-box {
background: rgba(247, 248, 245, 0.42);
border-left: 4px solid rgba(51, 177, 104, 0.42);
margin-bottom: 15px;
padding: 10px 15px;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.message-author {
font-weight: bold;
margin-bottom: 5px;
}
.message-content {
font-size: 16px;
line-height: 1.4;
}
textarea {
background: #f8f9fa;
border: 1px solid #ced4da;
box-sizing: border-box;
width: 100%;
padding: 12px 20px;
border-radius: 6px;
min-height: 100px;
font-size: 16px;
line-height: 1.5;
resize: none;
outline: none;
}
</style>
<style>
[x-cloak] {
display: none !important;
}
</style>
</head>
<body>
<div class="header">
<h1>Welcome {{ request.session.username }}</h1>
</div>
<div class="container">
<div class="messages">
<div id="sse-data"></div>
</div>
<form x-cloak
@submit.prevent="submit" x-data="{state: 'composing', errors: {}}">
<div>
<textarea name="content" @input="state = 'composing'" autofocus placeholder="Your next message..."></textarea>
<button class="button">
Send
</button>
</div>
<div x-show="state === 'error'">
<p>
Error sending your message โ
</p>
</div>
</form>
<form action="/lobby/" method="get">
<button type="submit">Return to Lobby</button>
</form>
</div>
<script>
let eventSource;
const sseData = document.getElementById('sse-data');
function startSSE() {
eventSource = new EventSource('/stream-chat-messages/');
eventSource.onmessage = event => {
const data = JSON.parse(event.data);
const messageHTML = `
<div class="message-box">
<div class="message-author">${data.author__name}</div>
<div class="message-content">${data.content}</div>
</div>`;
sseData.innerHTML += messageHTML;
};
}
// On load, start SSE if the browser supports it.
if (typeof(EventSource) !== 'undefined') {
startSSE();
} else {
sseData.innerHTML = 'Whoops! Your browser doesn\'t receive server-sent events.';
}
</script>
<script>
function submit(event) {
event.preventDefault();
const formData = new FormData(event.target);
const endpointUrl = "/create-message/"
fetch(endpointUrl, {
method: "post",
body: formData,
headers: {
'X-CSRFToken': '{{ csrf_token }}',
},
})
.then(response => {
this.state = response.ok ? 'success' : 'error';
return response.json();
})
.then(data => {
this.errors = data.errors || {};
});
}
</script>
<script defer="" src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.3/dist/cdn.min.js"></script>
</body>
</html>
9. Check out your real-time Django instant messenger app with server-sent events
- Visit
http://127.0.0.1:8000/
in your web browser. - Open several tabs and see the updates in realtime๐ ๐ฎ
Congratulations. You've built a real-time instant messenger app with Django and server-sent events, using the latest async Django features. ๐
Next steps? Deploy ๐
If enough people are interested, I'll write my next guide to show you how to fully deploy this app online.
Edit: Thanks for the interest ๐ Here's the guide: How to deploy a Django instant messenger app with real-time events
P.S Django frontend at warp speed? โก๏ธ
Do you also dream of creating Django products so quickly they break the space-time continuum? Yeah, me too. We're like Django wizards, eager to turn our magical ideas into reality at the snap of our fingers.
Well, let me introduce you to the magic wand I'm building: Photon Designer. It's a visual editor that puts the 'fast' in 'very fast.' When Photon Designer gets going, it slings Django templates at you faster than light escaping a black hole.
Warning: may cause excessive joy and productivity.