Firebase: The Great, the Meh, and the Ugly

Since its acquisition by Google in 2014, Firebase has rapidly evolved from a niche backend-as-a-service platform to a versatile suite of tools for developing modern web and mobile applications. Firebase‘s promise is an alluring one – abstracting away much of the plumbing and infrastructure work required to build an app so you can focus purely on crafting an amazing user experience. But how well does it actually deliver on this promise?

As a full-stack developer who has built multiple production apps with Firebase, I‘ve experienced first-hand the joys and frustrations of this increasingly popular platform. In this post, I‘ll share an in-depth and honest assessment of Firebase – the great parts that can massively accelerate development, the mediocre aspects you can live with, and the ugly problems that may have you tearing your hair out. Whether you‘re a Firebase veteran or just considering it for your next project, I hope you‘ll come away with a nuanced perspective to inform your decision.

The Great

Rapid, Realtime Data Sync

Firebase‘s crown jewel, and still its most compelling feature for many developers, is the realtime database. This is a flexible, NoSQL cloud-hosted database that automatically synchronizes data between all connected clients in real time.

For apps that benefit from realtime data – think chat, collaborative work tools, multiplayer games – Firebase is unparalleled in how quickly it allows you to add this capability. The auto-magical data sync frees you from having to manage data synchronization yourself via a homegrown solution with WebSockets or long polling. As updates occur on any client, they are instantly broadcast to all other connected clients with no extra server-side code required.

Here‘s how simple it is to listen for new chat messages as they‘re added in real time:

const chatMessagesRef = firebase.database().ref(‘chatMessages‘);

chatMessagesRef.on(‘child_added‘, (snapshot) => {  
  const message = snapshot.val();
  displayMessage(message.user, message.text);
});  

The on() method subscribes to the "child_added" event which will fire every time a new message is added to the chatMessages node in the database. The message data is retrieved from the data snapshot and can then be used to update the UI.

In our experience, the performance and reliability of Firebase‘s real-time sync has been consistently excellent. Even with many concurrent users, data updates propagate almost instantaneously. Firebase‘s Realtime Database can handle up to 100,000 concurrent connections per database and scales automatically to accommodate usage spikes.

Easy and Flexible User Authentication

Adding user authentication to an app is often a tedious and error-prone process. You have to set up password hashing, securely store auth tokens, integrate third-party OAuth providers, and manage sensitive user data. Firebase Authentication eliminates almost all of this hassle.

With just a few lines of code, you can add fully-featured sign-in and account management to your app. Firebase supports authentication via email/password, phone numbers, Google Sign-In, Facebook, Twitter, GitHub, and more. It handles all the details of securely storing user credentials and auth tokens.

Here‘s how easy it is to add Google Sign-In to your web app:

const provider = new firebase.auth.GoogleAuthProvider();

firebase.auth().signInWithPopup(provider)
  .then((result) => {
    const user = result.user;
    console.log(`Signed in as ${user.displayName}`);
  })
  .catch((error) => {
    console.error(`Authentication error: ${error.message}`);
  });

After initializing the Google auth provider, we simply call signInWithPopup() which will open the Google sign-in flow in a popup window. Upon successful sign-in, we get access to a user object containing the name, email, and other metadata for the signed-in user. Firebase also provides helpers for common tasks like password resets, email verification, and account linking.

Firebase Authentication currently supports up to 1 million active users for free. For apps with very heavy authentication usage, the paid plan supports up to 10 million users at a price of $0.01 per verification.

Serverless Cloud Functions

For most non-trivial apps, you‘ll inevitably need some server-side logic to prepare and validate data, integrate with third-party services, and augment the client-side experience. Firebase Cloud Functions allow you to extend the platform with custom JavaScript or TypeScript functions that execute in a fully managed Node.js environment.

Cloud Functions can be triggered in response to events in the Realtime Database, Firestore, Authentication, and Storage services. They can also expose HTTP endpoints for handling webhooks and client requests. The functions run in a scalable environment that automatically adjusts compute resources based on demand.

Here‘s an example function that automatically welcomes new users when their account is created:

exports.sendWelcomeEmail = functions.auth.user().onCreate((user) => {
  const email = user.email;
  const displayName = user.displayName;

  return sendEmail(email, ‘Welcome to My App!‘, `Hello ${displayName},\n\nThanks for signing up!`);
});

This function is triggered whenever a new Firebase Authentication user is created. It extracts the user‘s email and name from the user object and sends them a personalized welcome email using a sendEmail helper function (not shown).

