🏁 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
🗂 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))
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(),
),
]
📦 Redis: The Messaging Backbone
Add this to settings.py:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [("localhost", 6379)]},
},
}
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);
}
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 }));
});
});
}
🎨 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}`;
}
CSS Highlight:
.being-edited {
border: 2px solid orange;
background: #fff3cd;
}
💾 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"})
🔥 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)