WebSockets en Flask: cuándo usarlos y cómo llevarlos a producción
Un dashboard que refresca cada cinco segundos con una petición HTTP nueva reduce su retraso máximo si se baja el intervalo a un segundo, pero no elimina la ventana en la que el dato ya cambió antes de llegar, y multiplica por cinco el tráfico y la carga del servidor para conseguirlo. HTTP se diseñó para peticiones puntuales, no para una conversación continua entre cliente y servidor, y ahí es donde WebSocket no ajusta ese equilibrio sino que lo cambia: en vez de preguntar en bucle, abre una conexión que se queda abierta y por la que ambos lados escriben cuando hay algo que decir.
El protocolo en corto: qué resuelve y qué no
Un WebSocket empieza como una petición HTTP normal con una cabecera Upgrade: websocket; si el servidor la acepta, esa misma conexión TCP deja de hablar HTTP y pasa a intercambiar frames en ambas direcciones sin que cada mensaje necesite su propia petición-respuesta. El resultado es una conexión persistente y full-duplex: el servidor puede escribir sin que el cliente haya preguntado nada, y viceversa.
Eso no significa que WebSocket sea la respuesta por defecto para "tiempo real". Si el servidor solo necesita empujar datos hacia el cliente y este no tiene nada que responder por ese mismo canal, Server-Sent Events resuelve lo mismo sobre HTTP normal, con reconexión automática integrada en el propio EventSource del navegador y sin gestionar sockets a mano. Y si la frecuencia de cambio es baja o la frescura del dato no es crítica, seguir con polling, aunque sea poco elegante, evita abrir infraestructura nueva sin necesidad real.
| Técnica | Dirección | Reconexión | Cuándo tiene sentido |
|---|---|---|---|
| Polling | Cliente pregunta, servidor responde | No aplica (stateless) | Datos que cambian poco o cuya frescura no es crítica |
| Long-polling | Cliente pregunta, servidor retiene la respuesta | Manual tras cada respuesta | Compatibilidad con infraestructura que no soporta WebSocket |
| SSE | Solo servidor a cliente | Automática (EventSource) | Notificaciones o feeds de solo lectura sin respuesta del cliente por el mismo canal |
| WebSocket | Bidireccional | A implementar (o vía librería como Socket.IO) | Chat, colaboración en vivo, dashboards de alta frecuencia con entrada del cliente |
El caso del dashboard de la introducción cae del lado de WebSocket cuando, además de recibir actualizaciones, el propio panel necesita enviar acciones (filtrar, confirmar, reasignar) sobre la misma conexión; si solo consume, SSE es una opción más simple de operar.
El caso real: un panel de pedidos en vivo con Flask-SocketIO
Flask-SocketIO (versión 5.6.1) añade a Flask la capa de Socket.IO, que va por encima de WebSocket real: negocia el transporte, hace fallback a long-polling cuando WebSocket no está disponible y añade conceptos como rooms y namespaces que WebSocket puro no tiene. Por debajo usa python-socketio, en su release 5.16.4 del 7 de agosto de 2026 según el changelog oficial del proyecto, que expone también un cliente Python completo, útil para servicios internos que consumen los mismos eventos que el navegador.
El caso: un panel por tienda que muestra pedidos nuevos según entran, sin que cada dependiente tenga que refrescar. La versión ingenua de este ejemplo uniría al cliente a una room a partir de un store_id que el propio cliente envía por parámetro, pero eso es una vulnerabilidad de aislamiento multi-tenant, no un detalle menor: cualquiera que cambie ese parámetro entra en la room de otra tienda y ve sus pedidos. La tienda a la que pertenece una conexión tiene que derivarse de una credencial verificada en el servidor, nunca de un valor que el cliente declara:
import os
from flask import Flask, request
from flask_socketio import SocketIO, join_room, emit, disconnect, ConnectionRefusedError
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ['FLASK_SECRET_KEY'] # nunca hardcodeado fuera de desarrollo local
socketio = SocketIO(app, async_mode='gevent', cors_allowed_origins="https://panel.ejemplo.com")
# itsdangerous ya es dependencia de Flask; sirve para no montar un esquema
# de token propio en un ejemplo que ya tiene bastante superficie
token_serializer = URLSafeTimedSerializer(app.config['SECRET_KEY'])
# se llama al emitir la sesión del panel, por ejemplo tras el login del dependiente
def issue_store_token(store_id):
return token_serializer.dumps({'store_id': store_id})
def resolve_store_from_token(token):
"""Verifica el token firmado y devuelve el store_id al que da acceso,
o None si falta, no es válido o lleva más de una hora emitido."""
if not token:
return None
try:
data = token_serializer.loads(token, max_age=3600)
except (BadSignature, SignatureExpired):
return None
return data.get('store_id')
# sid -> store_id autenticado; con más de una instancia este diccionario en
# memoria no se comparte entre procesos, hace falta un almacén de sesión
# explícito (un Redis propio, no el message_queue de Socket.IO, que solo
# coordina broadcasts y rooms, no el estado de la aplicación)
authenticated_stores = {}
@socketio.on('connect')
def on_connect(auth):
store_id = resolve_store_from_token((auth or {}).get('token'))
if store_id is None:
raise ConnectionRefusedError('unauthorized')
authenticated_stores[request.sid] = store_id
join_room(f'store_{store_id}')
@socketio.on('disconnect')
def on_disconnect(reason):
authenticated_stores.pop(request.sid, None)
@socketio.on('ack_order')
def on_ack_order(data):
store_id = authenticated_stores.get(request.sid)
if store_id is None:
disconnect() # no debería llegar aquí sin haber pasado por connect, se corta la conexión
return
if not isinstance(data, dict) or 'order_id' not in data:
return # payload incompleto: se ignora en vez de asumir su forma
emit('order_ack', {'order_id': data['order_id']}, to=f'store_{store_id}', include_self=False)
def notify_new_order(store_id, order):
# se llama desde la vista o el servicio que crea el pedido, no desde un handler de socket
socketio.emit('new_order', order, to=f'store_{store_id}')
El token que issue_store_token genera es una firma URL-safe con marca de tiempo: loads(token, max_age=3600) comprueba la firma y además rechaza el token si tiene más de una hora, lanzando SignatureExpired o BadSignature según el caso. El argumento auth del handler de connect y la excepción ConnectionRefusedError son la vía documentada para autenticar en el momento de conectar: el cliente manda ahí ese token, no como un parámetro más de la URL, y el servidor corta la conexión antes de que exista ninguna room que unir. ack_order vuelve a comprobar la identidad guardada en authenticated_stores en vez de confiar en el store_id que venga en el payload; un cliente ya no puede forzar un emit hacia la room de otra tienda cambiando ese campo. join_room y emit(..., to=...) siguen siendo la API vigente para dirigir el mensaje a una room concreta; versiones antiguas de tutoriales usan broadcast=True para enviar a todos, pero eso reintroduce el mismo problema de aislamiento si la lógica de quién debe recibir cada evento no está resuelta antes.
Producción: lo que el tutorial no cuenta
Todo lo anterior funciona igual de bien en un portátil que en producción hasta que hay más de un usuario conectado a la vez desde procesos distintos. Ahí empiezan las decisiones que casi ningún tutorial de introducción cubre.
Despliegue. La documentación de Flask-SocketIO es explícita en un punto que ha cambiado con los años: eventlet "ya no se mantiene activamente", así que se recomienda gevent en su lugar; pero la autodetección de la librería sigue dando preferencia a eventlet sobre gevent cuando ambos están instalados, así que la recomendación solo se cumple si se fija explícitamente, como en el ejemplo anterior con async_mode='gevent'. El despliegue con Gunicorn usa un único worker por instancia en los tres modos soportados: gunicorn --worker-class eventlet -w 1 module:app, gunicorn -k gevent -w 1 module:app, o gunicorn -w 1 --threads 100 module:app si se prefiere threading en vez de un framework de corrutinas (esta última opción necesita además el paquete simple-websocket para servir WebSocket). La concurrencia dentro de cada instancia la dan las greenlets o los hilos, no procesos adicionales de Gunicorn: subir la capacidad significa levantar más instancias de ese mismo proceso detrás de un balanceador, no subir -w.
Escalado horizontal. Esas instancias adicionales solo conocen a los clientes que tienen conectados, así que un emit disparado en una no llega a un cliente conectado a otra a menos que exista un message queue compartido, Redis es la opción más simple, configurado con SocketIO(app, message_queue='redis://'). Eso resuelve la coordinación entre procesos, pero no el balanceo: el load balancer también necesita sticky sessions (en nginx, la directiva ip_hash) para que las peticiones de un mismo cliente lleguen siempre a la misma instancia; sin eso, el propio protocolo Socket.IO puede fallar la negociación de transporte antes incluso de llegar al message queue. La misma documentación señala que Gunicorn por sí solo no reparte con sticky sessions, así que ese balanceo tiene que resolverlo la capa de delante (nginx u otro proxy).
Reconexión del cliente. El cliente Python de Socket.IO reintenta solo tras una desconexión accidental, con backoff: por defecto reconnection=True, intentos infinitos (reconnection_attempts=0), un primer retraso de un segundo que se va duplicando hasta un máximo de cinco (reconnection_delay / reconnection_delay_max) y una variación aleatoria del 50% para que reconexiones simultáneas no lleguen todas a la vez. Pero los temporizadores por sí solos no reconstruyen el estado: al reconectar, el cliente ha salido de todas sus rooms y el servidor ya no tiene su sid anterior en authenticated_stores, así que el handler de connect vuelve a ejecutarse igual que en la primera conexión y tiene que reautenticar y volver a hacer join_room(), no asumir que sigue suscrito. Conviene además tratar ack_order de forma idempotente, porque un cliente que reconecta puede reenviar un ack que el servidor ya procesó, y no hay nada en este ejemplo que recupere los eventos ocurridos durante la desconexión: eso exige que el cliente guarde un cursor o el id del último evento recibido y lo reenvíe al reconectar, para que el servidor le mande solo lo que le falta.
Errores que aparecen justo al pasar de la demo a producción, y no antes:
- El broadcast llega solo a una parte de los clientes al escalar a más de una instancia: casi siempre falta el message queue, las sticky sessions, o ambos.
- Un handler bloquea el resto de conexiones cuando hace una llamada lenta (base de datos, HTTP externo) de forma síncrona; la operación debe ir en
socketio.start_background_task(), aunque eso solo cede el control en operaciones de I/O cooperativas: un cálculo intensivo de CPU dentro de ese background task sigue bloqueando el resto de conexiones del mismo worker, y necesita un proceso aparte (por ejemplo, un worker de Celery que emita después vía el message queue), no una tarea en background del propio servidor. - El handshake falla en producción pero no en local por CORS: si el frontend vive en otro dominio hace falta declarar
cors_allowed_originsexplícitamente, y el error que se ve en el navegador rara vez menciona CORS de forma directa. - La cola de mensajes está configurada pero no funciona porque falta instalar el cliente correspondiente (
pip install redispara Redis,kombupara RabbitMQ) además de tener el propio servicio de mensajería corriendo. - Con eventlet o gevent, el message queue se cuelga si no se aplica monkey patching al principio del script, antes incluso de los imports que lo necesitan.