This error occurs when you exceed Firebase Cloud Messaging rate limits for sending messages to topics. Firebase restricts concurrent message fanouts to prevent abuse and ensure fair usage across all projects.
The `messaging/topics-message-rate-exceeded` error indicates that your application has exceeded Firebase Cloud Messaging (FCM) rate limits for topic-based messaging. Firebase imposes these limits to ensure fair resource allocation and prevent abuse of the messaging infrastructure. When you send messages to topics in FCM, the system performs "message fanout" - distributing a single message to potentially thousands or millions of subscribers. This process isn't instantaneous and requires significant backend resources. Firebase limits the number of concurrent message fanouts per project to 1,000. When you exceed this limit, additional fanout requests are either rejected with this error or deferred until in-progress fanouts complete. The error typically manifests as an HTTP 429 RESOURCE_EXHAUSTED response with the error code "QUOTA_EXCEEDED". The quota automatically refills in the following minute, but repeated violations can indicate architectural issues with your messaging strategy that need to be addressed.
When you receive a 429 error, implement exponential backoff to retry the request after waiting progressively longer periods.
const admin = require('firebase-admin');
async function sendToTopicWithRetry(message, topic, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await admin.messaging().sendToTopic(topic, message);
console.log('Successfully sent message:', response);
return response;
} catch (error) {
if (error.code === 'messaging/topics-message-rate-exceeded') {
const waitTime = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.log(`Rate limit hit, waiting ${waitTime}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
} else {
throw error; // Re-throw non-rate-limit errors
}
}
}
throw new Error('Max retries exceeded for topic message');
}This ensures temporary rate limit issues don't cause permanent message failures.
Implement client-side rate limiting to prevent exceeding Firebase's concurrent fanout limits.
class TopicMessageQueue {
constructor(maxConcurrent = 100) {
this.maxConcurrent = maxConcurrent;
this.activeRequests = 0;
this.queue = [];
}
async send(topic, message) {
// Wait if at capacity
while (this.activeRequests >= this.maxConcurrent) {
await new Promise(resolve => setTimeout(resolve, 100));
}
this.activeRequests++;
try {
const result = await admin.messaging().sendToTopic(topic, message);
return result;
} finally {
this.activeRequests--;
this.processQueue();
}
}
processQueue() {
if (this.queue.length > 0 && this.activeRequests < this.maxConcurrent) {
const next = this.queue.shift();
next();
}
}
}
// Usage
const messageQueue = new TopicMessageQueue(100);
await messageQueue.send('news-updates', {
notification: { title: 'Breaking News', body: 'Story text' }
});Keep your concurrent sends well below 1,000 to leave headroom for other processes.
Instead of sending individual messages for every event, batch multiple updates together.
class MessageBatcher {
constructor(topic, batchInterval = 5000) {
this.topic = topic;
this.batchInterval = batchInterval;
this.pendingMessages = [];
this.timer = null;
}
addMessage(data) {
this.pendingMessages.push(data);
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), this.batchInterval);
}
}
async flush() {
if (this.pendingMessages.length === 0) return;
const batch = this.pendingMessages.splice(0);
this.timer = null;
// Send single message with batched data
await admin.messaging().sendToTopic(this.topic, {
data: {
updates: JSON.stringify(batch),
count: batch.length.toString()
}
});
}
}
// Usage: Send one message every 5 seconds instead of hundreds
const batcher = new MessageBatcher('stock-updates', 5000);
batcher.addMessage({ symbol: 'AAPL', price: 150.25 });
batcher.addMessage({ symbol: 'GOOGL', price: 2800.50 });This dramatically reduces the number of topic sends.
Topics are designed for broadcasting to large audiences. For smaller, specific groups (under ~1,000 devices), use device group messaging or direct token sends.
// Instead of creating a topic for a small group
// BAD: Topic for 50 users
await admin.messaging().sendToTopic('team-alpha-notifications', message);
// GOOD: Direct send to device tokens
const deviceTokens = [
'token1...', 'token2...', 'token3...' // Up to 500 tokens per batch
];
await admin.messaging().sendMulticast({
tokens: deviceTokens,
notification: {
title: 'Team Update',
body: 'New task assigned'
}
});This bypasses topic fanout limits entirely for targeted messaging.
Large topics with millions of subscribers take longer to fan out, increasing the chance of concurrent fanout limit issues.
Check your topic sizes and consider splitting very large topics:
// Split a massive global topic into regional topics
// Instead of: 'all-users' (5 million subscribers)
// Use: 'users-north-america', 'users-europe', 'users-asia'
async function sendToRegionalTopics(message) {
const regions = ['north-america', 'europe', 'asia', 'latam'];
for (const region of regions) {
await sendToTopicWithRetry(message, `users-${region}`);
// Add small delay between regions
await new Promise(resolve => setTimeout(resolve, 500));
}
}Smaller topics complete fanout faster, reducing concurrent fanout count.
Understanding Fanout Rate Limits
While Firebase limits concurrent fanouts to 1,000 per project, the actual achievable fanout rate can reach 10,000 QPS or higher depending on total system load. This means a single fanout can deliver messages to thousands of devices per second, but you can't have more than 1,000 of these operations running simultaneously.
Topic Subscription Limits
Be aware that each app instance can subscribe to a maximum of 2,000 topics, and the rate for adding/removing subscriptions is limited to 3,000 QPS per project. If you're also hitting subscription rate limits, you'll receive a 429 RESOURCE_EXHAUSTED error during subscribe/unsubscribe operations.
Production Architecture Patterns
For high-scale applications, consider implementing a message queue (Redis, RabbitMQ, Cloud Tasks) between your application and FCM. This allows you to control send rate precisely and handle backpressure gracefully:
// Using Cloud Tasks to throttle FCM sends
const { CloudTasksClient } = require('@google-cloud/tasks');
const client = new CloudTasksClient();
async function enqueueFCMSend(topic, message, delaySeconds = 0) {
const task = {
httpRequest: {
httpMethod: 'POST',
url: 'https://your-app.com/send-fcm',
body: Buffer.from(JSON.stringify({ topic, message })).toString('base64'),
},
scheduleTime: {
seconds: Date.now() / 1000 + delaySeconds
}
};
await client.createTask({ parent: queuePath, task });
}Monitoring and Alerting
Set up monitoring for FCM quota errors to detect patterns before they impact users. Track metrics like messages sent per minute, 429 error rate, and average fanout completion time.
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