Serverless vs Client-ish

Serverless, with all the attendant ambiguity on what that means, tends to be discussed from the perspective of people who build services. This often leads to a focus on the differences between serverless and previous generations of infrastructure. Specifically, we talk about how the provisioning, balancing and so on have been made utility, as compute, storage, hardware and bandwidth had been in previous generations of technology.

The specific flavour of serverless which I’ve come to appreciate in the Firebase products during the time I’ve been working on them feels slightly different. In fact, I think the focus on the “server” in the term serverless is wrong here — the approach in Firebase is, if anything, more “client-ish”.

This world derives from the approach of people building rich clients, usually for mobile devices. The aim is to drive from the service delivered to the user on down — starting with the user value, and ending with the system required to enable that.

In this model external services should be low-visibility, and trusted environments should be reserved for maintaining system-specific invariants.

Low-visibility services

I say low-visibility rather than invisible as it is important to be aware of the underlying infrastructure at a certain level of functionality. Thinking of the network on your phone, you want to know “am I connected” but probably not “which provider am I on”.

The Realtime Database is one of the more confusingly named parts of Firebase, as it tends to put people in mind of a SQL service, or even a more traditional NoSQL database, with attendant RPC-esque query-response. The RTDB is more like a live data structure — a tree that can you can write to and read to locally.

This is most fully realised in our Android and iOS environments, where the offline support effectively means you can just treat the database as an on-device JSON tree. The approach is simply listening — exactly as you’d set a value observer on some local structure. That’s the visible part. The mechanism of how those updates make it to the device is intended to be something you can just assume — as in it becomes irrelevant whether they’re local, or remote. This requires a different mental model!

The approach to users in Firebase moves in a similar direction, with similar rethinking required. Rather than a traditional “users” table, we let the developer plug in most any identity provider (e.g. Google, Facebook, Twitter, their own) and then have that generate a verifiable identifier that can be used throughout Firebase.

This separates the concern of “who is this” from the problem of getting an ID. We handle providing a unique identifier, but access to the users identity is handled by a system best suited to it.

Similarly, the approach to tuning your user experience for a specific user is handled through logging of Google Analytics (For Firebase) events. In the console you use those to create an “audience” — defining a group of people based on their behavior, not their identities. This then allows you tweak the parameters of your app through an app configuration management service called Remote Config.

Remote Config in this case is “low-visibility” — you have to visibly use it in your code, but the precise choice of which option is delivered is a property of the running system. For example: “users who buy IAPs should never see ads”.

System specific invariants

The previous example crosses the transition line between the two elements of the approach, the services and the invariants:

  • Analytics: you just log events, without being concerned about how they are delivered and stored.
  • Audience builder: You define a group based on events logged, without a reference to how they arrived.
  • Remote Config: You target variant settings in your app based on those audiences, but never need to explicitly assign a user to one group or another.

Several of the services in Firebase lean towards defining these system properties. The most obvious, and one that causes many of the problems for developers new to Firebase, are the rules.

It’s easy to think of rules like ACLs — user X can do Y. But it’s more helpful to think of them as enforced truths: all users can do Y to a path including their user ID.

Thinking of them as ACLs also encourages thinking of data validation rules as something separate. This is not at all helped by the fact that the rules language has a “validate” method to actually allow you to set rules on the structure of the data being written. As an example:

{
"rules": {
"users": {
"$uid": {
".write": "$uid === auth.uid"
"timestamp": { ".validate": "newData.val() <= now" },
}
}
}

If you think of these rules as system invariants though, the distinction goes away: the contents of the /users/<uid> path must be a historical record of action by the user identified by the UID.

This distinction is totally collapsed in the newer rules system, as seen in Cloud Storage for Firebase. Here you can determine read or write ability based on the auth state or the properties of the content:

match /users/{userId}/profilePicture.png {
allow read: if resource.size < 100 * 1024;
allow write: if request.auth.uid == userId;
}

Despite this flexibility, not everything can be expressed as a rule. You’re going to need to create a more dynamic method of maintaining the properties of your system. That is where Cloud Functions come in.

From the serverless perspective, these are a transparently managed execution environment. From the client-ish perspective these are dynamic methods that ensure system properties hold.

That means function execution must be driven by events. While this can be an HTTPS request, it may also be an Analytics event, a Database write, a Storage upload, or other signals. It also strongly implies serverless-world requirements like scale-to-zero: If the system is not changing, why would any time be needed to maintain it?

As an example, let’s take the most popular Cloud Function deployed at the moment: resizing images to make thumbnails. This is a classic backend task, but viewing it in the invariant way sets some clear boundaries on how we should approach this.

This is actually pretty well covered in the code in the functions-sample repo. The invariant we’d like to maintain is that all images have a full and lower resolution copy. The validation in the function looks for that — if there already is a thumbnail, there is nothing to do. If there is no new image, there is nothing to do. If there is an image, but not thumbnail, we should create and add the thumbnail — and that’s it.

Any further action as then we would be coupling our concerns, and writing an application, rather than simply maintaining our identified system properties.

Going client-ish

Though I’ve been working with mobile for many years, I still have many more years where I was building from the perspective of services (including the dark lands of server-side generated markup). When we approach a rich native or browser application it’s easy to build as if we were still thinking about doing the work on the server and sending things over the wire.

Unlearning those habits and instincts is hard. The approach Firebase has taken isn’t a perfect realisation of some ordained architecture either — it’s a set of products built on real world services drawing on real world experiences, so there are rough edges.

Overall though, the advent of serverless compute, websockets (as well as other bidi streaming), and a broad ecosystem of services is a potent combination. Getting the most out of it requires a degree of experimentation to find the right patterns, and requires working out which of the old patterns should be dropped, and which maintained.