DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Push Notifications with Claude Code: Firebase FCM, Token Management, and Bulk Delivery

"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
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

!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(),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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

  • registerDeviceToken upserts 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)