DEV Community

Cover image for ⚡ Building a Real‑Time Collaborative Form Editing System with Django Channels, WebSockets & Redis
Bharat Solanke
Bharat Solanke

Posted on

⚡ Building a Real‑Time Collaborative Form Editing System with Django Channels, WebSockets & Redis

🏁 Introduction

In Today's fast-paced work environment. teams often need to edit shared forms simultaneously. Whether it's a multi-user dashboard, a shared data entry sheet, or a confirmation panel - the challenge is the same:

  • Without real‑time sync, users may overwrite each other’s work 😓
  • Constant page refreshes slow down productivity
  • Communication gaps can lead to missing or stale updates

💡 Real‑time collaborative editing solves this by ensuring that everyone connected to the same form sees changes instantly — just like in Google Sheets.

This blog will walk through how I built a multi‑user, live‑updating form system using:

  • Django Channels for async WebSocket handling
  • Redis as the message broker
  • JavaScript WebSockets API for sending/receiving updates

🔍 The Problem We’re Solving

Imagine a shared form where multiple people:

  • Type into different fields at the same time ✏️
  • Need to see each other’s changes immediately 👀
  • Want visual indicators showing who’s editing what 💬
  • Can dynamically add/remove form rows in real time ➕➖

Without real-time features:

  • Data can be out of sync between users
  • There’s a risk of accidental overwrites
  • The experience feels slow and outdated

🏗 High-Level Architecture

The system follows this flow:

User edits a field → Browser sends WebSocket message to Django  
Django Channels sends it to a Redis Pub/Sub group  
Redis broadcasts to all connected clients in that group  
Other users’ browsers instantly update the field
Enter fullscreen mode Exit fullscreen mode

🗂 Room-based grouping ensures only users editing the same form instance see each other’s updates

⚙ Tech Stack Overview

Technology --> Purpose

WebSockets --> Enables instant 2‑way communication
Django Channels --> Adds async WebSocket support to Django
Redis --> Distributes messages between server processes
JavaScript --> Captures inputs, sends updates, and applies received changes

🖥 Backend – Django Channels Consumer

The consumer acts as the WebSocket handler — connecting users, receiving messages, and broadcasting to others.

import json
from channels.generic.websocket import AsyncWebsocketConsumer

class CollaborativeFormConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # Identifiers define a unique group for the form session
        self.section_id = self.scope["url_route"]["kwargs"]["section_id"]
        self.template_id = self.scope["url_route"]["kwargs"]["template_id"]
        self.shift_id = self.scope["url_route"]["kwargs"]["shift_id"]

        self.group_name = f"form_{self.section_id}_{self.template_id}_{self.shift_id}"
        self.username = f"{self.scope['user'].first_name} {self.scope['user'].last_name}".strip()

        print(f"✅ Connected: {self.username} joined {self.group_name}")
        await self.channel_layer.group_add(self.group_name, self.channel_name)
        await self.accept()

    async def disconnect(self, close_code):
        print(f"❌ Disconnected: {self.username}")
        await self.channel_layer.group_discard(self.group_name, self.channel_name)

    async def receive(self, text_data):
        data = json.loads(text_data)
        event_type = data.get("type", "")

        # Broadcast to others in the group
        await self.channel_layer.group_send(
            self.group_name,
            {
                "type": "broadcast_message",
                "event": event_type,
                "sender": self.username,
                "sender_channel": self.channel_name,
                **{k: v for k, v in data.items() if k != "type"},
            },
        )

    async def broadcast_message(self, event):
        if event["sender_channel"] == self.channel_name:
            return  # Don’t send updates back to the sender

        payload = {k: v for k, v in event.items() if k not in ("sender_channel", "type")}
        payload["type"] = event["event"]

        await self.send(text_data=json.dumps(payload))

Enter fullscreen mode Exit fullscreen mode

Why this works well:
✅ Group-based rooms keep messages relevant
✅ Echo prevention avoids duplicate updates for the sender
✅ Works with Redis for multi-server scalability

🌐 WebSocket Routing

from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(
        r"ws/form/(?P<section_id>\d+)/(?P<shift_id>\d+)/(?P<template_id>\d+)/$",
        consumers.CollaborativeFormConsumer.as_asgi(),
    ),
]

Enter fullscreen mode Exit fullscreen mode

📦 Redis: The Messaging Backbone

