Firebase is embedded in the heart of Fun Forms, we’ve been using it for over 10 years and have loved every second.
That said, just because you love something it doesn’t immediately mean you should use it. So, why was Firebase was the right choice for us?
Let’s walk through some of the many reasons we chose Firebase for our SaaS product.
Authentication and Users
Dealing with user authentication is the huge challenge for any sofrware company, which is why solutions like Firebase Authentication and Auth0 exist. Out-of-the-box, Firebase gives us Firebase Authentication, a simple drop-in for instant account creation, password resetting and more.
Social and Email Logins
Allowing users to one-click sign up or login is a great way to boost the user experience and drive more conversions, it lowers the barrier to entry.
With this in mind, we required social login via Google Authentication and Microsoft Authentication. Leaving no one out, we provide a third login option via traditional email and password authentication:

This easily covers all bases, and Firebase handles every aspect of account creation, login/logout flawlessly for us with each provider. Each login provider can be enabled or disabled in the Firebase Console, here’s our setup:

Implementing your own authentication is a huge challenge even for large software teams, with tokens, security and many more considerations it’s no wonder there are companies exclusively dedicated to solving this one problem.
It is critical to get to market as efficiently as possible, and reinventing the wheel is not an effective approach. Given authentication is one of the most critical aspects of an application - we trust Firebase to handle this for us indefinitely.
Auth Triggers via Cloud Functions
Managing account creation and deletion logic is simple by extending Firebase Authentication with Cloud Functions.
We use an onCreate
trigger to send a custom ‘Verify Your Email Address’ email (for ultimate branding purposes):
export const onUserCreate = functions.auth.user().onCreate(async (user) => {
const { email, emailVerified } = user;
if (!emailVerified) {
const verifyEmailLink = await admin
.auth()
.generateEmailVerificationLink(email);
await createTask('send-email-verify-email', {
to: email,
link: verifyEmailLink,
});
}
});
This method allows us to create custom emails, instead of the plain-text default from Firebase that’s sent via sendVerificationEmail()
after account creation:

