待たせないメール送信 — SvelteKit × BullMQ × Redis × Resend
Photo by Solen Feyissa on Unsplash
TL;DR
- SvelteKit のフォーム送信は “キュー(Queue)に仕事を投げる” だけにして即レスポンス。
- BullMQ + Redis がバックグラウンドでリトライ付きメール送信を保証。
- UX 向上・障害耐性・水平スケールの三拍子が 200 行以下のコードで実現。
3 秒の待ち時間が招く致命傷
社内計測では、フォーム送信から完了表示までの平均待ち時間が 3.2 秒 を超えると、完了率(CVR)が ▲12 % 低下しました。たった数秒でも、ユーザーは「壊れた?」と感じて離脱してしまいます。
- 同期送信(アンチパターン):ブラウザがメール API の応答を待ち続ける
- 非同期送信(今回の手法):ブラウザは即座に「完了」を受け取り、裏でメールが送られる
結論 ― 時間のかかる処理は “あとでやる箱” に任せよう。
アンチパターン → ベストプラクティス
まず、アンチパターンを見てみましょう。SvelteKitのフォームアクション内で、直接メールを送信するコードです。
// アンチパターン:低速な処理
export const actions = {
default: async ({ request }) => {
// ... バリデーション処理 ...
console.log("メールを送信します...");
// この行が全てをブロックします(数秒かかる可能性があります!)
await resend.emails.send({ ... });
console.log("メールが送信されました!");
return { success: true };
}
};
この実装では、resend.emails.send()の処理が完了するまで、ユーザーの画面はロード中のままになります。これはUXを著しく低下させます。
同期版は `await` の間 UI が固まります。非同期版はキューに投げるだけなので 数ミリ秒 で返ります。
非同期版の処理流れを簡易的な図で示します。
なぜ SvelteKit 単体でバックグラウンドジョブを実行しないのか?
SvelteKit を Vercel / Netlify / Cloudflare Pages などのサーバーレス環境に置くと、次の制約があります。
- 短命プロセス — リクエストが終わると Function が終了し、常駐してキューを監視できない。
- 外向けトリガー依存 — サーバーレス関数は "誰かからの HTTP 呼び出し" が来ないと動かない。
- タイムアウト制限 — 30 秒〜数分で強制終了されるため、重いバッチ処理やリトライループが不可能。
- スケール衝突 — フロントのトラフィック急増とワーカー負荷が同じプロセスで競合すると、片方のリソース不足で障害が波及。
結論 — UI 専用の SvelteKit と、ジョブ実行専用の Worker を責務分離すると、コスト・信頼性・スケーラビリティのすべてがシンプルになります。
- Producer : ジョブカードをキューへ
- Broker : 順番を保持
- Consumer : カードを取り出して Resend でメール送信
技術選定の理由
BullMQ
- リトライ(指数バックオフ):
attempts
,backoff
を一行設定 - 充実した TypeScript 型定義 & UI ダッシュボード
Redis
- インメモリで爆速、かつ永続化 AOF/RDB でジョブを失わない
- 1 つのインスタンスで 10 万 QPS 以上をラクに処理
Resend
- モダンでシンプル:
resend.emails.send()
1 行で HTML・添付ファイル・署名を送信。 - 高い到達率: DKIM/SPF/DMARC を自動セットアップし、スパム判定を回避。
- 安全な再試行:
Idempotency-Key
で重複送信なくリトライ可能。 - ダッシュボード: 送信・バウンス・スパム報告をリアルタイムで可視化。
5 分で動くハンズオン
1. フロント(SvelteKit / Vercel)
npm install bullmq
// .env
REDIS_HOST=<lightsail-ip> // ローカルだとlocalhost
REDIS_PORT=6379
REDIS_PASSWORD=your-passowrd
// src/lib/server/queue.ts
import { Queue } from 'bullmq';
import { env } from '$env/dynamic/private';
// バックエンドサーバーのRedisインスタンスに接続
export const emailQueue = new Queue('email-queue', {
connection: {
host: env.REDIS_HOST,
port: env.REDIS_PORT,
password: env.REDIS_PASSWORD,
},
});
// src/routes/signup/+page.server.ts
import { emailQueue } from '$lib/server/queue';
export const actions = {
default: async ({ request }) => {
// ... ユーザー入力のバリデーション ...
const { email, name } = ...;
// 直接送信する代わりに、ジョブをキューに追加
await emailQueue.add('send-welcome-email', {
to: email,
subject: `ようこそ、${name}さん!`,
html: `<h1>${name}さん、ご登録ありがとうございます!</h1>`,
});
// ユーザーには即座に成功を返す!
return { success: true };
}
};
2. バックエンド(Docker Compose / Lightsail)
// docker-compose.yml
services:
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
ports: ["6379:6379"]
volumes: [redis_data:/data]
worker:
build: ./worker
environment:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
RESEND_API_KEY: ${RESEND_API_KEY}
EMAIL_FROM: "Your App <noreply@yourdomain.com>"
depends_on:
redis:
condition: service_healthy
volumes:
redis_data:
// worker/index.js
import { Worker } from 'bullmq';
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
new Worker('email-queue', async job => {
const { email, name } = job.data;
await resend.emails.send({
from: process.env.EMAIL_FROM,
to: email,
subject: `ようこそ、${name}さん!`,
html: `<h1>${name}さん、ご登録ありがとうございます!</h1>`
});
}, {
connection: {
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD,
},
// 3 回まで指数バックオフで再試行
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
});
監視と運用 Tips
- BullMQ UI: ジョブ状態(待機 / 進行中 / 失敗)をブラウザで確認
- RedisInsight: メモリ使用量・キー数を可視化
- アラート: 失敗ジョブが X 件以上で Slack Webhook 通知