Add this to settings.py:

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {"hosts": [("localhost", 6379)]},
    },
}

Enter fullscreen mode Exit fullscreen mode

Redis ensures broadcasts work across multiple Django workers.

✨ Frontend – JavaScript WebSocket Integration

Open the connection & sync fields:

function openFormWS(sectionId, shiftId, templateId, currentUser) {
  const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
  const wsUrl = `${protocol}${window.location.host}/ws/form/${sectionId}/${shiftId}/${templateId}/`;
  const ws = new WebSocket(wsUrl);

  ws.onopen = () => console.log(`🔗 Connected`);

  ws.onmessage = (e) => {
    const data = JSON.parse(e.data);

    if (data.type === 'update') {
      document.querySelectorAll(`[data-template-id="${templateId}"][data-field="${data.field}"]`)
        .forEach(input => {
          input.value = data.value;
          showLastEdited(input, data.sender);
        });
    }
    else if (data.type === 'focus') {
      indicateUserEditing(data.field, data.sender, templateId);
    }
    else if (data.type === 'blur') {
      removeUserEditing(data.field, templateId);
    }
  };

  wireRealtimeFields(templateId, ws, currentUser);
}

Enter fullscreen mode Exit fullscreen mode

Attach event listeners:

function wireRealtimeFields(templateId, ws, currentUser) {
  document.querySelectorAll(
    `[data-template-id="${templateId}"].form-field:not([data-wired="1"])`
  ).forEach(input => {
    input.dataset.wired = "1";

    input.addEventListener('input', () => {
      ws.send(JSON.stringify({ type: 'update', field: input.dataset.field, value: input.value, sender: currentUser }));
    });

    input.addEventListener('focus', () => {
      ws.send(JSON.stringify({ type: 'focus', field: input.dataset.field, sender: currentUser }));
    });

    input.addEventListener('blur', () => {
      ws.send(JSON.stringify({ type: 'blur', field: input.dataset.field, sender: currentUser }));
    });
  });
}

Enter fullscreen mode Exit fullscreen mode

🎨 Visual Indicators for Collaboration

function indicateUserEditing(field, user, templateId) {
  const el = document.querySelector(`[data-template-id="${templateId}"][data-field="${field}"]`);
  el.classList.add('being-edited');
  el.insertAdjacentHTML('afterend', `<span class="editing-badge">✏ Editing: ${user}</span>`);
}

function removeUserEditing(field, templateId) {
  const el = document.querySelector(`[data-template-id="${templateId}"][data-field="${field}"]`);
  el.classList.remove('being-edited');
  const badge = el.parentElement.querySelector('.editing-badge');
  if (badge) badge.remove();
}

function showLastEdited(input, user) {
  let badge = input.parentElement.querySelector('.last-edit-badge');
  if (!badge) {
    badge = document.createElement('span');
    badge.className = 'last-edit-badge';
    input.parentElement.appendChild(badge);
  }
  badge.textContent = `👤 Last edited by: ${user}`;
}

Enter fullscreen mode Exit fullscreen mode

CSS Highlight:

.being-edited {
  border: 2px solid orange;
  background: #fff3cd;
}

Enter fullscreen mode Exit fullscreen mode

💾 Adding an Autosave Feature

To ensure no data loss during network issues, we add a lightweight autosave API:

@csrf_exempt
@login_required
def save_autosave_data(request):
    data = json.loads(request.body)
    template_id = data.get("template_id")
    autosave_data = data.get("autosave_data")

    MyAutosaveModel.objects.update_or_create(
        template_id=template_id,
        defaults={"data": autosave_data},
    )
    return JsonResponse({"status": "success"})

Enter fullscreen mode Exit fullscreen mode

🔥 Challenges & Solutions

Challenge -> Solution

Avoid feedback loops -> Skip sending updates to the origin user

Two users editing same field -> Show “Currently Editing” badges

Dynamic form rows -> Re‑wire event listeners after adding

Offline scenarios -> Periodic autosave to server

🎯 Conclusion

By pairing Django Channels + WebSockets + Redis, we’ve built a scalable, real‑time collaborative form system that:

  • Keeps all connected users instantly in sync
  • Shows edit indicators for clarity
  • Prevents frustrating overwrites
  • Works for any multi-user form editing scenario

This architecture can be used in dashboards, CRMs, multi-user admin tools, or any shared data entry system you build.

Top comments (0)