DEV Community

Cover image for Firebase Push Tokens Are Device-Specific, Not User-Specific: A Critical Refactoring
Sangwoo Lee
Sangwoo Lee

Posted on

Firebase Push Tokens Are Device-Specific, Not User-Specific: A Critical Refactoring

When I first built my Firebase Cloud Messaging (FCM) notification system, I made a critical architectural mistake based on a fundamental misunderstanding of how Firebase tokens work. I treated them as user-specific when they're actually device-specific. This seemingly subtle distinction led to complex deduplication logic and unnecessary database overhead.

Here's how I fixed it and what I learned about Firebase's token architecture.

The Original Problem: Misunderstanding Token Identity

My initial implementation assumed a one-to-one relationship between users and push tokens. The logic seemed sound: each user logs in with a phone number, so I should group by phone number and pick the most recent token, right?

Before Refactoring

// notification.service.ts - Original Implementation
async getValidPushTokens(lastSeq?: number): Promise<TokenResult[]> {
  // Complex query grouping by cell_phone
  const query = `
    SELECT u.cell_phone, u.push_token, MAX(u.logindate) as latest_login
    FROM users u
    WHERE u.push_token IS NOT NULL
      AND u.push_token != ''
      ${lastSeq ? 'AND u.seq > ?' : ''}
    GROUP BY u.cell_phone, u.push_token
    HAVING u.logindate = MAX(u.logindate)
    ORDER BY u.seq ASC
    LIMIT 1000
  `;

  const results = await this.connection.query(query, lastSeq ? [lastSeq] : []);

  // Further deduplication in memory
  const deduplicated = new Map<string, TokenData>();
  for (const row of results) {
    const existing = deduplicated.get(row.cell_phone);
    if (!existing || row.latest_login > existing.latest_login) {
      deduplicated.set(row.cell_phone, {
        token: row.push_token,
        loginDate: row.latest_login
      });
    }
  }

  return Array.from(deduplicated.values()).map(d => d.token);
}
Enter fullscreen mode Exit fullscreen mode

Problems with This Approach

  1. Database Overhead: Complex GROUP BY with HAVING clause on large tables
  2. Double Deduplication: Grouping in SQL, then re-deduplicating in memory
  3. Wrong Abstraction: Assumed phone number = user = single device 4.** Missed Multi-Device Users**: Users with multiple devices (phone + tablet) only got notifications on one
  4. Shared Device Issues: Multiple users on same device (family tablets) created conflicts

The query performance was acceptable but the logic was fundamentally flawed.

Understanding Firebase Token Architecture
The breakthrough came from reading Firebase's official documentation carefully. Here's what I learned:

Critical Insight: Tokens Are Device-Bound
From Firebase's documentation:

"An FCM registration token is a unique identifier issued by the FCM SDK for each client app instance. Each app installation on a specific device receives its own unique token."

This means:

  • One token per app installation per device—nothing more, nothing less
  • Tokens identify a specific app on a specific device
  • No inherent user association—that's your responsibility
  • One user can have multiple valid tokens (phone, tablet, laptop)
  • One device can serve multiple users (family iPad)

Token Lifecycle Events
Tokens are generated and refreshed when:

  • App first starts (initial installation)
  • User reinstalls the app
  • User clears app data
  • Device is restored on new hardware
  • User logs out and back in (in my implementation)

Critical for my use case: When a user logs in, the app:

  1. Calls Firebase.messaging.getToken()
  2. Sends the (possibly new) token to my backend
  3. I store: user_id → device_token mapping with timestamp

The Multi-Device Reality
According to Firebase experts:

"Just like multiple users can use a single device, a single user can also use multiple devices. In my experience, that is in fact the more common scenario."
Stack Overflow

This was my "aha" moment. I was trying to pick "the most recent token per phone number" when I should have been storing and using ALL valid tokens per user.

The Solution: Direct Token-Based Deduplication

Once I understood tokens are device-identifiers, the solution became obvious: deduplicate by token itself, not by phone number.

After Refactoring