With Cloud Functions, you avoid having to maintain your own backend infrastructure, provision servers, or worry about scaling. Firebase handles all the operational overhead so you can focus purely on your app‘s unique logic. The free tier allows up to 125K function invocations per month, with very affordable pricing beyond that ($0.40 per million invocations).

The Meh

Limited Realtime Database Querying

As magical as the Realtime Database is for syncing data across clients, it has some significant shortcomings when it comes to querying and aggregating data. Querying is limited to basic filtering by a child key‘s value, ordering by a child key, and shallow limiting of result sets. There is no support for compound queries, full text search, or relational-style JOIN operations.

To demonstrate, let‘s say we have a blog app with a Realtime Database containing Post and Comment data structured like this:

{
  "posts": {
    "post1": {
      "title": "My First Post",
      "author": "UID1",
      "timestamp": 1502144665,
      "content": "..."
    },
    "post2": { ... }
  },
  "comments": {
    "comment1": {
      "postId": "post1",
      "content": "Great article!",
      "author": "UID2",  
      "timestamp": 1502144665
    },
    "comment2": { ... }
  }
}

If we wanted to fetch the 10 most recent posts along with their associated comments, we‘d have to:

  1. Query the posts node ordered by timestamp and limited to 10 results
  2. Iterate through those posts client-side and make separate queries to fetch the comments for each post
  3. Manually combine the posts and comments into the desired result format

With a SQL database, this could be accomplished with a single query using a JOIN. The lack of server-side JOIN capability in Firebase means that many common data access patterns require extra client-side filtering and aggregation.

Querying Limitations at Scale

Firebase‘s realtime querying limitations become more painful as your data set grows. Queries that don‘t utilize Firebase‘s indexing system will simply stop working after the result set exceeds 100,000 records.

Let‘s say you want to find all posts containing a certain keyword. With the Realtime Database, you‘d have to download all posts to the client and iterate through them to check for the search term. Once your posts node surpasses 100K records, this becomes infeasible both in terms of client resource usage and latency.

Even if you design your data schema carefully to utilize Firebase‘s indexes, there are still scaling challenges. All querying happens client-side, so the entire result set must be downloaded to each client. For large, frequently accessed data sets, this becomes costly in terms of both network and memory usage.

To be fair, Firebase was never designed to be an analytical data warehouse supporting petabyte-scale aggregation workloads. But even for medium-sized production apps, developers may find themselves needing to supplement Firebase with other data stores or caching layers to meet querying needs.

Limited Local Persistence

Firebase makes it easy to persist your app‘s data in the cloud, but support for offline usage and local persistence is somewhat lacking. The officially supported local persistence options are:

  • Disk Persistence: Enabled via firebase.database.setPersistenceEnabled(true). This caches a copy of the Realtime Database‘s data in a local SQLite database on iOS and Android devices. The cache is used when the device is offline, and any local changes are automatically synchronized with the server when a connection is restored.

  • Firebase Offline: An experimental feature of the JavaScript SDK that uses IndexedDB to cache Realtime Database data in web browsers. It allows web apps to read and write from a local cache when offline.

Both of these options have some significant limitations. Disk Persistence is only available in the native mobile SDKs – there is no built-in support for the JavaScript SDK. And Firebase Offline is still experimental and not recommended for production use.

So while Firebase can make your app‘s cloud data available offline to some degree, it falls short of providing a seamless offline experience across all platforms. If your app needs to work reliably in disconnected scenarios, significant extra effort will be required to manage local storage and synchronization.

The Ugly

Data Modeling Difficulty

The Realtime Database‘s NoSQL nature makes modeling and querying relational data challenging. The lack of JOINs means you often need to manually denormalize and duplicate data to efficiently retrieve it.

Let‘s revisit our example of a blog app with Posts and Comments. In a relational database, you would model this as two separate tables with a foreign key relationship:

+-----------------+         +-----------------+
|      posts      |         |    comments     |
+-----------------+         +-----------------+
|  id             |<---+    |  id             |
|  title          |    +---->|  post_id        |
|  content        |         |  content        |
|  author_id      |         |  author_id      |
|  created_at     |         |  created_at     |
+-----------------+         +-----------------+

To fetch a post with its associated comments, you‘d perform a query with a JOIN:

SELECT * 
FROM posts
JOIN comments ON posts.id = comments.post_id
WHERE posts.id = 1234

With Firebase‘s Realtime Database, you‘d need to structure your data like this to allow efficient fetching of a post along with its comments:

{
  "posts": {
    "post1": {
      "title": "My First Post",
      "author": "UID1",
      "timestamp": 1502144665,
      "content": "..."
    }
  },
  "comments": {
    "post1": {
      "comment1": {
        "content": "Great article!",
        "author": "UID2",  
        "timestamp": 1502144665  
      },
      "comment2": { ... }  
    }
  }  
}

