Validação de email no signup — lazy vs eager vs híbrido
Três padrões arquiteturais para validar email no cadastro: eager bloqueia e garante qualidade, lazy prioriza conversão, híbrido entrega os dois. Veja trade-offs, código e quando escolher cada um.
Validação de email no signup — lazy vs eager vs híbrido
Todo produto passa pelo mesmo momento: o usuário digitou um email no formulário de cadastro. O que acontece a seguir define três coisas ao mesmo tempo — taxa de conversão, qualidade da base e custo de operação.
Existem três padrões arquiteturais para esse problema. Nenhum é universalmente certo. Este artigo mapeia cada um com código real, trade-offs e quando cada escolha faz sentido.
O problema: UX vs. accuracy vs. custo
A tensão é triangular.
UX: Bloquear o usuário enquanto valida email aumenta atrito. Pesquisas da Baymard mostram que formulários com excesso de fricção no cadastro têm abandono significativamente maior. Cada segundo de espera conta.
Accuracy: Aceitar qualquer string que passa em /^[^@]+@[^@]+\.[^@]+$/ vai encher sua base de teste@aaa.com, endereços com typo e domínios que nunca existiram. Email marketing vai sofrer, deliverability vai cair.
Custo: Validação SMTP real tem custo — tanto em latência (200ms–2s por verificação) quanto em dinheiro se você usa uma API externa. Validar 100.000 signups por mês não é de graça.
Você precisa decidir onde aceitar a perda em cada vértice desse triângulo.
Padrão 1: Eager (síncrono, bloqueante)
O que é
O formulário chama a API de validação antes de criar o registro. Se o email for inválido, o cadastro não acontece. O usuário vê o erro inline e precisa corrigir antes de prosseguir.
Quando faz sentido
- Produtos B2B onde qualidade da base vale mais que conversão
- Fluxos de convite onde o email é o identificador primário (sem ele, não tem produto)
- Bases pequenas onde custo de validação é tolerável
- Contextos onde email inválido causa downstream damage imediato (envio de contrato, NF)
O que NÃO fazer
Não use eager em landing pages de volume alto. Se sua campanha traz 10.000 visitas/dia e a API de validação tem latência de 1.5s, você está adicionando 1.5s no caminho crítico do signup. Isso mata conversão.
Código
// api/signup.ts
import { validateEmail } from '@/lib/email-validator'
export async function POST(req: Request) {
const { email, name, password } = await req.json()
// Validação eager — bloqueia o fluxo
const validation = await validateEmail(email, {
checkDNS: true,
checkSMTP: false, // SMTP é lento demais para síncrono
timeout: 3000,
})
if (!validation.isValid) {
return Response.json(
{
error: 'EMAIL_INVALID',
reason: validation.reason, // 'domain_not_found' | 'mailbox_not_exist' | 'disposable'
},
{ status: 422 }
)
}
const user = await db.user.create({
data: { email, name, passwordHash: await hash(password) },
})
return Response.json({ userId: user.id }, { status: 201 })
}
O checkSMTP: false é intencional. Verificação SMTP real (tentativa de entrega) pode levar 1–5s e depende da resposta do servidor remoto. Para síncrono, você valida DNS (domínio existe? tem MX record?) e descarta emails descartáveis. Isso já elimina 60–70% dos lixos.
Padrão 2: Lazy (assíncrono, marca o lead)
O que é
O signup é aceito imediatamente. Uma job é enfileirada para validar o email em background. O registro fica com status email_verification_pending. O sistema age sobre o resultado quando a job termina.
Quando faz sentido
- Growth products onde conversão é métrica-rei
- Fluxos onde o usuário já está dentro do produto antes de precisar de email (onboarding-first)
- Quando você quer dados sobre a jornada do usuário mesmo que o email seja inválido
- Bases grandes onde validar sincrono é financeiramente proibitivo
Fluxo de estado
signup recebido
|
v
user.email_status = 'pending'
|
v
job enfileirada (BullMQ / SQS / Inngest)
|
/ \
sim não
válido inválido
| |
v v
'verified' 'invalid'
| |
continua trigger
normal ação
(block / notify / softdelete)
Código
// api/signup.ts — lazy pattern
export async function POST(req: Request) {
const { email, name, password } = await req.json()
// Validação mínima: só formato
if (!isEmailSyntaxValid(email)) {
return Response.json({ error: 'EMAIL_SYNTAX_INVALID' }, { status: 422 })
}
const user = await db.user.create({
data: {
email,
name,
passwordHash: await hash(password),
emailStatus: 'pending', // <-- estado inicial
},
})
// Enfileira validação — não bloqueia resposta
await emailValidationQueue.add('validate', {
userId: user.id,
email,
})
return Response.json({ userId: user.id }, { status: 201 })
}
// workers/email-validation.ts
emailValidationQueue.process('validate', async (job) => {
const { userId, email } = job.data
const result = await validateEmail(email, {
checkDNS: true,
checkSMTP: true, // podemos ser lentos aqui
})
await db.user.update({
where: { id: userId },
data: {
emailStatus: result.isValid ? 'verified' : 'invalid',
emailValidationReason: result.reason ?? null,
},
})
if (!result.isValid) {
await handleInvalidEmail(userId, result.reason)
}
})
O que fazer com emailStatus = 'invalid'?
Depende do produto. Opções comuns:
- Bloquear features sensíveis (envio de email, export de dados)
- Disparar fluxo de "confirme seu email" com recoleta
- Marcar lead como baixa qualidade no CRM
- Simplesmente logar e ignorar (lead analytics)
Nunca delete imediatamente — a validação pode ser falso-negativo (ver seção de edge cases).
Padrão 3: Híbrido (recomendado)
O que é
Três camadas em sequência:
- Regex + formato — síncrono, < 1ms, no cliente e no servidor
- DNS/MX lookup — síncrono no servidor, ~50–300ms, bloqueia só se domínio não existir
- Validação SMTP completa via API — assíncrona, enfileirada, não bloqueia signup
O usuário passa pelo signup rápido. A validação profunda acontece em background. Mas você já eliminou o lixo óbvio antes de criar o registro.
Por que é o padrão certo para a maioria dos produtos
Você entrega conversão alta (não adiciona latência perceptível ao usuário), elimina os erros mais grosseiros de forma síncrona (domínios inexistentes, typos como @gnail.com) e faz a validação profunda onde ela pode ser cara e lenta — no worker, assíncrono.
Código completo
// lib/email-validation-hybrid.ts
// Camada 1: regex + normalização (cliente e servidor)
export function validateEmailFormat(email: string): {
valid: boolean
suggestion?: string
} {
const normalized = email.toLowerCase().trim()
const formatOk = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/.test(normalized)
if (!formatOk) return { valid: false }
// Typo detection simples
const domain = normalized.split('@')[1]
const commonTypos: Record<string, string> = {
'gnail.com': 'gmail.com',
'gmial.com': 'gmail.com',
'hotmial.com': 'hotmail.com',
'yaho.com': 'yahoo.com',
}
if (commonTypos[domain]) {
return { valid: true, suggestion: `${normalized.split('@')[0]}@${commonTypos[domain]}` }
}
return { valid: true }
}
// Camada 2: DNS check síncrono no servidor
export async function validateEmailDNS(email: string): Promise<{
valid: boolean
reason?: string
}> {
const domain = email.split('@')[1]
try {
const mxRecords = await dns.resolveMx(domain)
if (!mxRecords || mxRecords.length === 0) {
return { valid: false, reason: 'no_mx_record' }
}
return { valid: true }
} catch {
return { valid: false, reason: 'domain_not_found' }
}
}
// api/signup.ts — hybrid pattern
export async function POST(req: Request) {
const { email, name, password } = await req.json()
// Camada 1: formato
const formatCheck = validateEmailFormat(email)
if (!formatCheck.valid) {
return Response.json({ error: 'EMAIL_FORMAT_INVALID' }, { status: 422 })
}
// Camada 2: DNS — síncrono, rápido o suficiente
const dnsCheck = await validateEmailDNS(email)
if (!dnsCheck.valid) {
return Response.json(
{ error: 'EMAIL_DOMAIN_INVALID', reason: dnsCheck.reason },
{ status: 422 }
)
}
// Cria o usuário com status pending
const user = await db.user.create({
data: {
email,
name,
passwordHash: await hash(password),
emailStatus: 'dns_verified', // passou DNS, aguarda SMTP
},
})
// Camada 3: SMTP completo — async, não bloqueia
await emailValidationQueue.add('deep-validate', {
userId: user.id,
email,
})
// Retorna sugestão de typo se houver
return Response.json(
{
userId: user.id,
...(formatCheck.suggestion && { emailSuggestion: formatCheck.suggestion }),
},
{ status: 201 }
)
}
Tabela de trade-offs
| Dimensao | Eager | Lazy | Hibrido |
|---|---|---|---|
| Latencia no signup | Alta (500ms–3s) | Minima (<100ms) | Baixa (50–300ms) |
| Conversion rate | Menor | Maior | Alta |
| Custo por signup | Alto (API sincrona) | Medio (API async) | Medio (DNS gratis + API async) |
| Accuracy imediata | Alta | Baixa | Media |
| Accuracy final | Alta | Alta | Alta |
| Complexidade impl. | Baixa | Media | Media-alta |
| Melhor para | B2B, invite-only | Growth, volume | Produto geral |
Edge cases que vão te pegar
Catch-all domains
Alguns domínios corporativos configuram catch-all: qualquer endereço *@empresa.com responde como válido no SMTP. joao.inexistente@empresa.com vai passar na validação SMTP mesmo não existindo. Não há solução perfeita — sinalize como catch_all e trate diferente na sua lógica de negócio.
Anti-validation (greylisting e rate limiting)
Servidores legítimos podem rejeitar a verificação SMTP por greylisting ou rate limiting, retornando falso-negativo. Por isso: nunca delete um usuário por resultado de validação assíncrona. Use invalid como sinal fraco, não como certeza.
Retry da job assíncrona
Sua job de validação vai falhar. Timeout, API fora, DNS flaky. Configure retry com backoff exponencial e um número máximo razoável de tentativas. Após N tentativas sem sucesso, marque como validation_failed — não como invalid.
emailValidationQueue.add('deep-validate', data, {
attempts: 4,
backoff: { type: 'exponential', delay: 2000 },
})
Implementação progressiva
Se você está refatorando um sistema existente, não tente ir de eager para híbrido de uma vez.
Semana 1: Adicione a coluna email_status com valor padrão legacy para registros existentes. Deploy sem mudar comportamento.
Semana 2: Implante a camada 1 (formato + typo detection) síncrona. Monitore falsos positivos.
Semana 3: Adicione DNS check síncrono. Meça impacto em conversão via A/B se tiver volume.
Semana 4: Implante a job assíncrona para SMTP. Comece apenas logando os resultados sem agir neles.
Semana 5+: Implante ações sobre email_status = invalid de forma gradual — bloquear features específicas antes de bloquear acesso total.
Essa progressão permite validar cada camada em produção antes de subir o próximo nível de rigor.
Conclusão
A escolha entre eager, lazy e híbrido não é técnica — é de negócio.
Se você precisa de dados limpos e pode se dar ao luxo de perder conversão, vai de eager. Se você está em fase de crescimento e cada signup conta, vai de lazy. Se você quer os dois ao mesmo tempo — e quase sempre você quer — o padrão híbrido entrega DNS síncrono rápido para eliminar lixo óbvio e delega a validação profunda para o background.
Para a maioria dos produtos em fase de produto-mercado fit, comece com eager simples (é menos código, menos infra) e evolua para híbrido quando o volume justificar a complexidade.
Mais detalhes sobre as APIs disponíveis para cada camada estão no guia completo de API de validacao de email. Exemplos em Node.js com tratamento de catch-all e retry disponíveis nos exemplos de validacao em Node.
Para a implementacao das camadas em codigo, veja as 4 abordagens de validacao de email em Node.js ou, se voce trabalha com Python, como validar email em Python sem reinventar a roda.
João Costa trabalha em engenharia no EmailChecker. Os padrões descritos aqui são os que usamos em producao para validar emails em escala.