Un flujo de registro parece sencillo hasta que el usuario tiene que esperar un email. Si la verificacion ocurre dentro de la misma peticion, un proveedor lento convierte un signup normal en una pantalla que parece rota. En un SaaS pequeño esto se nota mucho: el equipo mira los logs, el usuario pulsa “reenviar” y terminamos procesando el mismo trabajo varias veces.
La solucion que me ha resultado mas practica es separar la peticion web de la verificacion con una cola pequeña. No hace falta empezar con Kafka. Una tabla en PostgreSQL, un worker y unos estados claros suelen ser suficiente para aprender qué está pasando y mejorar después.
El problema: cada signup bloquea demasiado
El endpoint de registro debería validar lo minimo, guardar el usuario y responder pronto. La verificacion del email puede tardar por rate limits, errores DNS o un proveedor temporalmente caido. Mezclar ambas cosas crea tres problemas:
- El navegador recibe timeouts aunque el trabajo siga vivo.
- Los reintentos del cliente crean emails duplicados.
- Es dificil distinguir un fallo real de una espera normal.
Una cola tambien ayuda cuando probamos con una fake email address o con un servicio de tempmail so durante una prueba. El objetivo no es esconder el riesgo, sino mostrarlo con un estado entendible.
La cola minima que funciona
Mi primera version puede tener estas columnas:
create table email_jobs (
id bigserial primary key,
user_id bigint not null,
kind text not null,
status text not null default 'pending',
attempts integer not null default 0,
available_at timestamptz not null default now(),
last_error text,
created_at timestamptz not null default now()
);
Al crear el usuario, insertamos un trabajo verify_signup. El endpoint responde 202 Accepted y el frontend muestra “Revisa tu correo”. Esto hace que la experiencia se sienta rapida, incluso cuando el proveedor necesita unos segundos.
En mi caso, una regla simple de productividad fue suficiente: un worker toma pocos trabajos cada vez, los bloquea con FOR UPDATE SKIP LOCKED y los marca como processing. Asi dos procesos no envian el mismo mensaje por accidente.
Estados y reintentos con contexto
Los estados no son solo detalles internos. Son una forma de hablar con soporte y con el usuario:
-
pending: el trabajo está esperando. -
processing: un worker lo está intentando. -
sent: el proveedor aceptó el email. -
retry: falló algo recuperable. -
failed: agotamos los intentos y hace falta revisar.
Guarda siempre last_error, el número de intento y un identificador de correlación. Un error como “timeout del proveedor, intento 2” es mucho mas util que “email failed”. Para reintentos, usa una espera creciente, por ejemplo 30 segundos, 2 minutos y 10 minutos. No reintentes para siempre: eso convierte un problema pequeño en una factura grande.
Tambien conviene diferenciar un dominio desechable de un fallo de red. La política de un SaaS puede marcar el primer caso para revisión, pero el worker no debería ocultar la razon. Los términos tepm mail com y tempail pueden aparecer en búsquedas o tickets escritos por usuarios; guardarlos como señales de texto ayuda a entender consultas reales, aunque no sean nombres de estados.
Ejemplo de implementación
El worker puede seguir una secuencia muy corta:
job = claim_next_job()
if not job:
sleep(2)
continue
try:
send_verification_email(job.user_id)
mark_sent(job.id)
except TemporaryProviderError as error:
schedule_retry(job.id, error)
except PermanentEmailError as error:
mark_failed(job.id, error)
El endpoint de “reenviar” no debería crear una fila sin mirar la anterior. Puede reutilizar un trabajo pendiente, o crear uno nuevo solo después de aplicar un cooldown. Ese detalle evita que un usuario impaciente genere diez mensajes en un minuto.
También separé el mensaje de verificacion de los recordatorios de onboarding. En otro experimento, los recordatorios de onboarding que si ayudan tenían una métrica distinta: no queríamos medirlos como si fueran emails críticos. Para el mismo motivo, los emails de churn con mejor contexto deben conservar el evento que los originó.
Errores comunes
- Una cola sin límite: define tamaño, antigüedad máxima y una alarma.
- Reintentar errores permanentes: valida la respuesta antes de programar otro intento.
- No tener idempotencia: usa una clave por usuario, tipo de email y ventana de tiempo.
- Mostrar “enviado” demasiado pronto: “aceptado para procesamiento” es más honesto.
-
Medir solo entregas: mide tiempo hasta
sent, reintentos y trabajos fallidos.
Checklist y recap
Antes de pasar esta parte del SaaS a más usuarios, compruebo:
- La petición web responde sin esperar al proveedor.
- Cada trabajo tiene estados, intentos y un error legible.
- El worker evita duplicados al reclamar filas.
- El reenvío tiene cooldown e idempotencia.
- Hay una vista o alerta para los trabajos
failed.
Una cola pequeña no es una arquitectura definitiva, pero sí una buena frontera para empezar. Hace que el Backend sea más observable y que el producto pueda crecer sin pedirle al usuario que espere mirando un spinner. Luego, cuando el volumen lo justifique, se puede cambiar la implementación interna sin cambiar el contrato del SaaS.
Top comments (0)