Django Channels : 3 erreurs qui ont tué mes WebSockets
Trois semaines en enfer avant que mes WebSockets tiennent la charge : connexions fantômes qui saturaient la RAM, Redis laissé en configuration par défaut, un Consumer monolithique qui bloquait tout. Les trois erreurs Django Channels et le code exact qui les règle.
Quand j'ai décidé d'ajouter des notifications en temps réel à Housing AI, mon agrégateur de logements au Québec, je pensais que Django Channels allait être un jeu d'enfant. Après tout, j'avais déjà une solide expérience avec Django et les WebSocket semblaient être la solution évidente pour notifier instantanément les utilisateurs de nouveaux logements correspondant à leurs critères.
Spoiler alert : j'ai passé 3 semaines en enfer.
Entre les connexions qui se multipliaient à l'infini, Redis qui plantait sous la charge et mes Consumers mal architecturés qui créaient des goulots d'étranglement, j'ai failli tout refaire avec FastAPI. Mais j'ai tenu bon, et aujourd'hui je veux partager les 3 erreurs critiques qui ont failli tuer mes WebSockets, et surtout, comment je les ai résolues.
L'erreur des connexions fantômes qui bouffent la RAM
Ma première grosse erreur avec Django Channels ? Sous-estimer complètement la gestion des connexions concurrentes. Dans mes tests locaux, tout fonctionnait parfaitement avec 2-3 connexions WebSocket ouvertes. En production, c'était l'apocalypse.
Le problème était vicieux : mes connexions WebSocket ne se fermaient jamais proprement. Chaque fois qu'un utilisateur fermait son onglet ou perdait sa connexion internet, mon Consumer continuait à tourner en arrière-plan comme un zombie. Résultat ? Au bout de quelques heures, j'avais des centaines de connexions fantômes qui bouffaient ma RAM.
# Mon Consumer initial (version qui tue la RAM)
class NotificationConsumer(AsyncWebsocketConsumer):
async def connect(self):
await self.channel_layer.group_add(
f"user_{self.user.id}",
self.channel_name
)
await self.accept()
# Pas de disconnect() propre = connexions fantômes garanties
La solution que j'utilise maintenant ? Un système de heartbeat combiné à un nettoyage automatique. J'ai implémenté un ping/pong toutes les 30 secondes pour détecter les connexions mortes, plus un task Celery qui nettoie les groupes de channels orphelins toutes les heures.
# Version corrigée avec gestion des connexions
class NotificationConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.user = self.scope["user"]
self.group_name = f"user_{self.user.id}"
await self.channel_layer.group_add(
self.group_name,
self.channel_name
)
await self.accept()
# Démarre le heartbeat
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
async def disconnect(self, close_code):
# Annule le heartbeat
if hasattr(self, 'heartbeat_task'):
self.heartbeat_task.cancel()
# Nettoie proprement le groupe
await self.channel_layer.group_discard(
self.group_name,
self.channel_name
)
async def heartbeat_loop(self):
while True:
try:
await asyncio.sleep(30)
await self.send(text_data=json.dumps({
'type': 'heartbeat'
}))
except Exception:
break
Depuis cette modification, zéro memory leak. Ma consommation RAM est restée stable même avec plus de 500 connexions simultanées.
Redis mal configuré = performance catastrophique
Deuxième erreur monumentale : ma configuration Redis était complètement à côté de la plaque. J'avais juste installé Redis avec les paramètres par défaut et je pensais que ça suffirait. Grosse erreur.
Mes Channel Layers étaient lents, très lents. Une notification qui devrait arriver instantanément prenait parfois 10 à 15 secondes. Mes utilisateurs sur Housing AI recevaient les alertes de nouveaux logements quand c'était déjà trop tard.
Le problème venait de plusieurs configurations Redis foireuses :
1. Pool de connexions insuffisant
# Configuration par défaut (limitée)
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
2. Pas de persistence configurée
Redis perdait toutes les connexions actives à chaque redémarrage.
3. Memory policy inadaptée
Redis éjectait mes channels au lieu des anciennes données.
Ma configuration Redis optimisée maintenant :
# Configuration Redis optimisée pour Channels
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
"capacity": 1500, # Messages max par channel
"expiry": 60, # TTL des messages
"group_expiry": 86400, # TTL des groupes (24h)
"symmetric_encryption_keys": [SECRET_KEY],
# Pool de connexions adapté
"CONNECTION_KWARGS": {
"max_connections": 50,
"retry_on_timeout": True,
"socket_keepalive": True,
"socket_keepalive_options": {},
"health_check_interval": 30,
}
},
},
}
Et dans mon redis.conf :
# Optimisations pour Django Channels
maxmemory 512mb
maxmemory-policy allkeys-lru
tcp-keepalive 60
timeout 300
# Persistence adaptée
save 900 1
save 300 10
save 60 10000
Résultat : mes notifications arrivent maintenant en moins de 200 ms. La différence est spectaculaire.
Architecture Consumer bancale = goulot d'étranglement garanti
Ma troisième erreur, et probablement la plus coûteuse : une architecture de Consumer complètement foireuse qui créait des goulots d'étranglement partout.
Au début, j'avais créé UN seul Consumer pour gérer toutes les notifications de Housing AI : nouveaux logements, mises à jour de prix, alertes de matching, messages du support. Tout passait par le même Consumer avec une énorme fonction receive qui faisait tout.
# Le Consumer monstre qui fait tout
class MegaNotificationConsumer(AsyncWebsocketConsumer):
async def receive(self, text_data):
data = json.loads(text_data)
if data['type'] == 'new_listing':
# 50 lignes de code...
elif data['type'] == 'price_update':
# 40 lignes de code...
elif data['type'] == 'match_alert':
# 60 lignes de code...
elif data['type'] == 'support_message':
# 30 lignes de code...
# ... et ainsi de suite
Le problème ? Chaque type de notification bloquait les autres. Si le traitement d'une alerte de matching prenait 3 secondes (requête à l'API OpenAI pour scorer les logements), toutes les autres notifications étaient en attente.
D'ailleurs, j'ai déjà parlé de cette tendance à créer des monstres monolithiques dans mon article sur les erreurs Django en production, c'est un piège classique qu'on retrouve partout.
Ma nouvelle architecture ? Un Consumer par type de fonctionnalité avec des tâches asynchrones pour les traitements lourds.
# Architecture modulaire avec routing
# websocket_routing.py
websocket_urlpatterns = [
path('ws/listings/', ListingConsumer.as_asgi()),
path('ws/alerts/', AlertConsumer.as_asgi()),
path('ws/support/', SupportConsumer.as_asgi()),
]
# Consumer spécialisé et optimisé
class ListingConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.user = self.scope["user"]
self.room_group_name = f"listings_user_{self.user.id}"
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
await self.accept()
async def new_listing_notification(self, event):
# Traitement léger et rapide
await self.send(text_data=json.dumps({
'type': 'new_listing',
'listing': event['listing_data']
}))
async def receive(self, text_data):
data = json.loads(text_data)
# Délègue les traitements lourds à Celery
if data['type'] == 'request_matches':
process_listing_matches.delay(
user_id=self.user.id,
channel_name=self.channel_name
)
Pour les traitements lourds, j'utilise des tâches Celery asynchrones qui renvoient le résultat via les Channel Layers :
# tasks.py
@shared_task
def process_listing_matches(user_id, channel_name):
# Traitement lourd (IA, calculs complexes...)
matches = heavy_matching_algorithm(user_id)
# Renvoie le résultat via WebSocket
channel_layer = get_channel_layer()
async_to_sync(channel_layer.send)(channel_name, {
'type': 'match_results',
'matches': matches
})
Cette architecture modulaire a divisé par 10 les temps de réponse et éliminé complètement les blocages.
Ce que j'aurais dû faire dès le début
Avec le recul, ces 3 erreurs auraient pu être évitées si j'avais commencé par les bonnes bases :
1. Toujours commencer petit
J'aurais dû implémenter un seul type de notification d'abord, bien tester la charge, puis étendre progressivement. Comme je l'explique dans mon approche pour créer un MVP rapidement, il faut valider chaque brique avant d'ajouter la suivante.
2. Tester la charge dès le début
Mes tests locaux avec 3 connexions ne valaient rien. J'utilise maintenant websocket-load-test pour simuler plus de 1000 connexions concurrentes dès le développement.
3. Monitorer en temps réel
J'ai ajouté des métriques Prometheus pour surveiller :
- Nombre de connexions actives
- Latence des notifications
- Utilisation mémoire de Redis
- Taux d'erreur des Consumers
4. Documentation des erreurs
Chaque bug que je résous est documenté avec la solution. Ma « tutorial hell » personnelle m'a appris que la meilleure documentation, c'est celle de tes propres erreurs.
La réalité du WebSocket en production
Aujourd'hui, Housing AI gère plus de 800 connexions WebSocket simultanées sans broncher. Les notifications de nouveaux logements arrivent en moins de 200 ms, et mes utilisateurs reçoivent leurs alertes avant même que les annonces apparaissent sur Kijiji ou LogisQuebec.
Mais soyons honnêtes : Django Channels, c'est pas magique. C'est un outil puissant qui demande une architecture réfléchie et une configuration aux petits oignons. Si tu débutes avec les WebSocket, attends-toi à galérer. C'est normal.
Mon conseil ? Commence par un projet de chat simple avant de te lancer dans quelque chose de complexe. Apprends à gérer les connexions, Redis et les Consumers sur un cas d'usage basique. Une fois que tu maîtrises les bases, tu peux attaquer les vrais projets.
Les WebSocket avec Django, c'est comme conduire une voiture de sport : c'est puissant, mais ça pardonne pas les erreurs de pilotage.
Tags
A lire aussi
Ma formule pour estimer un projet web sans me planter
Un mandat « refonte simple » estimé à 40 heures qui en prend 122 : le genre de dépassement qui te fait travailler à 26 $ l'heure sans t'en rendre compte. Voici la formule d'estimation que j'applique depuis, avec multiplicateur par type de client et marges fixes pour les zones grises récurrentes.
Facturer au forfait ou à l'heure : ce que j'ai appris en freelance
Le forfait mal cadré m'a fait travailler à moitié prix. Le taux horaire pur a puni ma rapidité. Voici comment j'ai fini par structurer mes devis avec un modèle hybride, cadrage payé puis heures plafonnées, et ce que ça a changé dans ma relation client.
React et TypeScript : quand dire non malgré la hype
Un site vitrine de cinq pages n'a aucune raison d'exister en React. Retour d'expérience d'un développeur au Québec sur le choix de stack : quand React et TypeScript valent vraiment le coup, et quand la hype coûte cher au client.