Notice how we‘ve flattened the comments to be children of their associated post. This allows us to fetch a post and its comments together with a single query like this:

const postId = "post1";
const postRef = firebase.database().ref(`/posts/${postId}`);
const commentsRef = firebase.database().ref(`/comments/${postId}`);

postRef.once(‘value‘, (postSnap) => {
  const post = postSnap.val();

  commentsRef.once(‘value‘, (commentsSnap) => {
    const comments = commentsSnap.val();

    // Render the post and its comments
  });
});

While this works, it introduces some new challenges. If a post is deleted, its associated comments must also be deleted or they‘ll become orphaned. If a comment is updated, the post must also be updated to trigger any listeners. Managing this data consistency manually is tedious and error-prone.

Denormalizing data in this manner also tends to lead to a lot of duplication. If a user‘s information is associated with posts and comments, it must be duplicated across all those records any time it changes. Over time, your database becomes bloated with hard to manage duplicated data.

What‘s worse is when your data model requires the dreaded… wait for it… nested arrays. Since there is no native array data type in the Realtime Database, arrays are typically stored as objects with numeric keys:

{
  "users": {
    "UID1": {
      "name": "Bob",
      "favoriteMovies": {
        "0": "The Matrix",
        "1": "Inception",
        "2": "Jurassic Park"
      }
    }
  }
}

Now imagine trying to query for all users who have "Inception" in their favorite movies list. Or adding a new favorite movie to a user‘s list without overwriting the existing items. Not pretty.

The Realtime Database‘s lack of support for more advanced data structures like arrays leads to awkward workarounds and makes it difficult to model anything beyond very simple one-to-many relationships. While the flexibility of the NoSQL model is great for rapidly prototyping, it becomes limiting as an app grows in complexity.

Limited Server-Side Functionality

We‘ve seen how Firebase Cloud Functions allow you to extend the platform with server-side logic, but they have some significant limitations compared to a traditional app server:

  • No long-running processes: Each function invocation is limited to 9 minutes of execution time. There is no way to run continous background processes or maintain server-side state between invocations.

  • No direct database access: Cloud Functions can only interact with other Firebase services through their respective client SDKs – there is no server-side API for directly querying the database or making administrative changes.

  • Stateless Functions: Cloud functions are executed in a stateless container that does not persist state between invocations. If your function needs to maintain state, it must use an external storage service like Firebase‘s Realtime Database or Cloud Storage.

  • Limited runtime customization: Cloud Functions run in a sandboxed Node.js environment. You cannot install arbitrary native dependencies, use alternative runtimes, or customize the execution environment.

  • Debugging difficulty: Testing and debugging Cloud Functions can be cumbersome. There is no way to attach a debugger to a running function or get visibility into its execution environment. Logs are available through the Firebase Console or CLI, but they are often delayed and lack detail.

These limitations make Cloud Functions poorly suited for some common server-side tasks like long-running scheduled jobs, complex data pipelines, or CPU-intensive workloads. They are best used for discrete pieces of business logic to augment the client experience, not as a full-fledged app server replacement.

Conclusion

Firebase is a powerful and versatile platform that can dramatically accelerate app development, but it‘s not without its flaws. Its realtime data synchronization and authentication features are top-notch and hard to beat in terms of ease of use and reliability. For apps that primarily need these capabilities, Firebase is a great fit.

However, Firebase‘s limitations start to become apparent as an app grows in complexity and scale. The Realtime Database‘s limited querying capabilities, rigid data model, and lack of server-side functionality can make it cumbersome for advanced use cases. Developers may find themselves bending over backwards to fit their app‘s needs into Firebase‘s opinionated structure.

Ultimately, Firebase is a specialized tool that is incredibly powerful for certain use cases but quickly shows its limitations for others. It‘s important for developers to have a clear understanding of its strengths and weaknesses to use it effectively.

My recommendation is to use Firebase thoughtfully and selectively. Lean into its strengths for features that need realtime sync, authentication, and basic file storage. But don‘t be afraid to use other tools alongside it for parts of your app that have more advanced data modeling or server-side processing needs. By composing Firebase with other best-of-breed services, you can create a robust and flexible architecture that scales with your app.

As a full-stack developer who has seen the good, the bad, and the ugly of Firebase, I can confidently say it has earned a place in my toolbelt. When used judiciously, it still has the ability to wow with its magical simplicity and performance. Just be prepared for a few bumps along the road and don‘t expect it to be a silver bullet for all your app development needs.

Similar Posts