// notification.service.ts - Refactored Implementation
async getValidPushTokens(
  lastSeq?: number,
  limit: number = 1000
): Promise<{ tokens: string[], nextCursor: number | null }> {
  const queryBuilder = this.userRepository
    .createQueryBuilder('user')
    .select(['user.seq', 'user.push_token'])
    .where('user.push_token IS NOT NULL')
    .andWhere("user.push_token != ''")
    .orderBy('user.seq', 'ASC')
    .limit(limit + 1);

  if (lastSeq) {
    queryBuilder.andWhere('user.seq > :lastSeq', { lastSeq });
  }

  const users = await queryBuilder.getMany();
  const hasMore = users.length > limit;

  if (hasMore) {
    users.pop();
  }

  // Simple token deduplication using Map
  const uniqueTokens = new Map<string, number>();
  for (const user of users) {
    if (!uniqueTokens.has(user.push_token)) {
      uniqueTokens.set(user.push_token, user.seq);
    }
  }

  return {
    tokens: Array.from(uniqueTokens.keys()),
    nextCursor: hasMore ? users[users.length - 1].seq : null
  };
}
Enter fullscreen mode Exit fullscreen mode

Why This Works Better

Simplified Database Query:

-- Before: Complex grouping
SELECT cell_phone, push_token, MAX(logindate)
FROM users
WHERE push_token IS NOT NULL
GROUP BY cell_phone, push_token
HAVING logindate = MAX(logindate);

-- After: Simple indexed scan
SELECT seq, push_token
FROM users
WHERE push_token IS NOT NULL
  AND push_token != ''
  AND seq > ?
ORDER BY seq ASC
LIMIT 1001;
Enter fullscreen mode Exit fullscreen mode

The refactored query:

  • No GROUP BY - simpler execution plan
  • No HAVING clause - no re-filtering needed
  • Uses cursor pagination - constant-time performance
  • Index-friendly - seq + push_token indexes only

In-Memory Deduplication:

const uniqueTokens = new Map<string, number>();
for (const user of users) {
  if (!uniqueTokens.has(user.push_token)) {
    uniqueTokens.set(user.push_token, user.seq);
  }
}
Enter fullscreen mode Exit fullscreen mode

Using JavaScript's Map for deduplication is fast (O(1) lookups) and explicit. I keep the first occurrence of each token, which is fine because:

  • Expired tokens are updated on login - the app calls getToken() and sends the new one
  • Invalid tokens are removed - I handle FCM errors and delete bad tokens (covered in Part 3)
  • The token itself is the unique identifier - duplicates mean the same device

Handling Multi-Device Users Correctly

With the new architecture, multi-device users naturally work:

// Database state for user with 2 devices
// user_id: "user123", device: "iPhone", push_token: "token_abc..."
// user_id: "user123", device: "iPad", push_token: "token_xyz..."

// Old logic: Would pick ONE token (most recent login)
// Result: Only one device gets notification ❌

// New logic: Both tokens are distinct, both included
// Result: Both devices get notification ✅
Enter fullscreen mode Exit fullscreen mode

Best Practice: Store Tokens with Metadata
I enhanced my schema to track devices properly:

@Entity()
export class User {
  @PrimaryColumn()
  id: string;

  @Column()
  seq: number;

  @Column({ nullable: true })
  push_token: string;  // Most recent token for this user-device combo

  @Column()
  cell_phone: string;

  @Column({ type: 'timestamp' })
  logindate: Date;  // Last login time

  @Column({ nullable: true })
  device_type: string;  // 'ios' | 'android' | null

  @Column({ nullable: true })
  device_id: string;  // Unique device identifier from client
}
Enter fullscreen mode Exit fullscreen mode

For more complex applications, consider a separate user_devices table:

@Entity()
export class UserDevice {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @Index()
  user_id: string;

  @Column()
  @Index({ unique: true })
  push_token: string;

  @Column()
  device_identifier: string;

  @Column()
  platform: 'ios' | 'android' | 'web';

  @Column({ type: 'timestamp' })
  token_updated_at: Date;

  @Column({ type: 'timestamp' })
  last_seen_at: Date;
}
Enter fullscreen mode Exit fullscreen mode

This enables:

  • Tracking token freshness per device
  • Removing stale tokens (>60 days inactive)
  • Device-specific notification targeting
  • Better debugging when notifications fail

Token Lifecycle Management
Understanding device-specific tokens led us to implement proper lifecycle handling:

1. Token Registration on Login

// auth.service.ts
async handleUserLogin(credentials: LoginDto, deviceInfo: DeviceInfo) {
  const user = await this.validateCredentials(credentials);

  // Update or create device record
  await this.userRepository.update(
    { id: user.id, device_id: deviceInfo.deviceId },
    {
      push_token: deviceInfo.pushToken,
      logindate: new Date(),
      device_type: deviceInfo.platform
    }
  );

  return { accessToken: this.generateToken(user) };
}
Enter fullscreen mode Exit fullscreen mode

