Estou usando uma rota da API do Next.js App Router (app/api/notify/route.ts) para verificar a hora atual (no fuso horário do Brasil) e disparar notificações quando um horário agendado for atingido.
Isso funciona localmente, mas depois de implantar no Vercel, notei um comportamento inesperado:
❌ Problema: Após a primeira solicitação, todas as solicitações subsequentes retornam o mesmo registro de data e hora (tempo congelado desde a primeira execução), mesmo que eu esteja usando new Date() no manipulador.
Parece que a função está sendo armazenada em cache ou o ambiente está reutilizando o mesmo contexto.
✅ Comportamento esperado: toda vez que a rota for atingida, new Date() deve retornar a hora atual, refletindo o momento real da solicitação.
✅ Etapas reproduzíveis: implante este manipulador simples no Vercel.
Envie várias solicitações GET para o endpoint com alguns segundos/minutos de atraso.
Você notará que o timestamp e o isoTime nunca mudam após a primeira chamada.
import connectMongo from "@/libs/mongoose";
import Routine from "@/models/Routine";
import User from "@/models/User";
import PushSubscription from "@/models/PushSubscription";
const webPush = require("web-push");
const vapidPublicKey = process.env.NEXT_PUBLIC_WEB_PUSH_PUBLIC_KEY;
const vapidPrivateKey = process.env.WEB_PUSH_PRIVATE_KEY;
if (!vapidPublicKey || !vapidPrivateKey) {
throw new Error("VAPID keys not configured");
}
console.log("VAPID keys configured correctly");
webPush.setVapidDetails("mailto:[email protected]", vapidPublicKey, vapidPrivateKey);
const getBrazilDateTime = () => {
const now = new Date();
const brazilTime = new Date(now.getTime() - 3 * 60 * 60 * 1000); // adjust for Brazil timezone
return brazilTime;
};
const getDayNameInPortuguese = (date) => {
const days = ["Domingo", "Segunda", "Terça", "Quarta", "Quinta", "Sexta", "Sábado"];
return days[date.getDay()];
};
const getTaskStartTime = (task, currentDay) => {
if (task.dailySchedule instanceof Map && task.dailySchedule.has(currentDay)) {
const daySchedule = task.dailySchedule.get(currentDay);
if (daySchedule && daySchedule.startTime) return daySchedule.startTime;
} else if (task.dailySchedule && typeof task.dailySchedule === "object" && task.dailySchedule[currentDay]?.startTime) {
return task.dailySchedule[currentDay].startTime;
}
if (task.startTime) return task.startTime;
return null;
};
const deduplicateSubscriptions = (subscriptions) => {
const uniqueEndpoints = new Set();
return subscriptions.filter((sub) => {
if (uniqueEndpoints.has(sub.endpoint)) return false;
uniqueEndpoints.add(sub.endpoint);
return true;
});
};
const notifiedTasksCache = new Map();
const isTaskAlreadyNotified = (taskId, userId) => {
const key = `${taskId}-${userId}`;
const lastNotified = notifiedTasksCache.get(key);
if (!lastNotified) return false;
const tenMinutesAgo = Date.now() - 10 * 60 * 1000;
return lastNotified > tenMinutesAgo;
};
const markTaskAsNotified = (taskId, userId) => {
const key = `${taskId}-${userId}`;
notifiedTasksCache.set(key, Date.now());
};
export async function GET(request) {
const headers = new Headers({
"Cache-Control": "no-store, max-age=0, must-revalidate",
"Content-Type": "application/json",
});
const logs = [];
const addLog = (message) => {
console.log(message);
logs.push(`[${new Date().toISOString()}] ${message}`);
};
addLog("🔔 Starting notification check...");
addLog(`🕒 Start timestamp: ${Date.now()}`);
try {
addLog("Connecting to MongoDB...");
await connectMongo();
addLog("MongoDB connection established");
// Update the time on each cycle to ensure the time is current.
const spDate = getBrazilDateTime();
const currentDay = getDayNameInPortuguese(spDate);
const currentTime = spDate.toLocaleTimeString("pt-BR", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
addLog(`📅 Brazil date and time: ${spDate.toLocaleString("pt-BR")}`);
addLog(`📅 Day of the week: ${currentDay}`);
addLog(`⏰ Current time: ${currentTime}`);
const users = await User.find({ activeRoutine: { $exists: true, $ne: null } });
addLog(`👥 Found ${users.length} users with active routines`);
if (!users.length) return NextResponse.json({ message: "No users with active routines found." });
const routineIds = users.map((user) => user.activeRoutine).filter(Boolean);
const routines = await Routine.find({ _id: { $in: routineIds } });
const routineMap = new Map();
routines.forEach((routine) => routineMap.set(routine._id.toString(), routine));
let notificationsSent = 0;
let usersNotified = 0;
let duplicatesSkipped = 0;
await Promise.all(
users.map(async (user) => {
const routineId = user.activeRoutine?.toString();
if (!routineId) return;
const routine = routineMap.get(routineId);
if (!routine) return;
const matchingTasks = routine.tasks.filter((task) => {
const taskDays = task.days || [];
const includesDay = taskDays.includes(currentDay);
const taskStartTime = getTaskStartTime(task, currentDay);
return includesDay && taskStartTime === currentTime;
});
if (!matchingTasks.length) return;
for (const matchingTask of matchingTasks) {
if (isTaskAlreadyNotified(matchingTask._id.toString(), user._id.toString())) {
duplicatesSkipped++;
continue;
}
let subscriptions = await PushSubscription.find({ userId: user._id });
if (!subscriptions.length) continue;
subscriptions = deduplicateSubscriptions(subscriptions);
addLog(` 📱 User ${user.email} has ${subscriptions.length} unique devices`);
const payload = JSON.stringify({
title: `🔔 ${matchingTask.name} - Time to start!`,
body: `⏰ ${currentTime} - ${matchingTask.details || "Stay focused on your routine!"}`,
icon: "/icon512_rounded.png",
badge: "/icon192_rounded.png",
tag: `task-${matchingTask._id}`,
data: {
url: `/dashboard/r/${routine._id}`,
taskId: matchingTask._id.toString(),
type: "task-reminder",
timestamp: new Date().toISOString(),
},
actions: [
{ action: "open", title: "📋 View Details" },
{ action: "dismiss", title: "✔️ Got it" },
],
vibrate: [200, 100, 200],
requireInteraction: true,
});
await Promise.all(
subscriptions.map(async (subscription) => {
try {
await webPush.sendNotification(
{
endpoint: subscription.endpoint,
keys: subscription.keys,
},
payload
);
notificationsSent++;
markTaskAsNotified(matchingTask._id.toString(), user._id.toString());
} catch (error) {
if (error.statusCode === 410) {
await PushSubscription.deleteOne({ _id: subscription._id });
}
}
})
);
usersNotified++;
}
})
);
return NextResponse.json(
{
message: `Notifications sent successfully!`,
notificationsSent,
usersNotified,
duplicatesSkipped,
logs,
},
{ headers }
);
} catch (error) {
console.error("Error sending notifications:", error);
return NextResponse.json({ error: "Error processing notifications.", logs }, { status: 500, headers });
}
}
🔍 O que tentei: removi toda a lógica externa — o manipulador mínimo acima ainda reproduz o problema.
Cabeçalhos de controle de cache Vercel: não parecem relacionados.
Foi verificado se uma variável global ou estática está retendo o estado — não é o caso.
❓ Minha pergunta: Este é um problema conhecido com o cache de funções ou comportamento de inicialização a frio do Vercel no Next.js App Router?
Como posso garantir que new Date() seja avaliado em todas as solicitações, não apenas na primeira?
O principal problema é a reutilização de instâncias de função no ambiente sem servidor do Vercel. Veja como corrigir:
Corrija o tratamento de data: O principal problema é que, mesmo que você esteja chamando new Date() dentro do seu manipulador, outras partes do seu código podem ser armazenadas em cache.
Adicione um sinalizador de recarga forçada para garantir que as funções não sejam reutilizadas:
ou com o tratamento adequado do fuso horário: em vez de ajustar manualmente o horário subtraindo 3 horas, use a API internacional para o tratamento adequado do fuso horário:
A plataforma sem servidor da Vercel é construída no AWS Lambda. A documentação da AWS afirma que
E a documentação da Vercel sobre a função serverless afirma que:
Acho que isso explica tudo. Embora sua
getBrazilDateTime()
função crie corretamente um novo objeto Date cada vez que é chamada, a instância da função sem servidor permanece "quente" entre as solicitações.Basicamente, o escopo externo da sua função é avaliado apenas uma vez durante a inicialização da instância. Apenas a própria função do manipulador (
GET
no seu caso) é reexecutada a cada solicitação.Acredito que chamar
Date.now()
e construir explicitamente uma nova Data a partir desse carimbo de data/hora dentro da função manipuladora resolverá esse problema. Isso forçará o JavaScript a obter o carimbo de data/hora atual em vez de reutilizar um valor calculado anteriormente.Você também pode adicionar uma verificação de tempo mais robusta como esta.