はじめに
前回の記事で、アプリを閉じていてもPush通知が届く仕組みを作りました。
次にやりたくなったのが、「毎日18時・20時・22時」のように、あらかじめ設定した時刻に通知を送ることです。
この記事では、Web Pushそのものの実装ではなく、「1日に複数回、決まった時刻に通知を送るにはどう設計するか」に絞って書いています。
Web Push通知の基本的な実装(VAPIDキー・Service Worker・購読情報の保存・通知の送信)は、前回の記事で解説しています。この記事はその続きとして、前回の構成が動いている前提で進めます。
今回作ったアプリのソースコード
この記事の対象読者
- Next.js + Vercel でPWAを作っている
- 「毎日18時、20時、22時に通知」のような定時通知を複数回実装したい
- サーバーレス環境でのスケジューリングに悩んでいる
課題:サーバーレスでは「時計を見張る」ことができない
「18時・20時・22時に通知を送りたい」と考えたとき、最初に思いつくのは、動き続けるプロセスで時刻を見張る方法です。
// こういうことがしたい(でもVercelではできない)
setInterval(() => {
if (今が18時 || 今が20時 || 今が22時) {
通知を送る();
}
}, 60000);
しかしVercelのようなサーバーレス環境では、長時間動き続けるプロセスを持てません。関数はリクエストが来たときだけ起動し、処理が終わるとすぐに停止するためです。
ではVercel Cronで全部やればいいのでは?
Vercel Cronを使えば定時実行はできますが、今回の用途には次の2つの問題がありました。
-
実行時刻が正確ではない
Hobbyプラン(無料)では、1つのCronは1日1回までしか実行できず、実行時刻も指定した時刻から最大59分ずれます。18時・20時・22時の3つのCronを登録することはできますが、どれも「18:00ちょうど」のように時刻どおりには届きません。 -
通知時刻をアプリから変更できない
Cronの時刻はvercel.jsonという設定ファイルに書くため、アプリから書き換えることができません。ユーザーが通知時刻を自由に変えられるようにしたくても対応できません。
Vercel Cronの制限はプランによって異なり、変更されることもあります。最新の情報は公式ドキュメントを確認してください。
解決策:0時に「今日の予約」をまとめて入れる
そこで発想を転換しました。
深夜0時に1回だけ起動して、その日に送る予定の通知をすべて事前に予約してしまう
予約した時刻に実際にAPIを呼び出す役割は、QStash という外部のスケジューラーに任せます。QStashは「指定した時刻に、このURLにリクエストを送って」という予約を受け付けてくれるサービスです。
この設計には次のメリットがあります。
- Vercel Cronの役割は「1日1回、予約係を起動するだけ」で済む(実行時刻が多少ずれても問題ない)
- 通知を送るタイミングの正確さはQStashが担保してくれる
- ユーザーが通知時刻を変更しても、翌日から自動で反映される
全体の流れ
使用技術
| 使用技術 | 役割 |
|---|---|
| Vercel Cron | 毎日深夜0時に、予約係のAPIを起動する |
| Upstash Redis | 通知設定と購読情報を保存する |
| Upstash QStash | 指定した時刻にAPIを呼び出すスケジューラー |
| web-push | 実際にPush通知を送るライブラリ |
事前準備
前回の記事で web-push・@upstash/redis のインストールと、VAPIDキー・Upstash Redisの環境変数の設定は済んでいる前提です。この記事では、追加で必要なものだけを準備します。
1. ライブラリのインストール
npm install @upstash/qstash
- @upstash/qstash:QStashに予約を登録するためのJavaScriptクライアント
2. QStashのトークンを取得する
- Upstash Console を開く(前回作ったアカウントでOK)
- 上部メニューから QStash を開く
- 表示されている
QSTASH_URLとQSTASH_TOKENをコピーする
3. 環境変数の追加
.env.local に以下を追加します。
# デプロイ先のURL(末尾のスラッシュは付けない)
BASE_URL=https://your-app.vercel.app
# Upstash QStash(Upstash Consoleからコピー)
QSTASH_URL=
QSTASH_TOKEN=
QSTASH_URL と QSTASH_TOKEN は両方必要です。QSTASH_URL を設定しないと、not found in this region というエラーになることがあります。詳しくは以下の記事で解説しています。
Vercelにデプロイする場合は、Vercelダッシュボードの Settings → Environment Variables にも同じ値を設定してください。
実装
この記事のコードは 1ユーザー・1端末 を前提にしています。Redisのキーを push-subscription や notification-settings という固定の値にしているためです。複数ユーザーに対応する場合は、ユーザーIDをキーに含める形に変更してください。
Step 1:通知設定の型を定義する
まず「いつ通知するか」の設定を型で定義します。平日と休日で時刻を分けられるようにしました。
// 1つの区分(平日 or 休日)の通知設定
export interface NotificationTimeSettings {
enabled: boolean; // 通知のON/OFF
times: string[]; // "HH:MM" 形式の文字列(例: ["18:00", "20:00"])
}
// 通知設定全体
export interface NotificationSettings {
weekday: NotificationTimeSettings; // 月〜金
holiday: NotificationTimeSettings; // 土・日
}
Step 2:通知設定をRedisに保存する
Cronが「今日は何時に通知するか」を読めるように、通知設定をRedisに保存しておきます。設定画面から、このAPIに設定を送ります。
import { Redis } from "@upstash/redis";
import { NextResponse } from "next/server";
import { NotificationSettings } from "@/lib/types";
// Redisクライアントを作って返す関数
function getRedis() {
return new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
}
// Redisに保存するときのキー
const REDIS_KEY = "notification-settings";
// GET /api/notification-settings
// 保存されている通知設定を返す
export async function GET() {
const redis = getRedis();
const settings = await redis.get<NotificationSettings>(REDIS_KEY);
return NextResponse.json(settings ?? null);
}
// POST /api/notification-settings
// 設定画面から送られてきた通知設定を保存する
export async function POST(req: Request) {
const redis = getRedis();
const settings: NotificationSettings = await req.json();
await redis.set(REDIS_KEY, JSON.stringify(settings));
return NextResponse.json({ ok: true });
}
Step 3:Vercel Cronで深夜0時に予約係を起動する
vercel.json にCronの設定を書くだけで、Vercelが指定した時刻にAPIを呼び出してくれます。
{
"crons": [
{
"path": "/api/cron/schedule",
"schedule": "0 15 * * *"
}
]
}
Vercel Cronのスケジュールは常にUTCです。
JSTの0時に実行したいので 15時UTC = 0時JST を指定しています。
Hobbyプランでは、実際には0時〜0時59分のどこかで実行されます。そのため、0時台に通知時刻を設定すると、予約が間に合わないことがあります。
Vercel CronはGETリクエストを送ります。APIを POST で書いていると、Cronから呼ばれても何も起きないので注意してください。
Step 4:QStashに今日の通知を予約する
Cronから呼ばれる /api/cron/schedule で、次の処理を行います。
- Redisから通知設定を読む
- 今日が平日か休日かを判定して、今日送る時刻の一覧を決める
- 時刻ごとに、QStashへ予約を1件ずつ登録する
import { Client } from "@upstash/qstash";
import { Redis } from "@upstash/redis";
import { NextResponse } from "next/server";
import { NotificationSettings } from "@/lib/types";
// QStashとRedisのクライアントを作って返す関数
// 関数の中で作ることで、ビルド時ではなくリクエスト時に初期化される
function getClients() {
const qstash = new Client({
token: process.env.QSTASH_TOKEN!,
baseUrl: process.env.QSTASH_URL, // 省略するとリージョンのエラーになることがある
});
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
return { qstash, redis };
}
// 土曜(6)・日曜(0)なら true を返す
function isHoliday(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6;
}
// 通知設定から、今日送る時刻の一覧を返す(例: ["18:00", "22:00"])
function getActiveTimes(settings: NotificationSettings, today: Date): string[] {
// 休日なら休日の設定、平日なら平日の設定を使う
const target = isHoliday(today) ? settings.holiday : settings.weekday;
// 通知がOFFなら空の配列を返す
return target.enabled ? target.times : [];
}
// GET /api/cron/schedule
// Vercel CronはGETで呼ぶので、GETで受け取る
export async function GET() {
const { qstash, redis } = getClients();
// 購読情報がなければ通知を送れないので、ここで終了する
const subscription = await redis.get("push-subscription");
if (!subscription) {
return NextResponse.json({ ok: false, reason: "no subscription" });
}
// Redisから通知設定を読む
const settings = await redis.get<NotificationSettings>("notification-settings");
if (!settings) {
return NextResponse.json({ ok: false, reason: "no notification settings" });
}
// 今日の日付(JST)を "YYYY-MM-DD" の形式で取得する
const todayJST = new Date().toLocaleDateString("en-CA", {
timeZone: "Asia/Tokyo",
});
// 曜日を判定するためのDate
// UTCの正午で作ると、サーバーのタイムゾーンに関係なく日付がずれない
const today = new Date(`${todayJST}T12:00:00Z`);
// 今日送る時刻の一覧(例: ["18:00", "20:00"])
const times = getActiveTimes(settings, today);
if (times.length === 0) {
return NextResponse.json({ ok: true, scheduled: [] });
}
// QStashに呼び出してもらうURL
// BASE_URL の末尾に "/" があると "//api" になり、通知が二重に届く原因になるので取り除く
const notificationUrl = `${process.env.BASE_URL?.replace(/\/$/, "")}/api/send-notification`;
for (const time of times) {
const [hour, minute] = time.split(":");
// "+09:00" を付けて「JSTの何時何分か」を明示する
const notifyAt = new Date(
`${todayJST}T${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00+09:00`,
);
// QStashには「1970年1月1日からの秒数(Unix時間)」で時刻を渡す
const notBefore = Math.floor(notifyAt.getTime() / 1000);
// QStashに予約を登録する(notBefore の時刻になったら notificationUrl をPOSTで呼ぶ)
await qstash.publishJSON({
url: notificationUrl,
body: {},
notBefore,
});
}
return NextResponse.json({ ok: true, scheduled: times });
}
ポイントは publishJSON の notBefore です。ここにUnix時間(秒)を渡すだけで、「この時刻になったら1回だけURLを呼ぶ」という予約を登録できます。APIが成功(200番台)以外のステータスを返したときは、QStashが自動でリトライしてくれます(初期設定では3回)。
日付と時刻の作り方を2通りに分けている理由
- 曜日の判定用:UTCの正午(
T12:00:00Z)で作ると、日本時間(+9時間)で見てもUTCで見ても同じ日付になるため、曜日がずれません。 - 通知の時刻用:
+09:00を付けて「日本時間の18時」と明示することで、正しくUTCに変換されます。
予約した時刻になると、QStashが /api/send-notification をPOSTで呼び出します。このAPIは前回の記事で作ったものをそのまま使えるので、追加の実装は必要ありません。
動作確認
1. 通知設定を保存する
設定画面から保存するか、curlで直接保存します。確認しやすいように、少し先の時刻を入れておきます。
curl -X POST https://your-app.vercel.app/api/notification-settings \
-H "Content-Type: application/json" \
-d '{"weekday":{"enabled":true,"times":["18:00","20:00"]},"holiday":{"enabled":true,"times":["18:00","20:00"]}}'
2. 予約係のAPIを手動で呼ぶ
0時を待たずに確認したいときは、Cronの代わりに手動で呼び出します。
curl https://your-app.vercel.app/api/cron/schedule
{"ok":true,"scheduled":["18:00","20:00"]} のように、予約した時刻が返ってくれば成功です。
3. QStashの予約を確認する
Upstash Consoleの QStash 画面で、登録した予約と、実行予定の時刻を確認できます。予約した時刻にスマホへ通知が届けば完成です。
手動で実行するときの注意
- 同じ日に2回実行すると、予約が2件ずつ登録されて通知が二重に届きます。
- すでに過ぎた時刻は、QStashがすぐに実行するため、その場で通知が届きます。
まとめ
サーバーレス環境で「1日に複数回、決まった時刻に通知する」ために、次の3つの役割に分けました。
| 担当 | 役割 |
|---|---|
| Vercel Cron | 1日1回、予約係を起動する |
| Upstash QStash | 決まった時刻にAPIを呼ぶ |
| Upstash Redis | 通知設定・購読情報を保存する |
「常に時計を見張る」のではなく、「0時にその日の予約をまとめて入れておく」と発想を変えることで、常駐プロセスを持てないサーバーレス環境でも定時通知を実現できました。
終わりに
今回、Claude Codeを使って実装を進めました。
実現したいことを質問したところ、QStashという知らなかったサービスを教えてもらい、実装に組み込むことができました。
これまでは、使えるライブラリを調べることに多くの時間を使っていましたが、その時間を大幅に減らすことができました。
一方で、コードをAIに任せることで、自分のコーディング能力が落ちてしまうのではという不安もあります。だからこそ、今回のように実装した内容を自分の言葉で記事にまとめ、理解を確かめるようにしています。
AIの発展でエンジニアの役割は変わりつつあります。実装はAIが手伝ってくれるようになっても、「何を実現したいか」を考えるのは自分自身であり、そこを考え続けることが大事だと感じました。
関連記事
参考
