"Just send a push notification" sounds trivial. But real push notification systems need to handle multi-device token management, invalid token cleanup (stale tokens silently degrade your FCM delivery rate), silent vs visible notifications, and bulk delivery at scale.
Claude Code generates the complete push notification infrastructure from CLAUDE.md rules.
The CLAUDE.md Rules
## Push Notification Rules
- One user can have multiple FCM tokens (multi-device: phone + tablet + web)
- Remove invalid tokens immediately when FCM returns an error for them
- Track lastUsedAt per token; delete tokens not used in 90+ days
- Use sendEachForMulticast for per-token delivery status (not send — it stops on first error)
- BullMQ queue for sends to 1000+ users (never block the HTTP response)
- FCM batch API accepts max 500 tokens per request — chunk accordingly
- iOS title limit: 178 characters
- Use data payload (not notification payload) for in-app navigation control
With these rules in CLAUDE.md, Claude Code generates the full infrastructure — not just the happy path.
Firebase Admin Initialization
import * as admin from 'firebase-admin';
if (!admin.apps.length) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
// Replace escaped newlines in env var
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\n/g, '\n'),
}),
});
}
export const messaging = admin.messaging();
!admin.apps.length prevents re-initialization in Next.js hot reload and serverless environments that reuse function instances.
registerDeviceToken()
import { prisma } from '../lib/prisma';
export async function registerDeviceToken(params: {
userId: string;
token: string;
platform: 'ios' | 'android' | 'web';
}): Promise<void> {
await prisma.deviceToken.upsert({
where: { token: params.token },
update: {
userId: params.userId,
platform: params.platform,
lastUsedAt: new Date(),
},
create: {
userId: params.userId,
token: params.token,
platform: params.platform,
lastUsedAt: new Date(),
},
});
}
token is the unique key — not userId. The same token can shift between users (device transfer, logout/login). Upserting on token handles this correctly without duplicates.
sendToUser(): Per-Token Delivery with Invalid Token Cleanup
import { messaging } from '../lib/firebase';
export async function sendToUser(params: {
userId: string;
title: string;
body: string;
data?: Record<string, string>;
}): Promise<{ sent: number; failed: number }> {
const tokens = await prisma.deviceToken.findMany({
where: { userId: params.userId },
select: { token: true },
});
if (tokens.length === 0) return { sent: 0, failed: 0 };
const tokenStrings = tokens.map((t) => t.token);
const message: admin.messaging.MulticastMessage = {
tokens: tokenStrings,
notification: {
title: params.title.slice(0, 178), // iOS limit
body: params.body,
},
data: params.data ?? {},
apns: {
payload: { aps: { sound: 'default' } },
},
android: {
priority: 'high',
},
};
const response = await messaging.sendEachForMulticast(message);
// Collect invalid tokens to remove
const invalidTokens: string[] = [];
response.responses.forEach((resp, idx) => {
if (!resp.success) {
const code = resp.error?.code;
if (
code === 'messaging/invalid-registration-token' ||
code === 'messaging/registration-token-not-registered'
) {
invalidTokens.push(tokenStrings[idx]);
}
}
});
if (invalidTokens.length > 0) {
await prisma.deviceToken.deleteMany({
where: { token: { in: invalidTokens } },
});
}
return {
sent: response.successCount,
failed: response.failureCount,
};
}
sendEachForMulticast returns a per-token result array — send stops on first failure. Invalid tokens (registration-token-not-registered) are removed immediately. Keeping stale tokens silently lowers your FCM delivery rate over time.
queueBulkNotification() with BullMQ
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL!);
const notificationQueue = new Queue('notifications', { connection });
export async function queueBulkNotification(params: {
userIds: string[];
title: string;
body: string;
data?: Record<string, string>;
scheduledAt?: Date;
}): Promise<void> {
const jobs = params.userIds.map((userId) => ({
name: 'send-to-user',
data: {
userId,
title: params.title,
body: params.body,
data: params.data,
},
opts: params.scheduledAt
? { delay: params.scheduledAt.getTime() - Date.now() }
: undefined,
}));
// addBulk is atomic — all jobs or none
await notificationQueue.addBulk(jobs);
}
Never call FCM directly in an HTTP handler when sending to thousands of users. addBulk enqueues all jobs atomically. Workers process them at their own rate, with retry on failure, without blocking the API response.
cleanupStaleTokens()
export async function cleanupStaleTokens(): Promise<number> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 90);
const result = await prisma.deviceToken.deleteMany({
where: { lastUsedAt: { lt: cutoff } },
});
return result.count;
}
Run this as a daily cron job. Tokens from uninstalled apps or inactive devices stop receiving FCM anyway — removing them keeps your token registry accurate and your delivery rate meaningful.
Prisma Schema: DeviceToken
model DeviceToken {
id String @id @default(cuid())
userId String
token String @unique
platform String // ios | android | web
lastUsedAt DateTime @default(now())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([lastUsedAt])
}
token @unique prevents duplicates. @@index([lastUsedAt]) makes the 90-day cleanup query fast at scale. onDelete: Cascade removes all tokens when a user is deleted — GDPR compliance by default.
What CLAUDE.md Gives You
-
registerDeviceTokenupserts on token → correct multi-device + device-transfer handling -
sendEachForMulticast→ per-token delivery status, not all-or-nothing - Immediate invalid token deletion → FCM delivery rate stays accurate
- BullMQ queue → bulk sends never block HTTP responses
- 90-day stale token cleanup → token registry stays lean
-
onDelete: Cascade→ GDPR-compliant user deletion
Without these rules, push notification systems default to "send and forget" — stale tokens accumulate, FCM delivery rate drops silently, and bulk sends time out the API.
Want the complete backend CLAUDE.md ruleset — including push notifications, job queues, input validation, and production-ready patterns? It's packaged as a Code Review Pack on PromptWorks (¥980, /code-review).
What's the hardest part of push notifications at scale — token management, delivery rate, or something else?
Top comments (0)