Cron em serverless: Vercel Cron, pg_cron ou Cloudflare Workers
As três opções resolvem agendamento de formas incompatíveis. O critério de escolha, e os quatro erros que aparecem em qualquer uma delas.
O erro tratado aqui
Cron job failed: GET /api/cron/faturas returned 401 UnauthorizedAmbiente testado
- Vercel Cron
- Supabase pg_cron 1.6
- Cloudflare Workers
- Postgres 15
Contexto: o que estava rodando
Uma rotina que fecha faturas: todo dia, à meia-noite, ela soma os consumos do dia anterior, gera a fatura e dispara o e-mail.
Em servidor tradicional isso é uma linha no crontab. Em serverless não existe servidor para hospedar o crontab, e as alternativas se comportam de maneiras bem diferentes.
O erro
A primeira versão usou Vercel Cron. O painel mostrava a execução acontecendo — e falhando:
/api/cron/faturas 0 0 * * * Last run: 21:00 UTC Status: 401Dois problemas numa linha só: o 401, e o horário — a rotina de “meia-noite”
estava rodando às 21:00 no horário de Brasília.
Diagnóstico
Uma rota de cron é uma URL pública
Este é o 401, e ele veio de uma correção anterior. Ao perceber que
/api/cron/faturas podia ser chamada por qualquer pessoa na internet — gerando
faturas duplicadas à vontade — eu tinha protegido a rota. Só que protegi contra o
próprio agendador também.
A Vercel envia um cabeçalho de autorização com o valor da variável CRON_SECRET.
A verificação precisa aceitar exatamente isso.
Expressão cron é sempre UTC
O 0 0 * * * significa meia-noite UTC, que é 21:00 no horário de Brasília.
Uma rotina que fecha “o dia anterior” rodando às 21:00 fecha o dia errado, com
três horas de consumo faltando.
Não existe configuração de fuso na expressão. O ajuste é aritmético: para meia-
noite em Brasília (UTC−3), a expressão é 0 3 * * *. E ela quebra se o país
voltar a adotar horário de verão — um bom motivo para a rotina calcular o
intervalo por data explícita, e não por “as últimas 24 horas”.
As três opções não são intercambiáveis
| Vercel Cron | Supabase pg_cron |
Cloudflare Workers | |
|---|---|---|---|
| Onde roda | Função HTTP sua | Dentro do Postgres | Runtime do Workers |
| Como agenda | vercel.json |
cron.schedule() em SQL |
wrangler.toml |
| Granularidade | Depende do plano | Por minuto | Por minuto |
| Duração | Limite da função | Sem limite prático | Limite do Worker |
| Acessa seu código | Sim | Não (só SQL, ou HTTP via pg_net) |
Sim, com outro runtime |
| Funciona se a app cair | Não | Sim | Sim |
| Repete se falhar | Não | Não | Não |
| Roda em preview | Não | Sim | Sim |
O critério de escolha é o que a tarefa precisa tocar:
Manutenção de dados — apagar registros antigos, recalcular agregados,
vacuum, atualizar materialized view — é pg_cron. Está no lugar dos dados, não
depende da aplicação estar de pé e não gasta invocação.
Regra de negócio que usa seu código — gerar fatura, mandar e-mail, chamar API externa — é Vercel Cron ou Workers, porque é onde a lógica vive.
Tarefa frequente e curta — a cada minuto, poucos segundos de trabalho — pesa para Workers, que foi desenhado para isso e custa menos por execução.
Ninguém repete por você
Nenhuma das três tem retry automático. Se a execução falhar — deploy no ar, banco indisponível, erro de código — aquela janela simplesmente não aconteceu. A próxima execução precisa perceber isso sozinha.
Duas execuções ao mesmo tempo
Uma rotina que passa a demorar mais que o intervalo eventualmente encontra a anterior ainda rodando. Sem trava, as duas processam as mesmas faturas.
A solução
-
Autenticar a rota do agendador.
src/app/api/cron/faturas/route.tsexport const maxDuration = 300; export async function GET(req: Request) { const esperado = `Bearer ${process.env.CRON_SECRET}`; // Comparação simples basta aqui: o segredo tem entropia alta e a rota não // devolve nada que ajude a adivinhá-lo. if (req.headers.get('authorization') !== esperado) { return new Response('não autorizado', { status: 401 }); } const processadas = await fecharFaturasDoDia(); return Response.json({ processadas }); }vercel.json{ "crons": [{ "path": "/api/cron/faturas", "schedule": "0 3 * * *" }] }O
CRON_SECRETprecisa existir nas variáveis de ambiente do projeto. Sem ela,process.env.CRON_SECRETéundefined, o valor esperado vira"Bearer undefined"e nada passa. -
Trabalhar com data explícita, nunca com “as últimas 24 horas”.
src/lib/faturas.ts/** * O dia é calculado no fuso do negócio, não no do servidor. Assim a rotina * fecha o mesmo período independentemente da hora em que rodar — inclusive * numa reexecução manual às 10h da manhã. */ export function diaAnteriorEmSaoPaulo(agora = new Date()) { const formatador = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Sao_Paulo', year: 'numeric', month: '2-digit', day: '2-digit', }); const hoje = formatador.format(agora); // 'AAAA-MM-DD' const ontem = new Date(`${hoje}T00:00:00-03:00`); ontem.setUTCDate(ontem.getUTCDate() - 1); return ontem; } -
Registrar cada execução e impedir sobreposição.
Uma tabela com restrição de unicidade resolve as duas coisas de uma vez:
prisma/schema.prismamodel ExecucaoCron { id String @id @default(cuid()) tarefa String competencia DateTime // o dia que esta execução fecha iniciadoEm DateTime @default(now()) concluidoEm DateTime? erro String? // Uma execução por tarefa por competência. O banco recusa a segunda. @@unique([tarefa, competencia]) }src/lib/faturas.tsexport async function fecharFaturasDoDia() { const competencia = diaAnteriorEmSaoPaulo(); const reserva = await prisma.execucaoCron.createMany({ data: [{ tarefa: 'faturas', competencia }], skipDuplicates: true, }); if (reserva.count === 0) { return { pulado: 'já processado ou em andamento' }; } try { const processadas = await gerarFaturas(competencia); await prisma.execucaoCron.update({ where: { tarefa_competencia: { tarefa: 'faturas', competencia } }, data: { concluidoEm: new Date() }, }); return { processadas }; } catch (err) { // Libera a competência para a próxima tentativa. await prisma.execucaoCron.delete({ where: { tarefa_competencia: { tarefa: 'faturas', competencia } }, }); throw err; } } -
Recuperar janelas perdidas.
Com o registro de execuções, achar o que faltou é uma consulta. A rotina passa a processar todas as competências pendentes, não só a de ontem:
src/lib/faturas.tsexport async function fecharPendentes(limiteDias = 7) { const feitas = await prisma.execucaoCron.findMany({ where: { tarefa: 'faturas', concluidoEm: { not: null } }, select: { competencia: true }, }); const jaFeitas = new Set(feitas.map((f) => f.competencia.toISOString())); for (let i = 1; i <= limiteDias; i++) { const dia = diaAnteriorEmSaoPaulo(new Date(Date.now() - (i - 1) * 86_400_000)); if (!jaFeitas.has(dia.toISOString())) { await fecharFaturasDoDia(); } } }O
limiteDiasevita que uma tabela vazia — num ambiente novo — dispare o processamento do histórico inteiro. -
Deixar manutenção de dados no banco.
O que é puramente SQL não precisa passar pela aplicação:
migracao/01-pg-cron.sqlcreate extension if not exists pg_cron; -- Limpeza diária às 4h UTC (1h em Brasília), fora do horário de pico. select cron.schedule( 'limpar-sessoes-expiradas', '0 4 * * *', $$ delete from sessoes where expira_em < now() - interval '7 days' $$ ); -- Agregado horário para o painel não recalcular a cada acesso. select cron.schedule( 'atualizar-resumo-diario', '5 * * * *', $$ refresh materialized view concurrently resumo_diario $$ );Consultar o que está agendado e como foi:
select jobid, schedule, jobname, active from cron.job; select jobid, status, return_message, start_time from cron.job_run_details order by start_time desc limit 20;
Como confirmar que resolveu
Chame a rota como o agendador chamaria:
curl -i -H "authorization: Bearer $CRON_SECRET" \
https://seu-app.vercel.app/api/cron/faturas
200 com o resumo. Sem o cabeçalho, 401.
Chame duas vezes seguidas. A segunda tem que responder já processado, sem
gerar nada. É o teste da trava de sobreposição.
Confira o horário real da execução no painel depois da primeira noite. A coluna de última execução mostra UTC — some ou subtraia o fuso antes de concluir que está errado.
Simule uma janela perdida. Apague a linha de ExecucaoCron de dois dias
atrás e rode a recuperação. Ela deve reprocessar exatamente aquele dia, sem tocar
nos outros.
Armadilhas que sobram depois disso
Cron da Vercel não roda em preview. Ele só é registrado no deploy de produção. Testar exige chamar a rota manualmente — e é por isso que a autenticação por segredo, e não por origem, é o desenho certo.
Planos gratuitos limitam a frequência. Uma expressão de minuto em minuto pode ser silenciosamente reduzida para uma vez ao dia. Confira o que o painel diz que está agendado, não o que você escreveu.
pg_cron não enxerga o seu código. Ele executa SQL. Para chamar um endpoint,
precisa da extensão pg_net, e aí você tem uma chamada HTTP disparada do banco —
sem retry, sem log da resposta e difícil de depurar.
Workers usam outro runtime. Bibliotecas que dependem de APIs do Node precisam da flag de compatibilidade, e algumas não funcionam mesmo assim. Reaproveitar o código da aplicação nem sempre é possível.
Rotina longa esbarra no limite da função. Fechar faturas de dez mil clientes não cabe numa invocação. O cron passa a ser o gatilho, e o processamento precisa ser fatiado.
Continue por aqui
APIs & Resiliência
FUNCTION_INVOCATION_TIMEOUT: quebrando trabalho longo em lotes
Aumentar o maxDuration só empurra a falha para um arquivo maior. A correção é a requisição iniciar o trabalho em vez de executá-lo até o fim.
APIs & Resiliência
Cortando o custo de API de LLM: medir, rotear e cachear
A fatura triplicou sem o uso triplicar. Como atribuir custo por funcionalidade e quais alavancas realmente rendem, na ordem de retorno.
APIs & Resiliência
Fila no Postgres com SKIP LOCKED, ou serviço de fila: qual escolher
Dois workers pegando a mesma tarefa é o defeito clássico de fila caseira. A cláusula que resolve, e quando vale trocar o banco por um serviço.