The simplest way to build an instant messaging app with Django ๐ŸŒฎ

Updated: Thu 29 February 2024

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 the sim directory.
  • Create a file named chat.html in the templates 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 the templates 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.

Let's get visual.

Do you want to create beautiful django frontends effortlessly?
Click below to book your spot on our early access mailing list (as well as early adopter prices).