Skip to main content

No More Firebase

·7 mins

Firebase has enabled me to ship secure, performant, and inexpensive projects as a solo developer. However, I’ve since run into its limitations—documented and otherwise—that have made me sour on it for new projects from now on.

Perhaps this is a necessary fact of life when developing software: you come to hate the tools you use once you outgrow them. Ages ago, I used a manually managed MySQL database on AWS with hand-rolled authentication. Then, once I realized how limiting and frustrating it was, I switched to Firebase. After going deep into the Firebase ecosystem, I’m pulling back for a few reasons.

Type-checking and validation #

I use type-checking extensively, nearly 100% of the time. Firebase’s Firestore database makes this challenging due to its lack of a schema. On the one hand, that’s a feature of a NoSQL database. However, it’s definitely a bug when trying to have type safety.

I’ve usually ended up using TypeScript types as a fake schema for the documents in my Firestore database. Something like this:

/** A type to represent the object that Firebase datetimes are displayed as */
export type Timestamp = { seconds: number; nanoseconds: number }

export type User = {
	id: string
	created: Timestamp
	username: string
	...
}

The problem lies in the fact that nothing is enforcing that schema. Furthermore, the schema breaks down frequently. Consider that created field on User. You could set it using new Date(). When writing to the database, Firestore happily converts it into its internal { seconds: number; nanoseconds: number } representation, which I’ve represented as Timestamp. However, Date is certainly not compatible with that type, so TypeScript complains. If I make created of type Date | Timestamp, it’s unclear to the type checker whether created is coming from Firestore and thus a Timestamp or if I set it directly.

To solve this, I use generic types like User<Date>as a hack to get TypeScript to infer the field’s type correctly. There’s still nothing enforcing it other than my careful programming. The whole point of the type system is to prevent the programmer from having to keep the types in their head correctly.

Similarly, reading from the database doesn’t tell you anything about the type. Visually, I can guess that doc("users/abcd5423").get() is going to return a User document. However:

  1. That is not guaranteed
  2. The type system doesn’t know that

Since there’s no validation on the schema, doc("users/abcd5423") might return something entirely different. It’s up to me, the programmer, to ensure I don’t mess up. This problem would be a nonissue if I used a traditional SQL-based database. The table would guarantee the types and fields. Tools such as Prisma can even generate the TypeScript types so that there’s a single source of truth: the database itself.

Firebase doesn’t provide any validation beyond rudimentary and hard-to-test security rules. That means all the validation logic has to be in the client (unsafe but acceptable when user-written content is never shared with another user) or in the server. If you’re deep in Firebase’s ecosystem, you’re using serverless functions to perform your writes. At that point, you might as well make a real REST or GraphQL server to sit between you and the database.

A Firebase corollary of Greenspun’s tenth rule of programming might be:

Any sufficiently complex Firebase project contains an ad hoc, informally specified, bug-ridden, slow implementation of half of an application-tier server

Imperfect local emulation #

Not every scenario allows development using the productions tools. The Lunar Landing Training Vehicle was a jet-powered VTOL aircraft that the astronauts used to practice landing on the Moon. The lunar module couldn’t work on Earth, so NASA had to simulate the controls and reduced gravity using a completely separate aircraft.

Building a web app is not landing on the Moon. You shouldn’t need an imperfect emulator to run your infrastructure locally. With Firebase, you do. For example, you can’t run extensions. I’ve also found numerous cases of different behavior between emulation and production. For example, there still is no way to simulate a scheduled function in the emulator. More perniciously, there seem to be slight differences between rule validation behavior in the storage system that I still haven’t figured out.

Whatever backend I use next is going to be open source and able to be run locally. I don’t want to practice landing in Houston. I want to practice landing on the Moon.

Aggregation is a nightmare #

Another pain point in Firebase is its persistent inability to perform any aggregation. Counting the number of documents in a collection requires a read for every document. That means you’re either stuck using a distributed counter if you have a large-scale collection or performing transactions. Since the distributed counter can’t be run in the emulator, you have to suck it up or figure out another way.

The solution to counting is to keep a running count. However, as with regular expressions, we now have two problems. We need to have aggregation logic somewhere, and we need to keep a count of the documents. Any time we modify the count, we’ll have to keep the counter implementation in mind. Deleting a document will have to decrement the counter. If you want to ensure that you remember to do this, you make a deleteRating function that centralizes the deletion logic. Since both users and moderators might delete the comment and you can’t trust clients, this has to be a cloud function that they call directly.

As a result, Firebase saved you no complexity. You ended up with a pseudo application layer performing the database writes when inserting, updating, and deleting documents. Pair that with the cold-start time for functions; now you’ve got a slow and complex app. As I said:

Any sufficiently complex Firebase project contains an ad hoc, informally specified, bug-ridden, slow implementation of half of an application-tier server

Functions are limited #

If you are operating on a small scale (i.e. Firebase’s target market), it’s unlikely your inevitably numerous functions will be kept warm. Yes, you’ve saved yourself the hassle of running servers. Now, every interaction requiring a function will be slow for the users as the functions warm up. The solution is to specify a minimum number of instances:

functions
	.runWith({
		minInstances: 1,
	})
	.https.onCall(async (data, context) => {...})

However, you’ve got to pay for every function you keep warm. If you have many backend functions for user interactions, you’re either paying to keep each function warm or unifying them into one huge function that you keep warm:

export const rpc = functions
	.runWith({
		minInstances: 1,
	})
	.https.onCall(async (data, context) => {

	if (!data.fn || !(typeof data.fn === 'string')) {
		throw new functions.https.HttpsError(
			'invalid-argument',
			'The function must be called with a "fn" argument.'
		)
	}

	switch (data.fn) {
		case 'setUsername':
			return await (await import('./onboard')).setUsername(data, context)
		case 'addRating':
			return await (await import('./ratings')).addRating(data, context)
		...
	})

At this point, you might as well use a single serverless function as a GraphQL endpoint, keep that warm, and skip the hacky-ness of my implementation. Again:

Any sufficiently complex Firebase project contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of a application-tier server

Finally, you can’t build Firebase functions in other languages and deploy them as part of the same flow as your normal functions. Even though Firebase has clients in Python and Go, I can’t use them unless I want to deploy them using the separate Google Cloud CLI manually. There goes the whole unified deployment benefit.

What I’ll use next #

Currently, I’m considering using Supabase (an open-source, Postgres-backed Firebase alternative) with Prisma as a type-safe ORM. I’m also interested in trying out GraphQL and, for other reasons, React. Putting that all together, I plan to use a framework such as RedwoodJS since it automatically puts all the puzzle pieces together. I’ve been impressed with RedwoodJS when building two pre-production apps, so stay tuned for more Redwood news.