When an account is deleted we also run another auth trigger, the onDelete
, which handles user cleanup, workspaces, forms and more:
export const onDeleteUser = functions.auth.user().onDelete(async ({ uid }) => {
const userRef = db.doc(`users/${uid}`);
const userDoc = await userRef.get();
if (userDoc.exists) {
await createTask('destroy-user', { uid });
}
});
We rely solely on these triggers to initiate a process, rather than completing the entire workflow within the trigger itself. Why? If an error occurs during the execution of the trigger, the operation is lost, and Firebase does not attempt to retry the trigger automatically. This can lead to data inconsistencies or missed operations.
To mitigate this, we use triggers to create tasks, which are then queued and processed independently. Tasks provide the following benefits:
- Retry Mechanism: Failed tasks can be retried without relying on the trigger to re-fire.
- Decoupled Logic: Triggers remain lightweight and efficient, only responsible for creating the task. The actual processing happens elsewhere, reducing potential bottlenecks.
- Improved Scalability: Tasks can be processed asynchronously, allowing us to handle high volumes without overloading our triggers.
This approach ensures reliability and resiliency, especially for critical workflows such as user onboarding or form submission processing.
Which brings us to the next key part of our Firebase implementation: handling complex tasks efficiently.
Scalable and Asychronous Tasks
Fun Forms uses task-driven architecture, i.e. a Task Queue.
Firebase Cloud Functions are superb for event-driven logic, but they have some limitations (just like all software):
- Cold Starts: Functions may take longer to execute if they haven’t been recently invoked.
- Timeouts: Long-running processes (over 540 seconds) are not supported by Cloud Functions (lots of operations in a single function may timeout).
- Retry Logic: Retry mechanisms are limited and can lead to duplicated effort if not carefully managed.
To address these limitations we use a tasks, where each task is written to a database collection and processed individually by a dedicated worker function.
This gives us huge benefits, some of which are:
- Reliability: Tasks persist in the database and can be retried if they fail (we only delete a task document when it’s successful)
- Scalability: Tasks are processed asynchronously and can be distributed across multiple workers (auto-scaling instances).
- Flexibility: Tasks can be prioritized, delayed, or even batched depending on their requirements ().
Task Queue Implementation
In reality, the above sounds like a lot of complex design but in reality it’s simpler than you think. First, we use a createTask
function that wraps around a document creation call:
export type TaskPayload<T extends funTasks> = T;
export const createTask = async <T extends funTaskNames>(
task: T,
payload: Omit<TaskPayload<Extract<funTasks, { task: T }>>, 'task'>
) => {
const tasksRef = db.collection('tasks').doc();
const data = {
task,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
...payload,
};
await tasksRef.set(data);
return { tasksRef, taskId: tasksRef.id };
};
And an example to create a new task:
await createTask('destroy-user', { uid });
Which then creates a new task document ready to be executed.
We make heavy use of onDocumentCreated
triggers throughout Fun Forms, and our task queue is no different. We listen to document creations and based off the “type” of task - a switch statement conditionally executes the correct function to execute the logic:
export const onTaskCreated = onDocumentCreated(
'tasks/{taskId}',
async (snapshot) => {
const snap = snapshot.data;
const { task, ...doc } = snap.data() as funTasks;
switch (task) {
case 'destroy-user': {
await doDestroyUser(doc as funTaskDestroyUser, snap.ref);
break;
}
case 'recursive-delete-form': {
await doRecursiveFormDelete(doc as funTaskRecursiveDeleteForm, snap.ref);
break;
}
}
}
);
This clean architecture lets us confidently handle critical asynchronous workflows with ease by simply instructing what task to perform and what data to perform it with. Some quick benefits:
- Sending emails for account verification, onboarding, or form notifications.
- Cleaning up resources when users are deleted (e.g., workspaces, forms, or uploaded files).
- Scheduling periodic maintenance tasks like data backups or checking progress in free trials.
Task queues future-proof our system and allow us to scale task processing independently of the main application logic. It also gives us the ability to create the same task from various places in the application without duplicating any logic.
Each task gives us a brand new Firebase Cloud Function instance where we can work quickly and effectively to stay within Firebase limits and gain full control over whether the task was successful, or not.
We’ve briefly touched on document creation events, and I’ve mentioned how we use these vastly in Fun Forms, so let’s explore how they solve more of our problems.
Cloud Firestore Document Triggers
The backbone of many Fun Forms workflows revolve around Cloud Firestore Document Triggers, specifically the Second Gen functions.
These triggers allow us to listen for changes to specific Firestore collections, or documents, and execute logic automatically. This ensures our app responds dynamically to specific user interactions.
What Are Document Triggers?
Cloud Firestore triggers are event-driven Cloud Functions that respond to changes in Firestore, such as:
- onDocumentCreated: Triggered when a document is written to for the first time.
- onDocumentUpdated: Triggered when a document already exists and has any value changed.
- onDocumentDeleted: Triggered when a document is deleted.
- onDocumentWritten: Triggered when onDocumentCreated, onDocumentUpdated or onDocumentDeleted is triggered.
By listening to these triggers we can instantly react to Firestore data changes. This includes executing critical server-side operations asynchronously (and out of sight) allowing users to focus on their tasks while we handle the rest.
This can include sending notifications, cleaning up resources, or synchronizing real-time data. A few examples of how we use them:
- Automate Backend Processes: Ensure server-side logic like response encryption, email notifications, PDF generation, and data validation happens in the background.
- Handle Resource Cleanup: Keep the database tidy and scalable by automatically deleting related data when a form or user is removed.
Example, if a form is created, there are a few bits of logic we run. One of those is generating a QR Code for each form via onDocumentCreated
:
export const onFormCreate = onDocumentCreated(
'workspaces/{workspaceId}/forms/{formId}',
async (snapshot) => {
const formRef = snapshot.data.ref;
const formData = snapshot.data.data() as funForm;
await createQrCode(formRef, formData);
}
);
This is just one example where a form is created and a QR code is automatically generated and written back to the newly created form in the background, so the user can download it at any time to share their form in printed format.
Similarly using onDocumentUpdated
, we can respond to user account upgrades or downgrades in their workspace:
export const onWorkspaceUpdated = onDocumentUpdated(
'workspaces/{workspaceId}',
async (snapshot) => {
const { workspaceId } = snapshot.params;
const { before, after } = snapshot.data;
await doWorkspaceSubscriptionPlanChange(workspaceId, before, after);
}
);
Within the doWorkspaceSubscriptionPlanChange
we would then compare the before
and after
document snapshots to see if the subscription plan has changed, and perform upgrades or downgrades via the aforementioned task queue.
We use onDocumentDeleted
to then perform necessary cleanup, example if a form is deleted:
export const onFormDelete = onDocumentDeleted(
'workspaces/{workspaceId}/forms/{formId}',
async (snapshot) => {
const { workspaceId, formId } = snapshot.params;
await deleteStorageFolder(`forms/${formId}`);
await createTask('recursive-delete-form', { workspaceId, formId });
}
);
Triggers let us handle large-scale operations like handling form responses, cleaning up forms or users in a distributed and scalable way - or even just performing simple tasks behind the scenes like QR code generation.
By seamlessly integrating Firestore triggers, we can confidently handle server-side tasks, ensuring a smooth and efficient experience for users - all in the magic of the cloud.
Real-Time Firestore Database
The nature of building forms on Fun Forms is typically done by a single user. However, we had the requirement to allow our users to invite team members:

This means that a user could be logged in from anywhere in the world and see another user build or edit the form in front of their eyes.
All changes are immediately synced to all viewers across any device. For us, this was a huge selling point that strengthened the real-time collaboration features we offer.