2. Token Cleanup on Logout

// auth.service.ts
async handleUserLogout(userId: string, deviceId: string) {
  // Critical: Remove token so next user on device doesn't get notifications
  await this.userRepository.update(
    { id: userId, device_id: deviceId },
    { push_token: null }
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Handling Invalid Tokens
When FCM rejects a token, I remove it: Firebase

// notification.processor.ts
async sendNotification(token: string, payload: NotificationPayload) {
  try {
    await this.fcmService.send({ token, ...payload });
  } catch (error) {
    if (error.code === 'messaging/invalid-registration-token' ||
        error.code === 'messaging/registration-token-not-registered') {
      // Token is invalid - remove from database
      await this.userRepository.update(
        { push_token: token },
        { push_token: null }
      );
      this.logger.warn(`Removed invalid token: ${token}`);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Proactive Stale Token Cleanup
I run a daily job to remove tokens inactive for >60 days: Firebase

// Scheduled job
@Cron('0 2 * * *')  // 2 AM daily
async cleanupStaleTokens() {
  const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);

  const result = await this.userRepository
    .createQueryBuilder()
    .update(User)
    .set({ push_token: null })
    .where('push_token IS NOT NULL')
    .andWhere('logindate < :cutoff', { cutoff: sixtyDaysAgo })
    .execute();

  this.logger.log(`Cleaned up ${result.affected} stale tokens`);
}
Enter fullscreen mode Exit fullscreen mode

Performance Results
After refactoring to token-based deduplication:

Metric Before After Improvement
Query complexity GROUP BY + HAVING Simple WHERE Simpler
Query time (1M users) 450ms 180ms 2.5x faster
Memory usage Double dedup Single Map 40% reduction
End-to-end batch 2.5 minutes 1.5 minutes 40% faster
Multi-device support ❌ One device only ✅ All devices Fixed

More importantly, the logic became correct. Users with multiple devices now receive notifications on all their devices, which was the original business requirement.

Lessons Learned

1. Read the Documentation Carefully
Firebase's docs clearly state tokens are device-specific, but I initially missed this nuance. Always validate your mental model against official documentation. Stack Overflow Github

2. The Domain Model Matters
My incorrect assumption (user = phone number = single token) led to incorrect architecture. Understanding the true relationships (user → multiple devices → multiple tokens) was crucial.

  1. Simpler Is Often Better The refactored solution is simpler than the original:
  • Simpler SQL query
  • Simpler deduplication logic
  • Simpler mental model

Complexity is often a sign you're fighting the framework rather than working with it.

4. Performance and Correctness Together
This refactoring delivered both:

  • Better performance (2.5x faster queries)
  • Correct behavior (multi-device support)

Often, the right abstraction provides both benefits.

Common Pitfalls to Avoid
Pitfall 1: Assuming One Token Per User
Wrong: "Each user has one push token"
Right: "Each user can have multiple tokens (one per device)"

Pitfall 2: Not Handling Token Refresh
Tokens can change at any time. Always implement onTokenRefresh handlers on the client and update your backend.

Pitfall 3: Not Cleaning Up Invalid Tokens
Failed sends should trigger token removal. Otherwise, your database fills with dead tokens, wasting resources.

Pitfall 4: Shared Device Scenarios
If your app supports shared devices (e.g., family tablets), ensure logout clears the token. Otherwise, User B gets User A's notifications.

Conclusion
Understanding that Firebase Cloud Messaging tokens are device-specific, not user-specific fundamentally changed how I architected my notification system. By:

  1. Removing incorrect abstractions (grouping by phone number)
  2. Simplifying queries (no GROUP BY needed)
  3. Deduplicating by token directly (using simple Map)
  4. Implementing proper lifecycle handling (registration, cleanup, refresh)

I achieved a 40% performance improvement while fixing multi-device notification delivery.

The key insight: Work with Firebase's architecture, not against it. Tokens are designed to be device identifiers—use them as such.

Key Takeaways:

  • FCM tokens identify app installations on devices, not users
  • One user can (and often does) have multiple tokens
  • Deduplicate by token directly, not by user attributes
  • Implement token lifecycle management (registration, refresh, cleanup)
  • Simpler queries and correct domain modeling deliver both performance and correctness

Top comments (0)