個人開発で「定期的に対象を洗い出して通知を送る」仕組みを作ろうとすると、意外と考えることが多いです。定期実行そのものより、二重送信の防止・失敗時の扱い・ログの持ち方・タイムゾーンといった地味な論点でつまずきます。
この記事は、Notionの期日やステータスを監視してメールでリマインドを送る個人開発SaaS(Kapsel)を運用するなかで整理した、サーバレスな通知基盤の勘所とハマりどころのメモです。技術スタックは Next.js (App Router) / Vercel / Supabase (Postgres) を前提にしています。「これが正解」というより「うちではこう考えて、こう落ち着いた」という温度感で読んでください。
全体の流れ:定期実行 → 対象抽出 → 通知 → ログ記録
通知バッチは、分解するとだいたい4段になります。
- 定期実行:一定間隔で処理を起動する(Vercel Cron)
- 対象抽出:「今、送るべき相手」だけをデータから絞り込む
- 通知送信:メールなどの外部サービスに投げる
- ログ記録:誰にいつ何を送ったかを残す(Supabase)
このうち、事故が起きやすいのは 2〜4 です。1 の「定期実行」は仕組みとしては簡単ですが、頻度の選び方にプラン都合の落とし穴があります。順番に見ていきます。
Vercel Cron で定期実行する
Vercel Cron は vercel.json にスケジュールとパスを書くだけで、指定したエンドポイントを定期的に叩いてくれます。別立てのワーカーやサーバを持たずに済むのが、個人開発では大きい利点です。
// vercel.json
{
"crons": [
{ "path": "/api/cron/reminders", "schedule": "0 0 * * *" }
]
}
叩かれる側は、App Router なら普通のルートハンドラです。ここで大事なのは、外部から誰でも叩けるエンドポイントになっている点です。Vercel Cron からのリクエストか確認する仕組みを最初に入れておきます。
// app/api/cron/reminders/route.ts
import { createClient } from "@supabase/supabase-js";
export async function GET(req: Request) {
// Cron からの呼び出しかを検証(環境変数に秘密の値を置く)
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY! // サーバ側専用。クライアントに出さない
);
// 1) 対象抽出 → 2) 送信 → 3) ログ記録 …(後述)
return Response.json({ ok: true });
}
Vercel Cron は環境変数 CRON_SECRET を設定しておくと、Authorization: Bearer <値> を付けて呼び出してくれます。この検証を省くと、外から叩かれて通知が意図せず飛ぶ余地が残ります。地味ですが最初に入れておく価値があります。
実行頻度の「現実」:分単位が要るなら別の仕組みを併用する
ここが最初の落とし穴でした。Vercel Cron はプランによって使える頻度・本数が違います。無料枠では実行頻度や本数に制限があり、細かい間隔で回したい用途だとそのままでは足りないことがあります(具体的な上限はプランや時期で変わるので、必ず公式ドキュメントで最新を確認してください)。
期日ベースのリマインドで「1時間ズレる」は用途によっては致命的です。そこでうちでは、役割を分けました。
- 日次〜数回で十分な定期便:Vercel Cron に任せる
-
分単位に近い精度が要る監視:データベース側のスケジューラ(Supabase の
pg_cronなど)に寄せる
「全部を1つの定期実行で賄おうとしない」というのが、無理のない構成でした。頻度要件は機能ごとに違うので、要件に合ったスケジューラを混在させる前提で設計すると楽になります。
冪等性:二重送信を防ぐ
サーバレスの定期実行は、タイムアウト・リトライ・多重起動などで同じ処理が2回走る可能性を前提に組むのが安全です。「送信を実行する」だけの素朴な実装だと、たまたま2回走ったときに同じ相手へ2通飛びます。
うちが取ったのは、送信の記録を先に一意キー付きで作り、重複はDBの制約で弾くやり方です。「対象ID × 送る予定日」など、同じ内容の送信を一意に識別できるキーを決めて、ログテーブルにユニーク制約を張ります。
// 送信予定を「先に記録」してから送る。重複はDB制約で弾かれる
const sendKey = `${itemId}:${scheduledDate}`; // 例: 対象ID × 予定日
const { error } = await supabase
.from("send_logs")
.insert({ send_key: sendKey, item_id: itemId, status: "pending" });
// send_key に UNIQUE 制約を張っておく。
// 2回目の挿入は一意制約違反になるので、その場合は「もう送った/送信中」として送信をスキップ
if (error) {
// duplicate key など → この対象は今回スキップ
return;
}
// ここまで来たものだけ実際に送信する
await sendEmail(itemId);
await supabase.from("send_logs").update({ status: "sent" }).eq("send_key", sendKey);
ポイントは、「送ったか」をアプリのメモリや実行の中だけで判断しないことです。実行が別々に走っても、共有の記録(DB)と一意制約で最終的に1通に収束させます。順番も「記録してから送る」にしておくと、送信後にログ書き込みだけ失敗して重複判定が効かなくなる、という事故を減らせます。
失敗時の再送をどう扱うか
外部サービスへの送信は普通に失敗します。ここで「全部やり直す」設計にすると、成功した分まで再送してしまいます。前述のログテーブルに状態(pending / sent / failed)を持たせておくと、再送は自然に扱えます。
- 送信前に
pendingで記録 → 成功でsent→ 例外でfailed - 次回の実行で「
sentになっていないもの」を対象に含める - ただし
failedを無限に叩き続けないよう、試行回数の上限は決めておく
冪等キーがあるので、sent のものは次回また対象に混ざっても制約で弾かれます。「再送のために特別なリトライ機構を組む」というより、状態と冪等キーがあれば通常の定期実行がそのまま再送になる、という考え方に落ち着きました。
送信ログの持ち方
ログは「送信の状態管理」と「後から見返す監査」の両方を兼ねます。うちで持っている項目はだいたいこのあたりです。
- 冪等キー(一意制約)
- 対象の識別子(NotionのページIDなど)
- 送信予定日 / 実際の送信時刻
- 状態(pending / sent / failed)と、失敗時の理由・試行回数
「何を送ったか」を残しておくと、ユーザーから「これ届いてます?」と聞かれたときに事実で答えられます。個人開発だと問い合わせ対応も自分がやるので、この送信記録は運用の安心材料としても効いています。
タイムゾーンの落とし穴
サーバレスの実行環境は基本的に UTC で動くと考えておくのが無難です。「毎朝9時に送る」を UTC の cron にそのまま書くと、日本の利用者にとっては別の時刻になります。
うちで気をつけているのは次の点です。
-
cronのscheduleは実行環境の基準(UTC想定)で解釈される前提で組む - 「期日を過ぎたか」の判定は、利用者の期待するタイムゾーンで日付を丸めてから比較する(例:JSTの日付境界で判定)
- DBには時刻を UTC で保存し、表示・判定のときに変換する
「日付だけ」を扱う項目(期日など)は、時刻・タイムゾーンの解釈で1日ズレやすいので、どのTZで日付境界を切るかを最初に決めておくと後がラクです。
実行時間の制限とバッチの分割
サーバレス関数には実行時間の上限があり、これもプランや設定で変わります(上限値は公式で確認してください)。対象が増えてくると、1回の実行で全部送ろうとしてタイムアウトする、という事態が現実味を帯びます。
対策はシンプルで、1回の実行で処理する件数に上限を設けることです。冪等キーと状態管理ができていれば、「今回さばけなかった分は次回の実行で拾う」が安全にできます。1実行を短く保ち、頻度と分割で全体をさばく、という発想に切り替えると、時間制限とはだいぶ付き合いやすくなります。
コストと運用の現実
個人開発の目線でのざっくりした実感です(金額はプラン改定で変わるので、判断の前に必ず最新の料金を確認してください)。
- 定期実行の常設サーバが要らない:Vercel Cron + Supabase の構成は、小さく始めるぶんには固定費を抑えやすいです。ワーカー用のインスタンスを別に持たずに済むのが効いています。
- 無料枠は「試す」には十分、でも頻度と実行回数は要件次第:前述のとおり、細かい間隔で回したい機能があると無料枠だけでは収まらないことがあります。何を有料の対象にするかは、頻度要件から逆算するのが現実的でした。
- 冷えたときの初動の遅さ:めったに叩かれないエンドポイントは、久しぶりの実行で立ち上がりが遅くなることがあります。定期実行のような「時々しか動かない」処理は、多少の遅延を許容できる設計にしておくと安心です。
- 料金は使用量で動く:送信数・実行回数・DBの規模で費用は変わります。無料〜低コスト枠で始めて、伸びてから見直す前提が個人開発には合っていました。
まとめ
サーバレスで通知基盤を作るとき、難所は「定期実行」ではなく、その周辺でした。整理すると次の5点です。
- Cron のエンドポイントは秘密トークンで守る
- 実行頻度はプラン制約が絡む。分単位が要るならDB側スケジューラと役割分担する
- 二重送信は「先に記録 + 一意制約」で構造的に防ぐ
- 状態 + 冪等キーがあれば、通常の定期実行がそのまま再送になる
- タイムゾーンと実行時間の上限は、最初に方針を決めておく
こうした構成で、Notionの期日を監視してメールでリマインドを送る個人開発SaaS(Kapsel / getkapsel.com)を動かしています。同じように「個人開発で通知や定期実行の基盤を作りたい」方の、設計の当たりをつける材料になれば幸いです。