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

Published: December 16, 2023

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

Subscribe to my free newsletter

Get updates on AI, software, and business.