I have a React Native app that uses Firestore and Cloud Functions to send notifications to users. My initial implementation uses sequential async/await calls, but I encountered timeout errors (even with a 540-second timeout and 512MB memory). To improve performance, I modified the code to process users concurrently while batching Firestore document updates and FCM notifications.
Below is the first working implementation that uses sequential processing:
async function sendNotifications() {
console.log("Sending notifications for recommended events...");
// Fetch all events once
const eventsRef = admin.firestore().collection("Events");
const eventsSnapshot = await eventsRef
.where('Start', '>=', new Date())
.get();
if (eventsSnapshot.empty) {
console.log("No upcoming events found.");
return;
}
const allEvents = eventsSnapshot.docs.map(doc => ({ ...doc.data(), docId: doc.id }));
// Fetch all users
const usersRef = admin.firestore().collection("Users");
const usersSnapshot = await usersRef.get();
let reset = false;
for (const userDoc of usersSnapshot.docs) {
try {
const userData = userDoc.data();
const { fcmToken, preferences, language = "en", sentNotifications = [] } = userData;
if (!fcmToken) continue; // Skip users without FCM token
const userPreferredTags = preferences ? preferences : [];
let eventToSend = findEventForUser(allEvents, userPreferredTags, sentNotifications);
// Fallback logic: No matching events, or user has no preferences
if (!eventToSend) {
eventToSend = findBangerEvent(allEvents, sentNotifications);
}
if (!eventToSend && sentNotifications.length > 0) {
console.log(`No new events to suggest, resetting`);
eventToSend = sentNotifications[sentNotifications.length - 1];
reset = true;
}
if (!eventToSend) {
console.log(`No events to send for user ${userDoc.id}. Skipping.`);
continue;
}
const notificationPayload = createNotificationPayload(
eventToSend,
fcmToken,
language
);
await admin.messaging().send(notificationPayload);
console.log(`Successfully sent message to user ${userDoc.id}, ${notificationPayload.notification.title}`);
const updatedNotifications = updateSentNotifications(eventToSend, reset ? [] : sentNotifications);
await userDoc.ref.update({ sentNotifications: updatedNotifications });
} catch (error) {
console.error(`Error processing user ${userDoc.id}:`, error);
}
}
console.log("Notifications sent successfully.");
}
To improve throughput, I moved to asynchronous functions to process users concurrently and batch Firestore updates and FCM notifications. The following code is my attempt on the Firebase Emulator:
async function sendNotifications() {
console.log("Sending notifications for recommended events...");
// Fetch all events once
const eventsRef = admin.firestore().collection("Events");
const eventsSnapshot = await eventsRef
.where('Start', '>=', new Date())
.get();
if (eventsSnapshot.empty) {
console.log("No upcoming events found.");
return;
}
const allEvents = eventsSnapshot.docs.map(doc => ({ ...doc.data(), docId: doc.id }));
// Fetch all users
const usersRef = admin.firestore().collection("Users");
const usersSnapshot = await usersRef.get();
const usersToProcess = usersSnapshot.docs.filter(userDoc => {
const userData = userDoc.data();
return true; // Include all users with an FCM token (set to true in emulator)
});
console.log(`Processing ${usersToProcess.length} users...`);
const notifications = [];
let batch = admin.firestore().batch();
let batchUserCount = 0; // Track the number of users in the current batch
const userPromises = usersToProcess.map(async (userDoc) => {
const userData = userDoc.data();
const { fcmToken, preferences, language = "en", sentNotifications = [] } = userData;
const userPreferredTags = preferences || [];
let eventToSend = findEventForUser(allEvents, userPreferredTags, sentNotifications);
// Fallback logic: No matching events
if (!eventToSend) {
eventToSend = findBangerEvent(allEvents, sentNotifications) ||
sentNotifications[sentNotifications.length - 1];
}
if (!eventToSend) {
console.log(`No events to send for user ${userDoc.id}. Skipping.`);
return;
}
const notificationPayload = createNotificationPayload(eventToSend, fcmToken ? fcmToken : "ezeazea", language);
notifications.push(notificationPayload);
const updatedNotifications = updateSentNotifications(eventToSend, sentNotifications);
const dataSize = JSON.stringify({ sentNotifications: updatedNotifications }).length;
console.log(`Estimated size of update: ${dataSize} bytes`);
batch.update(userDoc.ref, { sentNotifications: updatedNotifications });
batchUserCount++;
// If the batch has 100 operations, commit the batch and start a new one
if (batchUserCount === 100) {
console.log("Committing Firestore batch...");
await batch.commit(); // Commit the batch
batch = admin.firestore().batch(); // Create a new batch
batchUserCount = 0; // Reset the batch user count
}
});
await Promise.all(userPromises);
// Commit remaining updates if any users were left in the batch
if (batchUserCount > 0) {
console.log("Committing remaining Firestore batch...");
await batch.commit();
}
// Send notifications in bulk (in batches of 100)
console.log("Sending notifications in bulk...");
while (notifications.length) {
const batchNotifications = notifications.splice(0, 100); // Firebase max batch size for FCM
try {
await admin.messaging().sendEach(batchNotifications);
} catch (error) {
console.error("Error sending notifications:", error);
// Handle the error as necessary
}
}
console.log("Notifications sent successfully.");
}
However, when processing users asynchronously with batched updates, I get the error on the second commit call:
โ functions: Error: Cannot modify a WriteBatch that has been committed.
How can I resolve this conflict between asynchronous user processing and batch updates?