You exceeded Firebase Cloud Messaging's per-project rate limit by sending too many messages within a short time window. FCM enforces a default quota of 600,000 messages per minute across your entire project.
Firebase Cloud Messaging uses a token bucket system to rate-limit message sending. Each project gets a quota of 600,000 messages per minute by default. When your sending rate exceeds this limit, the service returns a 429 RESOURCE_EXHAUSTED error and rejects further messages until the quota resets in the next minute window. This error indicates you're sending messages faster than Firebase allows, either due to legitimate high-volume campaigns or spiky traffic patterns.
When you receive a 429 RESOURCE_EXHAUSTED error, don't retry immediately. Instead, use exponential backoff with random jittering to spread retry attempts over time.
Example implementation:
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 1000;
async function sendMessageWithRetry(message: Message): Promise<string> {
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return await admin.messaging().send(message);
} catch (error: any) {
if (error.code === 'messaging/message-rate-exceeded' && attempt < MAX_RETRIES - 1) {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const baseDelay = BASE_DELAY_MS * Math.pow(2, attempt);
// Add jitter: randomize between 0.9x and 1.0x of calculated delay
const jitter = Math.random() * 0.1;
const delay = baseDelay * (0.9 + jitter);
console.log(`Rate limited. Retrying after ${Math.round(delay)}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}Recommended backoff sequence: 1s, 2s, 4s, 8s, 16s, 32s. The jitter prevents thundering herd problems where all clients retry at the same time.
Rather than sending to all users instantly, distribute your message sends over time. This is critical for high-volume campaigns.
Example approach:
const userBatches = chunkArray(userIds, 1000); // Split into batches of 1000
for (const batch of userBatches) {
// Send to batch concurrently (up to reasonable limit)
const promises = batch.map(userId =>
sendMessageWithRetry({
notification: { title: 'Hello', body: 'Message content' },
token: getUserToken(userId)
})
);
await Promise.all(promises);
// Wait 1-2 seconds between batches to avoid rate limits
await new Promise(resolve => setTimeout(resolve, 1000));
}For large campaigns, ramp from 0 messages to your max RPS across a 60-second window minimum. Longer ramp windows (2-5 minutes) are safer for extremely high volumes.
Monitor your actual usage to understand if you're near the default 600K/min limit.
Steps:
1. Go to [Google Cloud Console](https://console.cloud.google.com)
2. Select your Firebase project
3. Navigate to APIs & Services → Firebase Cloud Messaging API
4. Click Quotas & System Limits
5. Look for "Quota" metric and check your current RPM usage
If you're consistently using 80%+ of your quota for 5+ minutes per day with less than 5% client error rate, you may be eligible to request a quota increase of up to +25%.
If your high message volume is expected and your error rate is low, Firebase may grant a quota increase.
Eligibility requirements:
- Usage is regularly ≥80% of current quota for at least 5 consecutive minutes per day
- Client error ratio is <5%, especially during peak traffic
- You're following best practices for sending at scale
Quota increase timeline:
- Standard requests: Submit at least 15 days before you need it
- Large requests (>18M messages/min): Submit 30 days in advance
Submit your request through your [Firebase Console](https://console.firebase.google.com) or contact Firebase Support. Include details about your use case, expected volume, and why you need the increase.
Teach your application to voluntarily back off when it detects failures.
async function sendMessageWithRetryAfter(message: Message): Promise<string> {
try {
return await admin.messaging().send(message);
} catch (error: any) {
if (error.code === 'messaging/message-rate-exceeded') {
// Firebase Admin SDK may include retry-after in error details
const retryAfter = error.retryAfter || 60; // Default to 60 seconds
console.log(`Rate limited. Server requests retry after ${retryAfter}s`);
// Queue for retry later
await queueMessageForRetry(message, retryAfter * 1000);
return 'queued';
}
throw error;
}
}Set a minimum 10-second timeout on send requests before giving up, since FCM's internal RPCs use 10-second timeouts.
FCM experiences predictable load spikes at :00, :15, :30, and :45 minute marks. Schedule important sends outside these windows.
Better approach:
- Avoid sending within 2 minutes before/after these quarter-hour marks
- Schedule batch sends for :02, :17, :32, :47 past the hour
- Use scheduling to spread sends throughout the day rather than bunching them
function getOptimalSendTime(): Date {
const now = new Date();
const minutes = now.getMinutes();
// Calculate next safe window (avoid :00, :15, :30, :45 ±2 minutes)
const unsafeWindows = [
{ start: 58, end: 2 }, // :58-:02
{ start: 13, end: 17 }, // :13-:17
{ start: 28, end: 32 }, // :28-:32
{ start: 43, end: 47 } // :43-:47
];
// Find next safe time...
}Different platforms have different per-device rate limits:
Android: 240 messages per minute, 5,000 messages per hour per device
iOS: Limited by Apple Push Notification (APNs) rates
Web: Similar to Android limits
If you're sending personalized messages to the same user from multiple sources, check that combined traffic doesn't exceed device limits.
The per-device limit is high by design to allow short bursts (like rapid chat messages), but aggressive retry logic hitting the limit indicates a logic error.
Collapsible Messages: If using collapsible messages (messages with the same collapse_key), note that FCM limits these to 20 messages per app per device as a burst, with a refill of 1 message every 3 minutes. This prevents battery drain on devices and is separate from the overall rate limit.
Multi-region Considerations: If sending from multiple regions or server instances, coordinate to avoid summing up to exceed quota. A distributed queue system (like Cloud Tasks or Pub/Sub) can help coordinate message sending across multiple processes.
Token Bucket Model: Understanding the token bucket: each project starts each 60-second window with 600,000 tokens. Each message send consumes 1 token. When tokens are exhausted, new requests are rejected with 429 until the bucket refills at the next minute boundary. Short bursts are allowed, but sustained high volume triggers the error.
Debugging: Enable debug logging in Firebase Admin SDK to see exact error details:
process.env.DEBUG = 'firebase-admin:*';Check the [FCM Error Codes documentation](https://firebase.google.com/docs/cloud-messaging/error-codes) for a complete list of messaging errors.
Callable Functions: INTERNAL - Unhandled exception
How to fix "Callable Functions: INTERNAL - Unhandled exception" in Firebase
auth/invalid-hash-algorithm: Hash algorithm doesn't match supported options
How to fix "auth/invalid-hash-algorithm: Hash algorithm doesn't match supported options" in Firebase
Hosting: CORS configuration not set up properly
How to fix CORS configuration in Firebase Hosting
auth/reserved-claims: Custom claims use reserved OIDC claim names
How to fix "reserved claims" error when setting custom claims in Firebase
Callable Functions: UNAUTHENTICATED - Invalid credentials
How to fix "UNAUTHENTICATED - Invalid credentials" in Firebase Callable Functions