The Firebase Cloud Messaging "messaging/too-many-topics" error occurs when a single app instance (device) attempts to subscribe to more than 2000 topics. Each device has a hard limit of approximately 2000 topic subscriptions to prevent resource exhaustion.
The "messaging/too-many-topics" error in Firebase Cloud Messaging (FCM) is returned when a single app instance exceeds the maximum topic subscription limit of approximately 2000 topics. Firebase enforces this per-device limit to prevent excessive memory consumption, network overhead, and performance degradation on client devices. While the FCM service itself supports unlimited topics and unlimited subscribers per topic, each individual device is restricted to prevent resource exhaustion on mobile devices with limited memory and bandwidth. This is a hard architectural limit that cannot be bypassed by configuration. If your app has exceeded this limit, you must redesign your topic strategy to use fewer subscriptions per device.
First, verify how many topics the app instance is currently subscribed to. This helps identify if you have exceeded the limit:
// For Android (using Firebase Messaging)
// The max limit is 2000 topics per app instance
const MAX_TOPICS = 2000;
// Track subscriptions
async function getSubscribedTopics() {
// Unfortunately, FCM doesn't provide a direct API to list all subscriptions
// Instead, track them in your database/localStorage
const storedTopics = localStorage.getItem('subscribedTopics');
const topics = storedTopics ? JSON.parse(storedTopics) : [];
console.log(`Currently subscribed to ${topics.length} topics`);
console.log(`Remaining capacity: ${MAX_TOPICS - topics.length} topics`);
if (topics.length >= MAX_TOPICS) {
console.warn('WARNING: At maximum topic subscription limit!');
return false;
}
return true;
}
// For Web with Firebase Messaging
import { getMessaging, getToken } from 'firebase/messaging';
const messaging = getMessaging();
getToken(messaging, {
vapidKey: 'YOUR_PUBLIC_VAPID_KEY'
}).then(token => {
console.log('Device token:', token);
// Use this token to track subscriptions on backend
});Implement topic tracking in your app to monitor subscription count before attempting new subscriptions.
Instead of creating a topic for every data item, group related topics using patterns:
// WRONG: Too many individual topics (hits 2000 limit quickly)
async function subscribeToMany() {
// If you have 100,000 products, you can't create a topic per product
for (let productId = 1; productId <= 100000; productId++) {
await messaging.subscribeToTopic([token], `product-${productId}`);
}
// This will fail after ~2000 subscriptions!
}
// CORRECT: Use parent topics with logic-based routing
async function subscribeToParentTopics() {
const parentTopics = [
'products-electronics',
'products-clothing',
'products-books',
'promotions-active',
'promotions-scheduled',
'notifications-account',
'notifications-orders'
];
for (const topic of parentTopics) {
try {
await messaging.subscribeToTopic([token], topic);
console.log(`Subscribed to ${topic}`);
} catch (error) {
console.error(`Failed to subscribe to ${topic}:`, error);
}
}
}
// CORRECT: Use topic conditions to achieve fine-grained targeting
async function sendWithConditions(message) {
// Instead of multiple topics, use a single message with conditional routing
const messageWithCondition = {
...message,
condition: "('products-electronics' in topics || 'products-clothing' in topics) && 'promotions-active' in topics"
};
// Send to devices matching the condition
await admin.messaging().send(messageWithCondition);
}
// CORRECT: Use server-side targeting instead of client-side subscriptions
async function sendToUserSegment(userId, message) {
// Query users matching criteria, send via direct tokens
const userDevices = await db.getUserDevices(userId);
const tokens = userDevices.map(d => d.fcmToken);
if (tokens.length > 0) {
await admin.messaging().sendMulticast({
tokens,
...message
});
}
}Key strategies:
1. Group by category: 10-20 parent topics instead of 1000+ individual ones
2. Use topic conditions: Combine multiple topics with boolean logic in the message
3. Server-side targeting: Send directly to device tokens instead of using topics
4. Tiered topics: category-subcategory-filter pattern keeps count low
Remove obsolete topics and unsubscribe from unused ones:
// Web/JavaScript
import { getMessaging, onMessage } from 'firebase/messaging';
const messaging = getMessaging();
async function cleanupOldTopics() {
// Load currently needed topics from your business logic
const currentlyNeededTopics = await getCurrentTopicList();
// Load previously subscribed topics
const subscribedTopics = JSON.parse(
localStorage.getItem('subscribedTopics') || '[]'
);
// Find topics that are no longer needed
const topicsToRemove = subscribedTopics.filter(
topic => !currentlyNeededTopics.includes(topic)
);
console.log(`Found ${topicsToRemove.length} topics to unsubscribe from`);
// Unsubscribe from old topics
if (topicsToRemove.length > 0) {
try {
// Send to backend to unsubscribe via IID service
const response = await fetch('/api/fcm/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: await getToken(messaging),
topics: topicsToRemove
})
});
if (response.ok) {
// Update local storage
const updated = subscribedTopics.filter(
topic => !topicsToRemove.includes(topic)
);
localStorage.setItem('subscribedTopics', JSON.stringify(updated));
console.log('Successfully cleaned up old topics');
}
} catch (error) {
console.error('Failed to cleanup topics:', error);
}
}
}
// Android (using Firebase Messaging library)
// Topics are tracked at the system level, cleanup is automatic when package is uninstalled
// Node.js backend endpoint for unsubscribing
import admin from 'firebase-admin';
export async function unsubscribeFromTopics(req, res) {
const { token, topics } = req.body;
if (!token || !Array.isArray(topics) || topics.length === 0) {
return res.status(400).json({ error: 'Invalid request' });
}
try {
const response = await admin.messaging().unsubscribeFromTopics(token, topics);
console.log(`Unsubscribed token from ${topics.length} topics`);
console.log(`Success: ${response}`);
res.json({
success: true,
unsubscribedCount: topics.length,
message: 'Successfully unsubscribed from old topics'
});
} catch (error) {
console.error('Unsubscribe error:', error);
res.status(500).json({ error: error.message });
}
}
// Call cleanup on app initialization
window.addEventListener('load', cleanupOldTopics);Implement cleanup:
- On every app startup, remove topics no longer needed
- After major app updates that change topic requirements
- Monthly cleanup of unused topics to maintain capacity
Instead of subscribing to many topics, combine a few topics with conditions:
// Node.js - Firebase Admin SDK
// INEFFICIENT: Trying to create separate topics for every combination
// This would quickly exceed the 2000 limit per device
// EFFICIENT: Use topic conditions to achieve complex targeting
async function sendWithConditions() {
const message = {
notification: {
title: 'Flash Sale',
body: 'Electronics sale is live'
},
condition: "('products-electronics' in topics || 'products-computers' in topics) && 'promotions-active' in topics && !('promotions-opt-out' in topics)"
};
try {
const response = await admin.messaging().send(message);
console.log('Message sent to devices matching condition:', response);
} catch (error) {
console.error('Error sending message:', error);
}
}
// Valid condition operators:
// - IN: topic in topics (device is subscribed)
// - &&: AND logic
// - ||: OR logic
// - !: NOT logic
// - (): grouping with parentheses
// Examples:
const examples = [
// Send to users interested in electronics OR books
"'products-electronics' in topics || 'products-books' in topics",
// Send to premium users interested in sales
"'user-type-premium' in topics && 'promotions-active' in topics",
// Send to everyone except those who opted out
"!('notifications-muted' in topics)",
// Complex: Active promotions + category + not opted out
"('promotions-active' in topics && ('products-electronics' in topics || 'products-clothing' in topics)) && !('promotions-opt-out' in topics)"
];
// Subscribe to foundation topics only
async function subscribeToFoundationTopics(token) {
const foundationTopics = [
'products-electronics',
'products-clothing',
'products-books',
'promotions-active',
'promotions-scheduled',
'user-type-premium',
'notifications-account',
'notifications-orders'
];
try {
const response = await admin.messaging().subscribeToTopic(
[token],
foundationTopics
);
console.log(`Subscribed to ${foundationTopics.length} foundation topics`);
} catch (error) {
console.error('Subscription failed:', error);
}
}
// Subscribe to no more than 15-20 parent topics
// Use conditions to achieve 100+ logical segmentsBenefits of topic conditions:
- Stay well under the 2000 topic limit per device
- More flexible targeting without changing subscriptions
- Easier to manage from the server side
- No need to continuously manage device subscriptions
For use cases with thousands of segments, use direct token targeting:
// Instead of subscribing to thousands of topics,
// maintain user preferences and send directly to tokens
// Database schema
interface UserPreferences {
userId: string;
fcmTokens: string[];
categories: string[]; // electronics, books, clothing
promoOptIn: boolean;
userType: 'free' | 'premium'; // premium
}
// Get user segments
async function getUserSegments(userId) {
const user = await db.users.findById(userId);
const preferences = await db.preferences.findByUserId(userId);
const segments = [];
if (preferences.categories.includes('electronics')) segments.push('segment-electronics');
if (preferences.categories.includes('books')) segments.push('segment-books');
if (preferences.promoOptIn) segments.push('segment-promo-active');
if (user.userType === 'premium') segments.push('segment-premium');
return segments;
}
// Send to user segment
async function sendToUserSegment(segmentName, message) {
// Find all users in this segment
const usersInSegment = await db.users.findBySegment(segmentName);
// Collect all FCM tokens
const allTokens = [];
for (const user of usersInSegment) {
const devices = await db.devices.findByUserId(user.id);
const tokens = devices.map(d => d.fcmToken).filter(t => t);
allTokens.push(...tokens);
}
console.log(`Sending to ${allTokens.length} devices in ${segmentName}`);
// Send in batches of 500 (FCM limit is 1000 per request)
for (let i = 0; i < allTokens.length; i += 500) {
const batch = allTokens.slice(i, i + 500);
try {
const response = await admin.messaging().sendMulticast({
tokens: batch,
notification: message.notification,
data: message.data,
android: message.android,
apns: message.apns,
webpush: message.webpush
});
console.log(`Batch sent: ${response.successCount} success, ${response.failureCount} failed`);
// Clean up invalid tokens
response.responses.forEach((resp, idx) => {
if (!resp.success && resp.error) {
const token = batch[idx];
const code = resp.error.code;
if (code === 'messaging/invalid-registration-token' ||
code === 'messaging/registration-token-not-registered') {
db.devices.deleteByToken(token);
}
}
});
} catch (error) {
console.error('Batch send failed:', error);
}
}
}
// Usage: Send to electronics enthusiasts
await sendToUserSegment('segment-electronics', {
notification: {
title: 'New Electronics',
body: 'Check out our latest gadgets'
},
data: { url: '/electronics' }
});
// This approach:
// - Eliminates the 2000 topic limit per device
// - Allows unlimited segmentation on the server
// - Each device subscribes to 0-5 foundation topics only
// - Server maintains user → segment mappingImplementation benefits:
- No topic subscription limit issues
- More control and flexibility in targeting
- Easier to change segments without app updates
- Better for large-scale user bases with many segments
Track topic subscription metrics to prevent hitting limits:
// Client-side tracking (Web)
class TopicSubscriptionTracker {
constructor() {
this.topics = new Set();
this.maxTopics = 2000;
this.warningThreshold = 1800; // Alert at 90% capacity
}
addTopic(topicName) {
if (this.topics.size >= this.maxTopics) {
console.error(`ERROR: Topic limit reached. Cannot add ${topicName}`);
return false;
}
if (this.topics.size >= this.warningThreshold) {
console.warn(`WARNING: Approaching topic limit (${this.topics.size}/${this.maxTopics})`);
// Send alert to monitoring system
this.reportMetric('topic-capacity-warning', this.topics.size);
}
this.topics.add(topicName);
this.saveToStorage();
return true;
}
removeTopic(topicName) {
this.topics.delete(topicName);
this.saveToStorage();
}
getUsage() {
return {
current: this.topics.size,
max: this.maxTopics,
percentUsed: Math.round((this.topics.size / this.maxTopics) * 100),
available: this.maxTopics - this.topics.size
};
}
saveToStorage() {
localStorage.setItem(
'subscribedTopics',
JSON.stringify(Array.from(this.topics))
);
}
reportMetric(metricName, value) {
// Send to analytics
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({ metric: metricName, value })
});
}
printUsage() {
const usage = this.getUsage();
console.log(`Topic usage: ${usage.current}/${usage.max} (${usage.percentUsed}%)`);
console.log(`Available slots: ${usage.available}`);
}
}
// Initialize and use
const topicTracker = new TopicSubscriptionTracker();
async function subscribeIfCapacity(topicName) {
if (topicTracker.addTopic(topicName)) {
await messaging.subscribeToTopic([token], topicName);
topicTracker.printUsage();
} else {
console.error('No capacity to add topic');
}
}
// Server-side monitoring
async function monitorTopicHealth() {
const allDevices = await db.devices.findAll();
const topicUsageStats = new Map();
for (const device of allDevices) {
const deviceTopics = await fcm.getDeviceTopics(device.token);
// Track max usage per device
const currentMax = Math.max(...Array.from(topicUsageStats.values()));
if (deviceTopics.length > currentMax) {
topicUsageStats.set(device.id, deviceTopics.length);
}
// Alert if near limit
if (deviceTopics.length > 1900) {
console.warn(`Device ${device.id} has ${deviceTopics.length} topics - near limit`);
// Send alert, page on-call, etc.
}
}
// Report metrics
const avgUsage = Array.from(topicUsageStats.values()).reduce((a, b) => a + b) / topicUsageStats.size;
const maxUsage = Math.max(...Array.from(topicUsageStats.values()));
console.log(`Average topic usage per device: ${avgUsage.toFixed(0)}`);
console.log(`Max topic usage: ${maxUsage}`);
}
// Run periodic health checks
setInterval(monitorTopicHealth, 60 * 60 * 1000); // Every hourKey monitoring practices:
- Track subscription count per device
- Alert at 90% capacity (1800/2000 topics)
- Implement automatic cleanup when capacity is low
- Monitor for anomalies in subscription patterns
### Firebase Topic Subscription Architecture
Per-Device Limits:
- One app instance: maximum 2000 topics
- One topic: unlimited subscribers
- This asymmetric limit prevents device-side resource exhaustion
Why the 2000 Limit Exists:
- Mobile devices have limited memory and battery
- Maintaining subscriptions to 10,000 topics would drain memory
- Network overhead increases with topic count
- Reduces device CPU usage during message processing
Subscription Methods:
1. IID Service (Legacy):
- Used by older Firebase SDKs
- Endpoint: https://iid.googleapis.com/iid/v1/${token}/rel/topics/${topic}
- No longer recommended
2. Firebase Admin SDK (Recommended):
// Subscribe
await admin.messaging().subscribeToTopic(tokens, topic);
// Unsubscribe
await admin.messaging().unsubscribeFromTopics(tokens, topics);3. Client SDKs:
- Android: messaging.subscribeToTopic(topic)
- iOS: Messaging.messaging().subscribe(toTopic: topic)
- Web: Must use admin SDK or IID service from backend
Topic Naming Best Practices:
- Use lowercase alphanumeric and hyphens only
- Maximum 900 characters per topic name
- Format: parent-category-subcategory
- Examples: promotions-active, products-electronics-sale
Efficiency Patterns:
1. Foundation Topics (10-20):
- Core categories user cares about
- Examples: category, user-type, region
2. Topic Conditions (at send time):
- Combine foundation topics with boolean logic
- Create unlimited virtual segments
- Change targeting without device subscriptions
3. Server-Side Targeting:
- Maintain user preferences in database
- Query matching users
- Send via direct tokens (sendMulticast)
- Scales to millions of users
Migration Path from Many Topics:
If your app currently has 1000+ topics per device:
1. Phase 1: Implement cleanup on next app startup
2. Phase 2: Redesign to use 15-20 foundation topics
3. Phase 3: Add topic conditions for complex targeting
4. Phase 4: Move to server-side targeting for future features
Testing:
firebase emulators:start --only messagingTest your topic architecture locally before deploying.
messaging/UNSPECIFIED_ERROR: No additional information available
How to fix "messaging/UNSPECIFIED_ERROR: No additional information available" in Firebase Cloud Messaging
App Check: reCAPTCHA Score Too Low
App Check reCAPTCHA Score Too Low
storage/invalid-url: Invalid URL format for Cloud Storage reference
How to fix invalid URL format in Firebase Cloud Storage
auth/missing-uid: User ID identifier required
How to fix "auth/missing-uid: User ID identifier required" in Firebase
auth/invalid-argument: Invalid parameter passed to method
How to fix "auth/invalid-argument: Invalid parameter passed to method" in Firebase