Overview

Firebase allows developers to create a fully featured backend on top of servers and APIs operated by Google.

overall benefits

  • solid developer experience
  • it scales to world-scale use
  • generous free-tier and pay-as-you-go pricing
  • high quality docs, well supported by AI models
  • actively developed and maintained

main backend components covered in this document

  • authentication with Firebase Auth
  • database with Cloud Firestore
  • storage with Cloud Storage
  • serverless functions with Cloud Functions

focus of this document: web-centric

We create a backend for web-apps, and use the web-centric client SDKs. We default to TypeScript, and pick Node.js as the runtime for Cloud Functions.

CLI tool

The Firebase CLI tool enables several workflows:

  • Emulate the Firebase backend locally, to run it and debug it at no cost and without a deploy step.
  • Scaffold the Cloud Functions' directory, and deploy Cloud Functions.
  • Submit secrets and API keys to Google, making them available to Cloud Functions.
  • Add and deploy security rules.
  • List the Firebase projects linked to the Google account.

the CLI executable

The firebase-tools package installs the firebase CLI executable.

npm install -g firebase-tools
firebase

Release notes

underlying Google account

Firebase projects are linked to a Google account.

firebase login:list # prints current Google account
firebase login
firebase logout

list projects and select one

firebase projects:list
firebase use imagetales

project configuration and scaffolding

The init command enables several workflows. We can:

  • scaffold the Cloud Functions directory
  • set up and configure emulators
  • add security rules for Firestore and Cloud Storage
firebase init

help

  • print the list of Firebase commands.
  • print the details about a given command.
firebase help

firebase help emulators:start
firebase help deploy

list deployed functions, deploy functions

firebase functions:list

firebase deploy --only functions
firebase deploy --only functions:requestPlanet

manage secrets

firebase functions:secrets:access ABC_API_KEY
firebase functions:secrets:set ABC_API_KEY
firebase functions:secrets:destroy ABC_API_KEY

start and config emulators

firebase emulators:start
firebase emulators:start --import emulator-data --export-on-exit

We set which emulators to run with firebase.json. We scaffold this file with firebase init. If the port is omitted, the emulator uses the default one.

{
    "emulators": {
        "firestore": { "port": 8080 },
        "auth": { "port": 9099 },
        "functions": { "port": 5001 },
        "storage": { "port": 9199 },
        "ui": { "enabled": true }
    }
}

manage and deploy security rules

The storage emulator requires storage access rules.

  • We define Storage rules in storage.rules.
  • We define Firestore rules in firestore.rules
  • We refer to the rules in firebase.json:
{
    "storage": { "rules": "storage.rules" },
    "firestore": {
        "rules": "firestore.rules",
        "indexes": "firestore.indexes.json"
    }
}

We deploy the rules:

firebase deploy --only storage
firebase deploy --only firestore:rules

gcloud: Google Cloud CLI tool

gcloud enables some operations not available with the firebase tool, such as listing secrets of a given project or describing a Storage bucket.

We call gcloud from the Google Cloud Console's Cloud Shell (it is pre-installed), or we install it locally from an archive provided by Google.

gcloud secrets list --project <PROJECT_ID>
gcloud storage buckets describe gs://abcd.firebasestorage.app

SDKs

We interact with the backend with the help of SDKs. In this book, we describe the JavaScript SDKs.

client SDKs

The client SDKs run on unprivileged clients, such as browsers. They can also run in Node.js apps that want to run unprivileged.

The client SDKs live in the firebase package:

npm i firebase

admin SDK: privileged environments

The admin SDK is designed to run on secure environments as it relies on privileged service accounts.

The admin SDK authenticates itself against Google servers using a privileged account called a service account. Service accounts are managed by Google, scoped to a Firebase project and have specific entitlements. Such accounts are not subject to security rules and are already authenticated.

As Cloud Functions run on Google servers pre-configured with appropriate service accounts, it is fitting and convenient to use the admin-SDK. The firebase-admin package is designed to run on a Node.js server:

npm i firebase-admin

Cloud Functions SDK

We define Cloud Functions with the (Node.js) Cloud Functions SDK.

We have the package listed as a dependency after scaffolding the Cloud Functions directory with firebase init.

"firebase-functions": "^7.0.0",

Project setup and initialization

identify the Firebase project (client SDK)

The config object stores credentials to identify the Firebase project when interacting with Google servers:

const firebaseConfig = {
    apiKey: "....",
    authDomain: ".....firebaseapp.com",
    projectId: "....",
    storageBucket: ".....firebasestorage.app",
    messagingSenderId: "....",
    appId: "....",
}

register one or more configs

We register the config (client SDK) and keep a reference to the app helper , which we initialize other services with:

const app = initializeApp(firebaseConfig)

When working with several Firebase projects, we get a helper for each. The first helper has a "[DEFAULT]" internal string identifier. We provide string identifiers for the additional project we want to work with.

const app1 = initializeApp(firebaseConfig1)
const app2 = initializeApp(firebaseConfig2, "bar")

When initializing the admin SDK on Google servers that run Cloud Functions, the admin SDK finds the credentials on its own. We initialize it without a config:

const app = initializeApp()

Auth Overview

authenticate app users

The Auth client SDK authenticates users, and notifies the app about Auth events. It provides several authentication flows.

the auth helper and reading currentUser

We keep a reference to the auth helper to read currentUser. We also use it in auth related functions.

const auth = getAuth(app)
auth.currentUser // User | null

currentUser starts as null. When the SDK has finished loading, and given that the user has logged in, currentUser switches to a User instance.

As a User instance, It exposes the user identifier (uid). The other properties are optional:

currentUser.uid
currentUser.email
currentUser.phoneNumber
currentUser.displayName
currentUser.isAnonymous

react to authentication events

We register a callback on onAuthStateChanged, which Firebase runs on auth events. Firebase gives us a user object (of type User | null).

onAuthStateChanged(auth, (user) => {
    if (user) {
        // user.uid
    }
})

Auth events:

  • the auth SDK has finished loading and no user is authenticated

  • the user has registered (sign up)

  • the user has logged in (sign in)

  • the user has logged out (sign out)

Login occurs in three specific scenarios:

  • the user fills out the standard login form or logs in through an identity provider (hard-login)
  • the user is recognized by the SDK and is logged in automatically (credentials stored in browser)
  • (canonically a registration) the user is automatically logged in after a successful sign-up. Note: a single authentication event occurs.

React patterns

We make the authentication status part of the React state. For example, we work with a isSignedIn variable. We make the display of the authenticated area conditional on isSignedIn being true.

On page load, the Auth SDK is loading: It's best to wait for the SDK to load before making any use of isSignedIn, given that the state should be indeterminate.

As such, we track the loading state in a one-off state variable, which becomes true on the first authentication event. Only then do we set isSignedIn with correct state and make use of it.

If we were to read isSignedIn before load, and because we initialize it to false for simplicity, we would have a rapid UI flicker when it switches from false to true if applicable.

const [hasLoaded, setHasLoaded] = useState(false)
const [isSignedIn, setisSignedIn] = useState(false) // the initial state shouldn't be relied upon.

useEffect(() => {
    const unsub = onAuthStateChanged(auth, (user) => {
        setHasLoaded(true)
        setisSignedIn(Boolean(user)) // accurate state
    })
    return unsub
}, []) // subscribe once, unsubscribe on unmount.

if (!hasLoaded) return null
if (!isSignedIn) return <Lobby />
return <Ingame />

sign out

sign out is consistent across all authentication flows:

signOut(auth)

Email-Password accounts

A provider that relies on collecting the user's email and password.

registration and hard-login

On success, we receive a credential of type UserCredential.

register:

createUserWithEmailAndPassword(auth, email, password).then((credential) => {
    credential.user // User
})

hard login:

signInWithEmailAndPassword(auth, email, password).then((credential) => {
    credential.user // User
})

send a password reset email

We ask Firebase to send a password-reset email to the provided email. We can customize the email content through the Firebase console:

sendPasswordResetEmail(auth, email)

email account's providerData (implementation detail)

The user object of type User has a providerData field with password as the providerId value:

{
    "providerData": [
        {
            "providerId": "password",
            "uid": "user@example.com",
            "email": "user@example.com",
            "displayName": null,
            "phoneNumber": null,
            "photoURL": null
        }
    ]
}

Identity Providers

We allow users to authenticate with external provider accounts, such as with their Google account or Apple account.

select one or several providers

Note: We enable providers in the Firebase console.

const gProvider = new GoogleAuthProvider() // Google account provider

authentication flows

Alternative flows:

  • the user authenticates through a popup window.
  • the user authenticates through a redirect.

A flow is initiated through a user action, such as tapping a button. Both flows handle sign-in and sign-up through a single, undiscriminated action. As such, we use generic labels, such as:

  • Continue with Google
  • Authenticate with Google

On success, a flow triggers an authentication event. The flow functions return a credential object (UserCredential), that exposes the user object:

const credential = await signInWithPopup(auth, gProvider)
credential.user // User

Note: We can detect it is a new user through a helper method:

const userInfo = getAdditionalUserInfo(credential)
if (userInfo?.isNewUser) {
}

popup flow specifics

The popup flow may fail if the browser doesn't allow popups.

const credential = await signInWithPopup(auth, gProvider)

redirect flow

The redirect flow relies on navigating to another page and navigating back.

It requires extra work unless the website is hosted on Firebase Hosting.

Anonymous accounts

Register an account with no personal information from the user:

signInAnonymously(auth)

The generated credentials are stored in the browser: users cannot access their account from other devices, and cannot recover it if they lose the credentials (browser wipe).

Auth-triggered Cloud Functions only partially support anonymous accounts:

  • The v1 auth functions such as onCreate() correctly trigger.
  • The v2 auth blocking functions such as beforeUserCreated() don't trigger (as of writing)

check if the account is anonymous

On the client, we check the isAnonymous prop from currentUser:

auth.currentUser?.isAnonymous

In auth-triggered Cloud Functions, we read providerData (from the UserRecord which resembles the client SDK's User).

export const onRegisterNonBlocking = auth.user().onCreate(async (userRecord) => {
    userRecord.providerData.length === 0 // empty array for anonymous accounts
})

convert to a non-anonymous account (client SDK)

We link the account to another provider. Since the user already exists (auth.currentUser), we provide it to the linking function. We link to an email provider or to an identity provider:

// email
const emailCred = EmailAuthProvider.credential(email, password)
await linkWithCredential(auth.currentUser, emailCred)

// Google account with popup
const gProvider = new GoogleAuthProvider()
await linkWithPopup(auth.currentUser, gProvider)

Manage users

We can manage users in the Firebase console or programmatically via the admin-SDK:

import { getAuth } from "firebase-admin/auth"
const auth = getAuth()

list users

listUsers() fetches at most 1000 users at once. If we have more users, we use pagination.

const result = await auth.listUsers() // implied 1000 max
const users = result.users

users.forEach((user) => {
    user // UserRecord

    user.uid
    user.email

    // HTTP-date string (RFC 1123)
    user.metadata.creationTime // "Tue, 13 Jun 2023 17:00:00 GMT"
    user.metadata.lastSignInTime // "Wed, 14 Jun 2023 17:00:00 GMT"
})

Firestore

conceptual

Firestore is a schema-less database made of collections and documents of loose shape (NoSQL), most similar to MongoDB.

  • A collection is a set of documents.
  • A document is a set of fields holding primitive data types (number, string, timestamps...) or object types (maps and arrays). A document has up to 20k fields and stores up to 1 MiB of data.
  • A reference serves to refer to a collection or to a document. It doesn't guarantee the collection or document's existence in the database: It's merely a path that points to a location.

import paths and documentation

We interact with the database with the client SDK or with the admin SDK. The admin SDK's firestore sub-package is a wrapper around a google-cloud package: It has the same syntax and capabilities.

"firebase/firestore" // client SDK
"firebase/firestore/lite" // client SDK

"firebase-admin/firestore" // admin SDK

helper object

We init a db object, for use in Firestore-related functions.

// const app = initializeApp()
const db = getFirestore(app)

Collection

Collection Reference

use the collection reference

We use a collection reference when an individual document reference is not needed or not yet available:

  • fetch all documents (it acts as a query): getDocs(colRef)

  • build a query (targeting the collection): query(colRef, filters..)

  • build a random-ID document reference: doc(colRef), or one that refers to a specific document: doc(colRef, docId)

  • add a random-ID document to the collection in a single step: addDoc(colRef, data).

build a collection reference

We use the path that identifies the collection uniquely. Root collections have the simplest path: the collection name, such as "users" (no starting slash). Sub-collections' paths are built from several components.

We set the path as:

  • a string, with slash separators.

  • a sequence of strings, with no slash separators.

const colRef = collection(db, "users")
const colRef = collection(db, `users/${uid}/custom_list`)
const colRef = collection(db, "users", uid, "custom_list")
const colRef = db.collection(`users/${uid}/custom_list`) // admin SDK

TypeScript: a collection's documents as DocumentData

Collections are schema-less: they don't define the shape of their documents.

When receiving data from the database, the client SDK instantiates documents with no regard to the content: documents are of any shape and may differ from one another. It types them as DocumentData, which doesn't provide information about the content.

TypeScript: annotate the document's type at the collection level.

We provide a more precise type at the collection reference level. The simplest way to do it is through a type assertion:

const colRef = collection(db, "players") as CollectionReference<Player, Player>

Converter (optional)

the case for a converter

The SDK supports working with two document shapes on the client:

CollectionReference<AppModel, DbModel>

DbModel represents the object that the SDK instantiates from the raw data. It is DocumentData by default.

We can add a converter to transform it to a different shape for use in the app.

AppModel represents the object after the converter's transformation. It also defaults to DocumentData. We set it to whatever type the converter converts to.

Before sending to Firestore, the converter transforms back AppModel to DbModel.

Transformation examples:

  • We transform the DbModel's Timestamp field to an AppModel Date field.
  • We add properties to AppModel.

implement the converter

We transform the documents at the app boundaries:

  • upon receiving from Firestore (fromFirestore())
  • upon sending to Firestore (toFirestore())

We define the functions and add them to the converter.

fromFirestore() takes the snapshot as instantiated:

fromFirestore(snapshot: QueryDocumentSnapshot<FirestoreWorkout>): Workout{
		// to client shape
		// FirestoreWorkout -> Workout
		const firestoreItem = snapshot.data()
		const workout = { ...firestoreItem, date: firestoreItem.date.toDate()}
     return workout
    }

toFirestore() takes the object in its AppModel shape.

toFirestore(workout: Workout) {
		// to database shape
  	// Workout -> FirestoreWorkout
        	return { ...workout, date: Timestamp.fromDate(workout.date)}
    }

We gather the transforms in the converter (FirestoreDataConverter). While the type are inferred from the transforms, we can still add them at the converter level:

// FirestoreDataConverter<AppModel, DbModel>
const myConverter: FirestoreDataConverter<Workout, FirestoreWorkout> = {
    toFirestore() {},
    fromFirestore() {},
}

We attach it to the collection reference to let it type its documents.

const colRef = collection(db, "players").withConverter(conv)

Document

Document reference

The document reference identifies a document within the database, and embeds meta information:

docRef.id // "Nk....WQ"
docRef.path // "users/Nk....WQ"
docRef.parent // colRef

use the document reference

We use the reference for most CRUD operations:

  • read the document: getDoc

  • update an existing document (it errors if the document doesn't exist): updateDoc

  • delete the document: deleteDoc

  • create the document, or override an existing one (upsert): setDoc

Note: addDoc() works without a document reference since it generates a new one on the fly.

build a document reference

The document's path identifies it uniquely. We set the path as a single string or build it from string components:

const docRef = doc(db, "users", id)
const docRef = doc(db, "users/Nk....WQ")

// const docRef = collectionRef.doc("NkJz11WQ") // admin sdk

Alternatively, we provide the collectionRef and the document ID, or only the collectionRef. In the latter case, the SDK generates a random ID.

const docRef = doc(collectionRef, id)
const docRef = doc(collectionRef) // random ID

read document at reference (get)

The get operation succeeds even when no document exists. We receive a Document snapshot which may be empty.

As such, checking the document existence requires to read inside the Document snapshot.

getDoc(docRef) // DocumentSnapshot
// docRef.get() // DocumentSnapshot

Document snapshot

The Document snapshot is a wrapper that doesn't guarantee the document existence. It exposes the document (or its absence) via a getter. The document's type is DocumentData or the more specific type we provided. It can also be undefined, unless it comes from a query (see Query)

Note: data() is a function because it accepts some configuration.

docSnapshot.exists()
docSnapshot.data() // DocumentData | undefined

It also exposes helpers and metadata.

docSnapshot.id // NkJ...7f
docSnapshot.ref // DocumentReference
docSnapshot.metadata // SnapshotMetadata

Query a specific field

docSnapshot.get("address.zipCode") // low use

real-time listener on a single document

Set up a real-time listener on a (single) document reference:

const unsub = onSnapshot(docRef, (docSnapshot) => {
    docSnapshot.data() // DocumentData | undefined
})

Query

overview

A query matches documents based on a set of criteria, and not based on a set of document references.

the result of a query: a query snapshot

The snapshot hosts a list of document snapshots (docs). The list is empty when no match occurs.

Otherwise, it contains only non empty snapshots, of type QueryDocumentSnapshot, which is DocumentSnapshot except that data() cannot be undefined.

querySnapshot.docs // list of document snapshots (QueryDocumentSnapshot)
querySnapshot.empty

docSnapshot.data() // DocumentData (not undefined)
const cats = querySnapshot.docs.map((docSnap) => docSnap.data())

a collection reference is a query

A collection ref can serve as a query, the one that targets all documents (get):

getDocs(colRef)
// getDocs(q)

colRef.get()
// q.get()

build a query

We always provide the collection reference. Then, we can:

  • add value-based filters
  • set the order
  • limit the count
const q = query(colRef, where(..), where(..), orderBy(..), limit(..))
// const q = collection(..).where(..).orderBy(..).limit(..)

where filter: look for documents with a given value

We filter documents based on a value we want to find in a property. We request an exact value or one within a range. Depending on the data, we expect a single match or several.

Note: documents that do not possess the property at all are filtered out.

For example, we look for a document whose id is user.id:

where("id", "==", user.id)
// where(propertyName, operator, value)

We set the requirement for the value: exact match, being different, being smaller or larger, exact match with at least one value, or different from all values.

==
!=

<
<=
>
>=

"in" // the property is equal to either A, B or C
"not-in" // the property is different from A, B and C.

We can also ask the value to be included or excluded from the array if the property is an array.

"array-contains" // the array contains the value
"array-contains-any" // the array contains A, B or C..

order documents based on a field

We order documents based on (the value of) a field, in the ascending or descending order. If omitted, the order defaults to ascending.

orderBy(propertyName, orderDirection)
orderBy("postCount", "asc")
orderBy("postCount", "desc")

We can start from a given value, e.g. documents that have at least (or more than) 10 posts.

startAt(10)
startAfter(10)

pagination: cap the read, then read the next page

Get at most n documents:

limit(20)

To get the next page, we provide a cutoff document (snapshot), stored from the current batch: we then receive the document snapshots that starts after it:

query(colRef, startAfter(docSnapshot), limit(20))

run the query (get)

one time fetch

const qs = getDocs(query)
const qs = query.get()

real-time listener

Set up a real-time listener on the query: we receive a query snapshot:

const unsub = onSnapshot(query, (qs) => {
    const documents = qs.docs.map((docSnapshot) => docSnapshot.data())
    setMessages(documents)
})

Create and update documents

targeted, strict document creation

On the admin SDK, we can perform strict targeted document creations, aka create a document with a controlled ID and expect a failure if the document already exists:

docRef.create(data)

The client SDK doesn't offer an equivalent function. Instead, we can do a two-step transaction where we read for document existence then write the document conditionally.

random-ID document creation (add)

The client SDK prefers random-ID creation that always succeed because the document won't already exist by design (add):

addDoc(collectionRef, data)
// db.collection("message").add(data)

upsert (set)

An upsert works regardless if a document exists or not. It is destructive, as it override any existing document.

setDoc(docRef, data)
// docRef.set(data)

update documents

We assume the document already exists: we use the update pattern or the set with merge pattern.

The update function is a strict update: it correctly fails if the document doesn't exist.

Both update and set merge expect a change object. We type the change as a Partial or as a Pick of the document:

const change: Partial<User> = { displayName: "Johnny Appleseed" }
updateDoc(docRef, change)
// docRef.update(change)

update uses the provided fields to replace the existing ones, the other fields being left unchanged.

To mutate a single field in an object field, we target it with dot notation. If we target the object instead, the omitted sub-fields are deleted (different from set with merge)

// sub-field
const change: Partial<User> = { "address.city": "Lyon" }
updateDoc(docRef, change)

If TypeScript complains about the dot notation, we use the FieldPath overload:

updateDoc(docRef, new FieldPath("address", "city"), "Lyon")

partial update with set and merge

the merge option changes the meaning of set: we are now providing a change object, not the new object.

What we target in the change object is what gets changed, including sub-fields. The omitted sub-fields are preserved (deep merge): there is no need for dot notation:

const change = { address: { city: "Lyon" } } // only changes city in address

setDoc(docRef, change, { merge: true })
// docRef.set(data, { merge: true })

relative change (increment, decrement)

We ask the server to change the field by n, which can be positive or negative. The current value is unknown:

const change = {
    activityScore: increment(1), // or e.g. -1
}

// docRef.update({
//    count: FieldValue.increment(1),
// })

delete field

We ask the server to delete a field. This shortcuts the need to fetch the document first and store it second omitting the given field:

updateDoc(docRef, {
    fleet: deleteField(),
})

// docRef.update({
//    fleet: FieldValue.delete(),
// })

server timestamp field

Ask the server to generate a Firestore timestamp value.

updateDoc(docRef, {
    count: serverTimestamp(),
})

// docRef.update({
//    count: FieldValue.serverTimestamp(),
// })

delete document

deleteDoc(docRef)
// docRef.delete()

Batch writes

We gather writes in a batch object and ask Firebase to perform (commit) all the writes at once.

The operation is atomic: if one write fails, the others fail as well. This prevents a broken state where only some documents are updated.

A single network request is sent.

Note: the functions are namespaced on batch both for the client SDK and the admin SDK.

batch changes then commit

Collect up to 500 writes in a batch object, and execute them with commit().

In this example, we use update(), but we could also use set() or other methods.

const batch = writeBatch(db)
// const batch = db.batch() // admin SDK

const change = { timezone: "Europe/London" }

batch.update(docRef1, change)
batch.update(docRef2, change)
// ..
// ..

await batch.commit()

other batch operations

batch.set(docRef, data)
batch.set(docRef, change, { merge: true })
batch.update(docRef, change)
batch.delete(docRef)
batch.create(docRef, data) // Admin SDK

Transactions

Read and write atomically with transactions

conceptual

Transactions are for operations where a preliminary read is required to determine the applicability, nature and extent of a tentative write.

A naive read and write is subject to desync: by the time the write order hits the database, the motivating condition may have changed: the data can change between the database read and the database write, as simple reads have no lock effect.

A transaction is a guarantee that by the time the database commits the write, the data hasn't changed since the initial read, so that the check that was performed on the data is still relevant.

For example, if credits is positive and sufficient, the condition is fulfilled, but by the time we are about to commit the purchase, we want credits not to have changed since the read.

implementation

The simplest way to guarantee it is to lock the document between the read and the write. The Admin SDK locks the document during the read to write time-window.

The client SDK doesn't lock the document, because the time between the read and write orders can become overly long and degrade the UX for the rest of users. Instead:

  • the SDK is aware of the document's version (by the time it was read), and asks the database to only perform the write if the document is still of this version (possibly tracked with an updateTime flag or equivalent).
  • The database allows changes initiated by other operations, if any.
  • On receiving the conditional write order, the database enforces the condition: it proceeds only if the document hasn't changed. Otherwise, it rejects the transaction: it is up to the client SDK to attempt a new transaction. The client SDK does retry by default, up to 5 total attempts. (retry strategy).

This pattern is called emulated optimistic concurrency.

See also: Transaction serializability and isolation (Firestore).

the runTransaction function

runTransaction expects a callback. transaction is a helper that holds the read and write methods (get, update, set).

Note that we await reads, but don't await writes, since writes are grouped up on the transaction object and sent all at once.

In case of failed preconditions, we abort the transaction with a throw:

await runTransaction(db, async (transaction) => {
    // read
    const snapshot = await transaction.get(docRef)

    // check condition
    if (!snapshot.data()) throw Error("No such event!")
    const count = snapshot.data().count
    if (count >= 10) throw Error("Sorry, event is full!") // Abort

    // proceed
    transaction.update(docRef, { count: count + 1 })
})

// admin SDK
// await db.runTransaction(async (transaction) => {
// identical API
// })

Timestamp value type (advanced)

Note: storing dates as ISO strings is simpler and more portable.

As the Firestore database comes with a native value type for storing dates called timestamp, we describe using this pattern in this article. The Firestore SDK comes with a Timestamp type that represents a timestamp field.

storing timestamps

As we attempt to store data, the SDK detects Date and Timestamp fields and assumes we want to store them as timestamps.

const user = {
    createdAt: new Date(),
    createdAt_: Timestamp.now(),
}

When preparing data for the HTTP request, the SDK serializes Date objects and Timestamp objects to plain objects with a single timestampValue property:

{
  "createdAt": { "timestampValue": "2025-10-07T18:47:13.279000000Z" },
  "createdAt_": { "timestampValue": "2025-10-07T18:47:13.279000000Z" }
},

The database detects this pattern and sets the fields as timestamps.

receiving timestamps

The SDK's Timestamp type represents database timestamps. As we receive timestamp fields from the database, the Firestore SDK instantiates them as Timestamp objects.

Firestore Security rules

We define the security rules in the Firebase console or in a firestore.rules file, referenced by firebase.json. Firebase doesn't bill reads and writes denied by security rules.

rules version

rules_version = "2"

firestore scope

We start by scoping the rules to cloud.firestore

service cloud.firestore {
    // ...
    }

database scope

We scope the rules to the current database. The security rules only affect the current database, so this can be seen as superfluous. But we use the wildcard when querying separate documents (see below).

match /databases/{database}/documents {
    // ...
}

set rules for a given collection

We target a collection. The document ID wildcard holds the requested document's ID. We can name it with what the document represents:

match /users/{user_id} { // document ID wildcard
    	// ...
}

operations and condition

allow operation, operation: if condition;

operations

read
create
update
delete

authentication, user ID

If the user is not authenticated, request.auth is null. We can filter out unauthenticated users:

allow read: if request.auth != null;

The user's authentication uid (if logged in) is available as request.auth.uid:

request.auth.uid

Note: if auth is null, trying to read uid triggers a failsafe mechanism that denies the request.

authorize specific documents

Document ID based authorization: We authorize the operation if the document's ID matches some condition:

    match /players/{player_id} {
         allow read: if request.auth.uid == player_id;
    }

Field value based authorization: we authorize the operation based on the value of a specific field. Either from the requested document or from the uploaded document.

  • resource.data represents the requested document.
  • request.resource.data represents the uploaded document
  • For example, we check the requested document's owner property against auth.uid. In this case, we ignore the uploaded document's data which can be tampered with.
    match /planets/{planet_id} {
         allow read: if request.auth.uid == resource.data.owner.id;
    }

If the document is missing the field, the request is denied.

authorization based on separate documents

We read a document with get(). It is a billed read.

first example (do not use)

This unlocks a pattern where we read authorization data in a separate document, such as in the user document, which would store the user's entitlements:

get(/databases/$(database) / documents / users / $(request.auth.uid)).data.rank

For example, we read the user's rank in the database to authorize a write on any character:

    match /characters/{character_id} {
         allow update: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.rank == "Game Master";
    }

Note: We should use Firebase Auth custom claims instead:

request.auth.token.rank == "Game Master";

second example

For example, we read the player's character's zone to determine if it can read the requested overworld character:

match /overworld_characters/{overworld_character} {
     allow read: if get(/databases/$(database)/documents/characters/$(request.auth.uid)).data.zone == resource.data.zone;
}

Note:

  • if a query matches 10 documents, the get() is run only once, so it only triggers a single read
  • if a query, by its nature, could match at least one document that is not authorized by the security rule, the entire query is rejected. This forces the client to query only authorized documents. In the example, it forces the client to use a where() clause instead of fetching all overworld_characters. That is, we cannot rely on security rules to filter documents out of a broad query.

payload validation

request.resource.data is the request's payload. We can validate critical fields:

	// simple check
request.resource.data.age >= 0

  // check against auth uid.
request.auth.uid == request.resource.data.uid;

  // check both requested document and uploaded document
  allow update,delete: if
  request.auth.uid == resource.data.uid
  &&
  request.auth.uid == request.resource.data.uid;

Alternative: We can perform validation in Cloud Functions and forbid writes coming from the client.

Storage

reference

file terminology

Firebase Storage is a wrapper around Google's Cloud Storage, a cloud storage service. It is technically an object storage service because it stores immutable objects in a flat bucket, instead of files in a hierarchical filesystem.

Firebase Storage reintroduces the concept of files, folders and file hierarchy, and uses that terminology exclusively. Among other things, It does so by naming objects with their hierarchical path such as public/abc.png.

project's default bucket (implementation detail)

A Firebase project is given a default bucket, with a given URI:

"gs://<PROJECT-ID>.firebasestorage.app"
"gs://<PROJECT-ID>.appspot.com" // old default bucket URIs

The bucket's URI serves to distinguish it from other buckets.

  • It is made of two components: a gs:// prefix and a domain name.
  • The default bucket's domain uses the project's name as a subdomain, which makes it globally unique.
  • If we add another bucket, we pick a globally unique name by ourselves:
"gs://<GLOBALLY-UNIQUE-ID>" // non-default bucket URI

The URIs only act as identifiers. There is no matching HTTP endpoint and no server listening to, for example, abc.firebasestorage.app.

storage helper

We get a storage helper for use in various storage related functions:

  • We don't name it storage because Firebase already exports a storage variable. We use another name such as storageService or bucket.
  • The client SDK uses the default bucket unless we specify another one in the initializer:
const storageService = getStorage(app)
const storageService = getStorage(app, "gs://...")

// const bucket = getStorage().bucket(); // admin SDK

File references and metadata

file path

A file path uniquely identifies a file in the bucket. It includes the file extension. It starts from the bucket's root.

file reference

  • We use references to interact with files
  • A file reference does not guarantee the file existence.
  • We build them with file (full) paths:
const fileRef = ref(storage, "tts/2F14Izjv.mp3")
// const fileRef = bucket.file("tts/2F14Izjv.mp3") // admin SDK

Note: ref can also build folder references.

The properties are of limited use:

ref.bucket // "abc.firebasestorage.app"
ref.fullPath // "tts/abc.mp3"
ref.name // "abc.mp3"

// computed references
ref.parent // ref(storage, "tts")
ref.root // ref(storage, "/")

file metadata

A file metadata, of type FullMetadata, or FileMetadata on the admin SDK, contains various information about the file:

metadata.size // 1048576 (bytes)
metadata.contentType // "audio/mpeg" (MIME type)
metadata.timeCreated // "2026-01-04T12:34:56.789Z"

metadata.ref // file reference

// repeat from fileRef
// metadata.bucket
// metadata.fullPath
// metadata.name

We fetch an existing file's metadata:

const metadata = await getMetadata(fileRef)

// admin SDK
// const [metadata] = await fileRef.getMetadata()

List files and folders

folders and prefix terminology

The API describes folders as prefixes, but the docs also mention folders.

folder existence

A file, by its name alone, can create one or more nested folders. It occurs when its name contains subpaths. For example, abc/def/hello.pdf creates two folders: abc and def. Those folders are an artificial byproduct.

By design, those folders can't be empty, because they derive from a nested file.

use a folder reference to list its content

We build a folder reference to list its content. It outputs a shallow list: we see the top level files and folders.

The list discriminates files (items) from folders (prefixes), putting them into separate arrays, but both arrays are typed with the same StorageReference element type.

Note: list() is a capped version that expects a count limit.

folderRef = ref(storage, "uploads")

const result = await listAll(folderRef)
const result = await list(folderRef, { maxResults: 100 })

result.items // StorageReference[]
result.prefixes // StorageReference[]

Read, download files

general considerations

  • The client SDK's functions are subject to access-control rules.

    • Some functions allow the user, after access-control, to save a bearer URL which is not subject to security rules. This pattern is a one-off access control.
  • Download workflows are influenced by the browser restrictions regarding the file's URL.

get a HTTP URL on the client

We request a read URL. Access control is performed during such request.

The returned URL is a bearer URL, which is not subject to access-control. We consume it as a regular URL, outside the realm and control of the Storage SDK.

Note: the URL remains valid unless manually revoked at the file level in the Firebase Console, or with the admin SDK.

getDownloadURL(fileRef).then(url => ...)

consume a cross-origin HTTP URL on the client (browser-specific)

The bucket's file lives on a separate origin than the origin that ships the web-app. For the browser, the file's URL is cross-origin. The challenges and patterns to consume a cross-origin URL are not specific to Firebase.

The way we consume the URL determines if CORS headers are necessary:

  • The browser allows cross-origin URLs in media elements' src attribute (hot linking), with no CORS header requirement.
  • The browser allows navigating to cross-origin URLs (basic browser behavior). For example, we navigate to an image in a new tab.
  • The browser doesn't allow background fetch of cross-origin resources unless explicit CORS headers are present on the server. This applies to fetch() and functions that rely on it.

Buckets do not have permissive CORS headers by default, but we can add them on demand. CORS headers whitelist one, several or all domains. We use gcloud to whitelist our domain (see the dedicated CORS chapter).

download a Blob with the client SDK

A blob is an opaque object that we fetch and transform to a local URL for easier saving. When using getBlob():

  • access rules are enforced
  • CORS headers are required (it uses fetch() under the hood)

We create a local (same-origin) URL out of the blob, to avoid the browser restrictions against cross-origin URLs. It restores the ability to download content through a single click, without navigating to a different URL (see below).

getBlob(fileRef).then((blob) => {
    // create a local URL and trigger download imperatively
})

add the download attribute to an anchor tag with a local URL

The download attribute on anchor tags (<a href="" download>) offers one-click downloads for same-origin URLs or local URLs.

For cross-origin URLs, clicking the anchor tag triggers standard browser navigation instead: the browser navigates to the resource and shows its full URL.

create a local URL out of a blob (browser specific)

This example also triggers download programmatically, and revokes the local URL for clean up. We set the user-facing file name by setting the download attribute:

// 3. Create a local URL out of the blob
const objectURL = URL.createObjectURL(blob)

// 4. Use the local URL and trigger the download
const link = document.createElement("a")
link.href = objectURL
link.download = img.id + ".png"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)

// 5. Clean up by revoking the local URL
URL.revokeObjectURL(objectURL)

Upload data

client SDK

upload a Blob or a File

We prepare some data in a JavaScript Blob or File object, and upload it to the reference:

const result = await uploadBytes(fileRef, file) // File object
  • The upload is a non-conditional upsert which overrides existing files.
  • It makes the file immediately downloadable through the SDK read functions.
  • On success, we receive an UploadResult, which wraps the bucket file's metadata and the file reference.
result.metadata // FullMetadata
result.ref

(advanced) upload and track the progress

For each tick, we receive a snapshot. We may show the upload progress.

const uploadTask = uploadBytesResumable(ref, file)

uploadTask.on(
    "state_changed",
    /* on snapshot */
    function (snapshot) {
        // snapshot.bytesTransferred
        // snapshot.totalBytes
        // snapshot.state // "paused" | "running"
    },
    function (error) {},
    function () {
        /* on completion */
        getDownloadURL(uploadTask.snapshot.ref).then(/**/)
    },
)

admin SDK

upload a Node.js Buffer

We prepare some data in a Node.js Buffer, and upload it to the reference.

await fileRef.save(imageBuffer, {
    resumable: false,
    metadata: {
        contentType: `image/png`,
        cacheControl: "public, max-age=31536000, immutable",
    },
})

Note: it doesn't make the file downloadable for clients: a client getDownloadURL() fails. This is because the underlying Firebase-specific download token in the GC storage object's metadata is missing.

make it downloadable

To make it downloadable for clients, we then use the admin SDK's getDownloadURL(). It adds a permanent download token to the underlying GC storage. It also returns the bearer URL (tokenized URL that embeds this very access token, and is not subject to security rules).

We can store it in a database, return it to the client, or discard it and let the client SDK generates the URL on its own with getDownloadURL() (since it is now downloadable).

const url = await getDownloadURL(fileRef)

We can invalidate the access token from the Firebase console. It makes the file non-downloadable. The bearer URL becomes invalid.

advanced: read and write the token

The token, if present, is in the File's metadata field. We should avoid setting this field manually when using save(). We use getDownloadURL instead (see full example below).

metadata: {
  firebaseStorageDownloadTokens: token
}

We can revoke the token:

await file.setMetadata({
    metadata: {
        firebaseStorageDownloadTokens: null,
    },
})

Or rotate the token

await file.setMetadata({
    metadata: {
        firebaseStorageDownloadTokens: randomUUID(),
    },
})

upload image example (admin SDK)

We upload an image and make it readable by clients. We may store the bypass URL.

// 1.0 create a file reference
const fileRef = bucket.file(`generated/${userID}/cat.png`)

// 1.1 create a Buffer object
const imageBuffer = base64ToBuffer(base64Data)

// 1.2 upload the Buffer object
await fileRef.save(imageBuffer, {
    resumable: false,
    metadata: {
        contentType: `image/png`,
        cacheControl: "public, max-age=31536000, immutable",
    },
})
//  1.3 make it readable by client SDKs (generate a token).
const url = await getDownloadURL(fileRef)

//  1.4 store the bypass URL (if applicable)
//  ...

Setting the bucket CORS header

Some read operations require the web client's domain to be whitelisted, through a bucket-side CORS header.

read operations that require a CORS whitelist

Browser reads relying on background fetch rather than navigating to the URL require a CORS whitelist:

  • getBlob(fileRef) to get a Blob, which uses fetch() under the hood.
  • getBytes(fileRef) to get an ArrayBuffer, which uses fetch() under the hood.
  • using fetch() manually with a bearer (tokenized) URL.

white-listing the domain

We add authorized domains to cors.json and send it to Google through the CLI:

cors.json

[
    {
        "origin": ["https://imagetales.io", "http://localhost:5173"],
        "method": ["GET"],
        "maxAgeSeconds": 3600
    }
]

Register cors.json:

gcloud storage buckets update gs://abc.firebasestorage.app --cors-file=cors.json

For debugging, we can get a description of the existing bucket CORS config:

gcloud storage buckets describe gs://abc.firebasestorage.app --format="default(cors_config)"

Cloud Functions

Cloud Functions are serverless functions: we run code on servers operated by Google.

As it is a secure environment, we can run sensitive tasks: authenticate requests, perform server-side validation, use API keys, make sensitive database writes, and more.

Functions trigger on direct requests, or on events happening in the Firebase ecosystem, such as the registration of new users through Firebase Auth. They can also be triggered by a Cron job.

trigger on direct requests: two options

The first option is to configure and establish a bare-bones REST-API endpoint, called a HTTP function. We configure the endpoint with an Express.js like API.

import { onRequest } from "firebase-functions/https"

The second option is to define a Callable function, a pattern that involves both a server SDK and a client SDK, which together do some heavy lifting on behalf of the developer, such as managing authentication, defining the type of the request's data and response, and offering streaming responses.

import { onCall } from "firebase-functions/https"

select and deploy functions

The main file determines which functions are deployed by default: all the ones that it exports. We determine the main file in package.json. Since it must be a JavaScript file, we refer to the TypeScript compiler output file:

{
    "main": "lib/index.js"
}

It is usually a barrel file that re-exports functions from their own files:

export { requestPlayer } from "./requestPlayer.js"

We deploy functions imperatively, all of them or some of them:

firebase deploy --only functions
firebase deploy --only functions:FOO
firebase deploy --only functions:FOO,functions:BAR

We can delete a function imperatively:

firebase functions:delete FOO

configure TypeScript

We use a workflow that transpiles to JS since the functions are deployed as JavaScript functions.

The convention is to store TypeScript code in src/ and transpile towards lib/. The main file is lib/index.js.

tsconfig.json configures the transpilation, targeting the Node.js runtime:

{
    "compilerOptions": {
        "module": "NodeNext",
        "esModuleInterop": true,
        "moduleResolution": "nodenext",
        "noImplicitReturns": true,
        "outDir": "./lib",
        "rootDir": "./src",
        "sourceMap": true,
        "target": "es2022",
        "skipLibCheck": true,

        /* Linting */
        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noFallthroughCasesInSwitch": true,
        "verbatimModuleSyntax": true
    },
    "compileOnSave": true,
    "include": ["src"]
}

use of the admin SDK

The use of the admin SDK is a natural fit from Cloud functions.

Define Callable functions

The code we run in Callable functions has access to the user's authentication status along with the request's data.

Callable functions support streaming responses: we describe the pattern in a dedicated section.

Overview and syntax

synopsis

onCall<ReqData, Promise<ResData>>(options, callback)
onCall<ReqData, Promise<ResData>>(callback)

the callback

The callback has access to the request object (CallableRequest), which exposes auth and data.

We define the callback async so it returns a promise. The connection is kept open until the promise settles.

;async (req) => {}
;async ({ auth, data }) => {}
  • auth is undefined when the request is unauthenticated. It has uid otherwise.
  • ReqData defines the data sent by clients.
  • ResData defines what the callback returns.
onCall<ReqData, Promise<ResData>>(async (req) => {
    req.auth // AuthData | undefined
    req.auth?.uid
    req.data // ReqData
    return { message: "" } // ResData
})

add options

The options object, of type CallableOptions, a subclass of GlobalOptions, sets the region, the secrets, and the number of instances and the concurrency for each instance:

const options: CallableOptions = {
    concurrency: 1,
    minInstances: 1,
    maxInstances: 1,
    region: "europe-west1",
}

An instance can handle several requests at once. By default, Firebase runs with no minimum and no maximum.

  • Since there are no minimum (minInstances defaults to 0), Firebase doesn't run any instance when the endpoint is inactive. When a request is received, it spins up an instance, which requires some time (cold start), and keeps it alive for a while.
  • To avoid cold starts, we can set minInstance to 1, which keeps the instance warm at all time but costs much more.
  • We can limit maxInstances to 1 if we prefer throttling traffic instead of scaling up.

concurrency sets how many requests a single instance processes in parallel. Since it can process multiple requests in parallel by default, We only set it to 1 if we want a clean instance for each request or, if we also limit maxInstances to 1, if we want to process one request at a time.

Streaming version

Streaming the response means to send small chunks of data with sendChunk().

The third type argument (StreamData) defines what kind of chunk we stream. We usually stream string chunks.

The request exposes acceptsStreaming, which we read to check if the client supports streaming. When it does, the callback has access to an extra response argument, on which we call sendChunk().

onCall<T, U, V>(options, callback) // streaming Callable
onCall<ReqData, Promise<ResData>, StreamData>(async (request, response) => {
    if (response.acceptsStreaming) {
        response?.sendChunk("abc") // StreamData
        response?.sendChunk("def")
    } else return { message: ".." } // fallback
})

Patterns

halt and send an error immediately

We throw an HttpsError with a specific error code which conforms to a predefined list. It defaults to internal error if omitted.

throw new HttpsError("unauthenticated", "unauthenticated")

logger

logger.debug("")
logger.info("")
logger.warn("")
logger.error("")

Callable v1 (deprecated)

define the function

functions.https.onCall(async (data, context) => {
    const auth = context.auth
    const message = data.message
    return { message: ".." }
})

the context object

The context object provides the authentication details, if any, such as the email, and the request metadata such as the IP address, or the raw HTTP request. It is of type CallableContext

check authentication

if (!context.auth) {
    throw functions.https.HttpsError("unauthenticated", "you must be authenticated")
}

Invoke Callable functions

On the client, we invoke callable functions through specific handlers provided by the SDK.

configure a functions helper with the firebase project and the region

  • Since a client can interact with separate Firebase projects, we specify the project we target. We do so by providing the app helper, which already identifies the project.
  • Since Cloud function instances are region-specific, we specify which instance region we target. If omitted, the client SDK targets us-central1, which errors if no instance runs there:
const functions = getFunctions(app, "europe-west1")

get a handle over the Callable function

We provide the function's name and the type arguments:

const requestPokemonCF = httpsCallable<ReqData, ResData>(functions, "requestPokemon")

invoke and handle the result

  • The request data, if any, is of type ReqData in our example.
  • The returned value is of type HttpsCallableResult<ResData>, which is a container over the actual data, found in the data property, of type ResData in our example:
const result = await requestPokemonCF({ number: 151 })
result.data // ResData

HTTP functions

overview

Configure and establish a bare-bones REST-API endpoint, called an HTTP function. We use an Express.js like API. We respond with JSON, HTML, or plain text:

export const sayHello = onRequest((req, res) => {
    res.send("Hello from Firebase!")
})

add options

const options: HttpsOptions = {
    region: "europe-west1",
    cors: true,
}
export const sayHello = onRequest(options, (req, res) => {})

ExpressJS concepts and syntax

req and res objects have the shape of expressJS req and res objects. We can add middleware.

call the endpoint: standard HTTP request (not Firebase specific)

We read the function's URL at deploy time.

We consume endpoints like regular REST API endpoints. The deployed endpoint URLs look like this:

https://requestPlanet-x82jak2-ew.a.run.app

Run functions on Auth events

Register functions that react to Auth events. Blocking functions can deny the registration of a user. Non blocking functions run after the authentication event has occurred.

Blocking functions

run a function before the user is added to Firebase Auth

We perform validation, and, if applicable, deny the registration by throwing an error. Firebase Auth aborts user creation on throw. The Auth client SDK receives such error and can display it to the user.

export const onRegisterBlocking = beforeUserCreated(options, async (event) => {
    const userRecord = event.data // AuthUserRecord === UserRecord
    // userRecord.uid
    // userRecord.email

    // 1.0 validate
    if (userRecord?.email?.includes("@hotmail.com")) {
        throw new HttpsError("invalid-argument", "don't use hotmail")
    }
    // 2.0 perform block side effects
    // e.g. add a user document to the database:
    await addUserDocFor(userRecord)
    return
})

Non blocking functions

The non blocking functions run after a user has been created (or deleted) by Firebase Auth.

As of writing, there is no v2 version for the non blocking functions:

auth.user().onCreate(async (user) => {})
auth.user().onDelete(async (user) => {})

example: add the user to the Firestore database (or delete it)

We read the auth user's uid and act accordingly:

export const onRegisterNonBlocking = region("europe-west1")
    .auth.user()
    .onCreate(async (user) => {
        const { uid, email } = user
        // add to db
        await db.doc("users/" + uid).set({ uid, email })
        // delete from db
        await db.doc("users/" + uid).delete()
    })

on Firestore and Storage events

on Firestore events

Run Cloud functions on database events. They are non-blocking: they run after writes. As they don't prevent writes, we don't call it validation. Instead we use the term sanitization.

sanitize data post-write

List of Cloud Firestore triggers.

For example, onDocumentWritten triggers on:

  • onDocumentCreated
  • onDocumentUpdated
  • onDocumentDeleted
export const onUserWritten = onDocumentWritten("users/{docId}", (event) => {
    const docId = event.params.docId // string

    const change = event.data
    // Change<QueryDocumentSnapshot | DocumentSnapshot> | undefined

    const before = change?.before.data() // DocumentData | undefined
    const after = change?.after.data() // DocumentData | undefined
})

on Storage events

sanitize data post-upload

the user uploads a file to Firebase Storage. Sanitize data post-upload. For example:

import { onObjectFinalized } from "firebase-functions/v2/storage"

export const generateThumbnail = onObjectFinalized(async (event) => {
    const file = event.data

    // const fileBucket = file.bucket;
    // const filePath = file.name;
    // const contentType = file.contentType;
    // const metageneration = file.metageneration;
    // // Number of times metadata has been generated. New objects have a value of 1.
})

Dates and Timestamps serialization

ISO strings are the better choice

When interacting with Callable Functions, it's best to represent dates as ISO strings. The value and the type stay consistent when sending and receiving on the client and on Cloud functions.

In this article, we explain what happens when we send Date and Timestamp objects to Callable Functions or when we receive them from Callable Functions. Before being sent, both Date and Timestamp are serialized to JSON.

sending Date and Timestamp to Callable Functions (do not use)

Timestamp is a Firestore specific type and doesn't get a special treatment: it serializes to an object with seconds and nanoseconds (through toJSON()).

// on the Cloud function receiving end
type T = { seconds: number; nanoseconds: number }

Date fields serialize to an ISO string (through toJSON()):

date: "2023-10-08T07:54:47.527Z"

When receiving in Cloud functions, we have a type difference between RequestData on the client and RequestData in Cloud functions.

We could manually instantiate Timestamp or Date instances, but it requires manual processing:

new Date(date) // build a Date
new Timestamp(timestamp.seconds, timestamp.nanoseconds) // build a Timestamp

returning data from Callable functions

If we attempt to return a Date object, it serializes to an ISO string as well, through the same mechanism.

If we attempt to return a Timestamp object, it serializes to the internal representation specific to the admin SDK, which is different than the client SDK's Timestamp representation:

possible an object with _seconds and _nanoseconds. We should avoid this pattern:

// on the client SDK receiving end
type T = { _seconds: number; _nanoseconds: number }

Environment variables

Firebase provides a way to register and manage secrets through the CLI. We then read them from Cloud functions, both from deployed functions and from functions running locally in the emulator.

As such, we don't need to store secrets in local .env files.

Manage secrets

set, read and destroy secrets through the CLI

firebase functions:secrets:set ABC_API_KEY
firebase functions:secrets:access ABC_API_KEY
firebase functions:secrets:destroy ABC_API_KEY

gcloud secrets list --project <PROJECT_ID>

Note: the secrets are available both locally (function emulator) and for deployed functions.

read secrets from cloud functions

We declare the secrets requirements in the function options. We then read them from process.env:

const options: CallableOptions = {
    // ..
    secrets: ["ABC_API_KEY"],
}

onCall<ReqData, Promise<ResData>>(options, async (request) => {
    const abcKey = process.env.ABC_API_KEY
})

Https functions:

const options: HttpsOptions = {
    // ..
    secrets: ["ABC_API_KEY"],
}

onRequest(options, (req, res) => {
    const abcKey = process.env.ABC_API_KEY
})

.env file pattern

The .env file pattern should be only used if the CLI pattern is not available or not applicable. We set the env variables in a .env file:

ABC_API_KEY=xxx

The key is then made available to cloud functions the same way as seen above, at process.env, both locally and for deployed functions, as long as the key is listed in the options.

On deploy, the .env file is automatically detected and deployed along functions. See env-variables docs

Debug Functions locally

start the functions emulator

We run the functions with other emulators, or on their own:

firebase emulators:start
firebase emulators:start --import emulator-data --export-on-exit

firebase emulators:start --only functions
npm run serve

invoke callable functions through the SDK

We redirect client invocations towards the emulated functions, but only on localhost:

if (location.hostname === "localhost") {
    // ...
    connectFunctionsEmulator(functions, "localhost", 5001)
}

invoke callable functions outside the client SDK

We can invoke callable functions outside the client SDK as long as the function emulator is running.

The emulator exposes them on specific URLs. We get the URLs when starting the emulators:

http://localhost:5001/imagetale/europe-west1/get_images

Callable functions can be invoked:

  • directly, with a local HTTP request
  • through the Firebase REPL shell.

In both cases, the request's data must be present in the data key of the request's body (JSON). The data key must exist.

Note: We turn off authentication requirements when calling callables outside the client SDK

calling functions with curl

We can invoke callable functions with curl:

curl -H "Content-Type: application/json" \
  -d '{ "data": { } }' \
  http://localhost:5001/imagetale/europe-west1/get_images

calling functions with the Firebase REPL

functions:shell starts the functions emulator and an interactive CLI shell from which we invoke callable functions.

firebase functions:shell

We provide the data key and its content, which represents the request's data:

generateExample({ data: { word: "你好" } })

Schedule Cron jobs

periodic code execution: schedule a Cron job

We schedule a Cron job, with the help of Google Cloud's Cloud Scheduler. We register a job with onSchedule. We set:

  • the periodicity, using strings such as every day 00:00 or every 8 hours.
  • the timezone (IANA string), the region and the callback function:
export const updateRankingsCRON = onSchedule(
    {
        schedule: "every day 00:00",
        timeZone: "Europe/London",
        region: "europe-west1",
    },
    async () => {
        // ...
    },
)

The older API (v1) relied on pubsub instead:

export const updateRankingsCRON = functions.pubsub
    .schedule("every 8 hours")
    .timeZone("Europe/London")
    .onRun(async (context) => {
        // ..
    })
earlymorning logo

Overview

Firebase allows developers to create a fully featured backend on top of servers and APIs operated by Google.

overall benefits

  • solid developer experience
  • it scales to world-scale use
  • generous free-tier and pay-as-you-go pricing
  • high quality docs, well supported by AI models
  • actively developed and maintained

main backend components covered in this document

  • authentication with Firebase Auth
  • database with Cloud Firestore
  • storage with Cloud Storage
  • serverless functions with Cloud Functions

focus of this document: web-centric

We create a backend for web-apps, and use the web-centric client SDKs. We default to TypeScript, and pick Node.js as the runtime for Cloud Functions.

CLI tool

The Firebase CLI tool enables several workflows:

  • Emulate the Firebase backend locally, to run it and debug it at no cost and without a deploy step.
  • Scaffold the Cloud Functions' directory, and deploy Cloud Functions.
  • Submit secrets and API keys to Google, making them available to Cloud Functions.
  • Add and deploy security rules.
  • List the Firebase projects linked to the Google account.

the CLI executable

The firebase-tools package installs the firebase CLI executable.

npm install -g firebase-tools
firebase

Release notes

underlying Google account

Firebase projects are linked to a Google account.

firebase login:list # prints current Google account
firebase login
firebase logout

list projects and select one

firebase projects:list
firebase use imagetales

project configuration and scaffolding

The init command enables several workflows. We can:

  • scaffold the Cloud Functions directory
  • set up and configure emulators
  • add security rules for Firestore and Cloud Storage
firebase init

help

  • print the list of Firebase commands.
  • print the details about a given command.
firebase help

firebase help emulators:start
firebase help deploy

list deployed functions, deploy functions

firebase functions:list

firebase deploy --only functions
firebase deploy --only functions:requestPlanet

manage secrets

firebase functions:secrets:access ABC_API_KEY
firebase functions:secrets:set ABC_API_KEY
firebase functions:secrets:destroy ABC_API_KEY

start and config emulators

firebase emulators:start
firebase emulators:start --import emulator-data --export-on-exit

We set which emulators to run with firebase.json. We scaffold this file with firebase init. If the port is omitted, the emulator uses the default one.

{
    "emulators": {
        "firestore": { "port": 8080 },
        "auth": { "port": 9099 },
        "functions": { "port": 5001 },
        "storage": { "port": 9199 },
        "ui": { "enabled": true }
    }
}

manage and deploy security rules

The storage emulator requires storage access rules.

  • We define Storage rules in storage.rules.
  • We define Firestore rules in firestore.rules
  • We refer to the rules in firebase.json:
{
    "storage": { "rules": "storage.rules" },
    "firestore": {
        "rules": "firestore.rules",
        "indexes": "firestore.indexes.json"
    }
}

We deploy the rules:

firebase deploy --only storage
firebase deploy --only firestore:rules

gcloud: Google Cloud CLI tool

gcloud enables some operations not available with the firebase tool, such as listing secrets of a given project or describing a Storage bucket.

We call gcloud from the Google Cloud Console's Cloud Shell (it is pre-installed), or we install it locally from an archive provided by Google.

gcloud secrets list --project <PROJECT_ID>
gcloud storage buckets describe gs://abcd.firebasestorage.app

SDKs

We interact with the backend with the help of SDKs. In this book, we describe the JavaScript SDKs.

client SDKs

The client SDKs run on unprivileged clients, such as browsers. They can also run in Node.js apps that want to run unprivileged.

The client SDKs live in the firebase package:

npm i firebase

admin SDK: privileged environments

The admin SDK is designed to run on secure environments as it relies on privileged service accounts.

The admin SDK authenticates itself against Google servers using a privileged account called a service account. Service accounts are managed by Google, scoped to a Firebase project and have specific entitlements. Such accounts are not subject to security rules and are already authenticated.

As Cloud Functions run on Google servers pre-configured with appropriate service accounts, it is fitting and convenient to use the admin-SDK. The firebase-admin package is designed to run on a Node.js server:

npm i firebase-admin

Cloud Functions SDK

We define Cloud Functions with the (Node.js) Cloud Functions SDK.

We have the package listed as a dependency after scaffolding the Cloud Functions directory with firebase init.

"firebase-functions": "^7.0.0",

Project setup and initialization

identify the Firebase project (client SDK)

The config object stores credentials to identify the Firebase project when interacting with Google servers:

const firebaseConfig = {
    apiKey: "....",
    authDomain: ".....firebaseapp.com",
    projectId: "....",
    storageBucket: ".....firebasestorage.app",
    messagingSenderId: "....",
    appId: "....",
}

register one or more configs

We register the config (client SDK) and keep a reference to the app helper , which we initialize other services with:

const app = initializeApp(firebaseConfig)

When working with several Firebase projects, we get a helper for each. The first helper has a "[DEFAULT]" internal string identifier. We provide string identifiers for the additional project we want to work with.

const app1 = initializeApp(firebaseConfig1)
const app2 = initializeApp(firebaseConfig2, "bar")

When initializing the admin SDK on Google servers that run Cloud Functions, the admin SDK finds the credentials on its own. We initialize it without a config:

const app = initializeApp()

Auth Overview

authenticate app users

The Auth client SDK authenticates users, and notifies the app about Auth events. It provides several authentication flows.

the auth helper and reading currentUser

We keep a reference to the auth helper to read currentUser. We also use it in auth related functions.

const auth = getAuth(app)
auth.currentUser // User | null

currentUser starts as null. When the SDK has finished loading, and given that the user has logged in, currentUser switches to a User instance.

As a User instance, It exposes the user identifier (uid). The other properties are optional:

currentUser.uid
currentUser.email
currentUser.phoneNumber
currentUser.displayName
currentUser.isAnonymous

react to authentication events

We register a callback on onAuthStateChanged, which Firebase runs on auth events. Firebase gives us a user object (of type User | null).

onAuthStateChanged(auth, (user) => {
    if (user) {
        // user.uid
    }
})

Auth events:

  • the auth SDK has finished loading and no user is authenticated

  • the user has registered (sign up)

  • the user has logged in (sign in)

  • the user has logged out (sign out)

Login occurs in three specific scenarios:

  • the user fills out the standard login form or logs in through an identity provider (hard-login)
  • the user is recognized by the SDK and is logged in automatically (credentials stored in browser)
  • (canonically a registration) the user is automatically logged in after a successful sign-up. Note: a single authentication event occurs.

React patterns

We make the authentication status part of the React state. For example, we work with a isSignedIn variable. We make the display of the authenticated area conditional on isSignedIn being true.

On page load, the Auth SDK is loading: It's best to wait for the SDK to load before making any use of isSignedIn, given that the state should be indeterminate.

As such, we track the loading state in a one-off state variable, which becomes true on the first authentication event. Only then do we set isSignedIn with correct state and make use of it.

If we were to read isSignedIn before load, and because we initialize it to false for simplicity, we would have a rapid UI flicker when it switches from false to true if applicable.

const [hasLoaded, setHasLoaded] = useState(false)
const [isSignedIn, setisSignedIn] = useState(false) // the initial state shouldn't be relied upon.

useEffect(() => {
    const unsub = onAuthStateChanged(auth, (user) => {
        setHasLoaded(true)
        setisSignedIn(Boolean(user)) // accurate state
    })
    return unsub
}, []) // subscribe once, unsubscribe on unmount.

if (!hasLoaded) return null
if (!isSignedIn) return <Lobby />
return <Ingame />

sign out

sign out is consistent across all authentication flows:

signOut(auth)

Email-Password accounts

A provider that relies on collecting the user's email and password.

registration and hard-login

On success, we receive a credential of type UserCredential.

register:

createUserWithEmailAndPassword(auth, email, password).then((credential) => {
    credential.user // User
})

hard login:

signInWithEmailAndPassword(auth, email, password).then((credential) => {
    credential.user // User
})

send a password reset email

We ask Firebase to send a password-reset email to the provided email. We can customize the email content through the Firebase console:

sendPasswordResetEmail(auth, email)

email account's providerData (implementation detail)

The user object of type User has a providerData field with password as the providerId value:

{
    "providerData": [
        {
            "providerId": "password",
            "uid": "user@example.com",
            "email": "user@example.com",
            "displayName": null,
            "phoneNumber": null,
            "photoURL": null
        }
    ]
}

Identity Providers

We allow users to authenticate with external provider accounts, such as with their Google account or Apple account.

select one or several providers

Note: We enable providers in the Firebase console.

const gProvider = new GoogleAuthProvider() // Google account provider

authentication flows

Alternative flows:

  • the user authenticates through a popup window.
  • the user authenticates through a redirect.

A flow is initiated through a user action, such as tapping a button. Both flows handle sign-in and sign-up through a single, undiscriminated action. As such, we use generic labels, such as:

  • Continue with Google
  • Authenticate with Google

On success, a flow triggers an authentication event. The flow functions return a credential object (UserCredential), that exposes the user object:

const credential = await signInWithPopup(auth, gProvider)
credential.user // User

Note: We can detect it is a new user through a helper method:

const userInfo = getAdditionalUserInfo(credential)
if (userInfo?.isNewUser) {
}

popup flow specifics

The popup flow may fail if the browser doesn't allow popups.

const credential = await signInWithPopup(auth, gProvider)

redirect flow

The redirect flow relies on navigating to another page and navigating back.

It requires extra work unless the website is hosted on Firebase Hosting.

Anonymous accounts

Register an account with no personal information from the user:

signInAnonymously(auth)

The generated credentials are stored in the browser: users cannot access their account from other devices, and cannot recover it if they lose the credentials (browser wipe).

Auth-triggered Cloud Functions only partially support anonymous accounts:

  • The v1 auth functions such as onCreate() correctly trigger.
  • The v2 auth blocking functions such as beforeUserCreated() don't trigger (as of writing)

check if the account is anonymous

On the client, we check the isAnonymous prop from currentUser:

auth.currentUser?.isAnonymous

In auth-triggered Cloud Functions, we read providerData (from the UserRecord which resembles the client SDK's User).

export const onRegisterNonBlocking = auth.user().onCreate(async (userRecord) => {
    userRecord.providerData.length === 0 // empty array for anonymous accounts
})

convert to a non-anonymous account (client SDK)

We link the account to another provider. Since the user already exists (auth.currentUser), we provide it to the linking function. We link to an email provider or to an identity provider:

// email
const emailCred = EmailAuthProvider.credential(email, password)
await linkWithCredential(auth.currentUser, emailCred)

// Google account with popup
const gProvider = new GoogleAuthProvider()
await linkWithPopup(auth.currentUser, gProvider)

Manage users

We can manage users in the Firebase console or programmatically via the admin-SDK:

import { getAuth } from "firebase-admin/auth"
const auth = getAuth()

list users

listUsers() fetches at most 1000 users at once. If we have more users, we use pagination.

const result = await auth.listUsers() // implied 1000 max
const users = result.users

users.forEach((user) => {
    user // UserRecord

    user.uid
    user.email

    // HTTP-date string (RFC 1123)
    user.metadata.creationTime // "Tue, 13 Jun 2023 17:00:00 GMT"
    user.metadata.lastSignInTime // "Wed, 14 Jun 2023 17:00:00 GMT"
})

Firestore

conceptual

Firestore is a schema-less database made of collections and documents of loose shape (NoSQL), most similar to MongoDB.

  • A collection is a set of documents.
  • A document is a set of fields holding primitive data types (number, string, timestamps...) or object types (maps and arrays). A document has up to 20k fields and stores up to 1 MiB of data.
  • A reference serves to refer to a collection or to a document. It doesn't guarantee the collection or document's existence in the database: It's merely a path that points to a location.

import paths and documentation

We interact with the database with the client SDK or with the admin SDK. The admin SDK's firestore sub-package is a wrapper around a google-cloud package: It has the same syntax and capabilities.

"firebase/firestore" // client SDK
"firebase/firestore/lite" // client SDK

"firebase-admin/firestore" // admin SDK

helper object

We init a db object, for use in Firestore-related functions.

// const app = initializeApp()
const db = getFirestore(app)

Collection

Collection Reference

use the collection reference

We use a collection reference when an individual document reference is not needed or not yet available:

  • fetch all documents (it acts as a query): getDocs(colRef)

  • build a query (targeting the collection): query(colRef, filters..)

  • build a random-ID document reference: doc(colRef), or one that refers to a specific document: doc(colRef, docId)

  • add a random-ID document to the collection in a single step: addDoc(colRef, data).

build a collection reference

We use the path that identifies the collection uniquely. Root collections have the simplest path: the collection name, such as "users" (no starting slash). Sub-collections' paths are built from several components.

We set the path as:

  • a string, with slash separators.

  • a sequence of strings, with no slash separators.

const colRef = collection(db, "users")
const colRef = collection(db, `users/${uid}/custom_list`)
const colRef = collection(db, "users", uid, "custom_list")
const colRef = db.collection(`users/${uid}/custom_list`) // admin SDK

TypeScript: a collection's documents as DocumentData

Collections are schema-less: they don't define the shape of their documents.

When receiving data from the database, the client SDK instantiates documents with no regard to the content: documents are of any shape and may differ from one another. It types them as DocumentData, which doesn't provide information about the content.

TypeScript: annotate the document's type at the collection level.

We provide a more precise type at the collection reference level. The simplest way to do it is through a type assertion:

const colRef = collection(db, "players") as CollectionReference<Player, Player>

Converter (optional)

the case for a converter

The SDK supports working with two document shapes on the client:

CollectionReference<AppModel, DbModel>

DbModel represents the object that the SDK instantiates from the raw data. It is DocumentData by default.

We can add a converter to transform it to a different shape for use in the app.

AppModel represents the object after the converter's transformation. It also defaults to DocumentData. We set it to whatever type the converter converts to.

Before sending to Firestore, the converter transforms back AppModel to DbModel.

Transformation examples:

  • We transform the DbModel's Timestamp field to an AppModel Date field.
  • We add properties to AppModel.

implement the converter

We transform the documents at the app boundaries:

  • upon receiving from Firestore (fromFirestore())
  • upon sending to Firestore (toFirestore())

We define the functions and add them to the converter.

fromFirestore() takes the snapshot as instantiated:

fromFirestore(snapshot: QueryDocumentSnapshot<FirestoreWorkout>): Workout{
		// to client shape
		// FirestoreWorkout -> Workout
		const firestoreItem = snapshot.data()
		const workout = { ...firestoreItem, date: firestoreItem.date.toDate()}
     return workout
    }

toFirestore() takes the object in its AppModel shape.

toFirestore(workout: Workout) {
		// to database shape
  	// Workout -> FirestoreWorkout
        	return { ...workout, date: Timestamp.fromDate(workout.date)}
    }

We gather the transforms in the converter (FirestoreDataConverter). While the type are inferred from the transforms, we can still add them at the converter level:

// FirestoreDataConverter<AppModel, DbModel>
const myConverter: FirestoreDataConverter<Workout, FirestoreWorkout> = {
    toFirestore() {},
    fromFirestore() {},
}

We attach it to the collection reference to let it type its documents.

const colRef = collection(db, "players").withConverter(conv)

Document

Document reference

The document reference identifies a document within the database, and embeds meta information:

docRef.id // "Nk....WQ"
docRef.path // "users/Nk....WQ"
docRef.parent // colRef

use the document reference

We use the reference for most CRUD operations:

  • read the document: getDoc

  • update an existing document (it errors if the document doesn't exist): updateDoc

  • delete the document: deleteDoc

  • create the document, or override an existing one (upsert): setDoc

Note: addDoc() works without a document reference since it generates a new one on the fly.

build a document reference

The document's path identifies it uniquely. We set the path as a single string or build it from string components:

const docRef = doc(db, "users", id)
const docRef = doc(db, "users/Nk....WQ")

// const docRef = collectionRef.doc("NkJz11WQ") // admin sdk

Alternatively, we provide the collectionRef and the document ID, or only the collectionRef. In the latter case, the SDK generates a random ID.

const docRef = doc(collectionRef, id)
const docRef = doc(collectionRef) // random ID

read document at reference (get)

The get operation succeeds even when no document exists. We receive a Document snapshot which may be empty.

As such, checking the document existence requires to read inside the Document snapshot.

getDoc(docRef) // DocumentSnapshot
// docRef.get() // DocumentSnapshot

Document snapshot

The Document snapshot is a wrapper that doesn't guarantee the document existence. It exposes the document (or its absence) via a getter. The document's type is DocumentData or the more specific type we provided. It can also be undefined, unless it comes from a query (see Query)

Note: data() is a function because it accepts some configuration.

docSnapshot.exists()
docSnapshot.data() // DocumentData | undefined

It also exposes helpers and metadata.

docSnapshot.id // NkJ...7f
docSnapshot.ref // DocumentReference
docSnapshot.metadata // SnapshotMetadata

Query a specific field

docSnapshot.get("address.zipCode") // low use

real-time listener on a single document

Set up a real-time listener on a (single) document reference:

const unsub = onSnapshot(docRef, (docSnapshot) => {
    docSnapshot.data() // DocumentData | undefined
})

Query

overview

A query matches documents based on a set of criteria, and not based on a set of document references.

the result of a query: a query snapshot

The snapshot hosts a list of document snapshots (docs). The list is empty when no match occurs.

Otherwise, it contains only non empty snapshots, of type QueryDocumentSnapshot, which is DocumentSnapshot except that data() cannot be undefined.

querySnapshot.docs // list of document snapshots (QueryDocumentSnapshot)
querySnapshot.empty

docSnapshot.data() // DocumentData (not undefined)
const cats = querySnapshot.docs.map((docSnap) => docSnap.data())

a collection reference is a query

A collection ref can serve as a query, the one that targets all documents (get):

getDocs(colRef)
// getDocs(q)

colRef.get()
// q.get()

build a query

We always provide the collection reference. Then, we can:

  • add value-based filters
  • set the order
  • limit the count
const q = query(colRef, where(..), where(..), orderBy(..), limit(..))
// const q = collection(..).where(..).orderBy(..).limit(..)

where filter: look for documents with a given value

We filter documents based on a value we want to find in a property. We request an exact value or one within a range. Depending on the data, we expect a single match or several.

Note: documents that do not possess the property at all are filtered out.

For example, we look for a document whose id is user.id:

where("id", "==", user.id)
// where(propertyName, operator, value)

We set the requirement for the value: exact match, being different, being smaller or larger, exact match with at least one value, or different from all values.

==
!=

<
<=
>
>=

"in" // the property is equal to either A, B or C
"not-in" // the property is different from A, B and C.

We can also ask the value to be included or excluded from the array if the property is an array.

"array-contains" // the array contains the value
"array-contains-any" // the array contains A, B or C..

order documents based on a field

We order documents based on (the value of) a field, in the ascending or descending order. If omitted, the order defaults to ascending.

orderBy(propertyName, orderDirection)
orderBy("postCount", "asc")
orderBy("postCount", "desc")

We can start from a given value, e.g. documents that have at least (or more than) 10 posts.

startAt(10)
startAfter(10)

pagination: cap the read, then read the next page

Get at most n documents:

limit(20)

To get the next page, we provide a cutoff document (snapshot), stored from the current batch: we then receive the document snapshots that starts after it:

query(colRef, startAfter(docSnapshot), limit(20))

run the query (get)

one time fetch

const qs = getDocs(query)
const qs = query.get()

real-time listener

Set up a real-time listener on the query: we receive a query snapshot:

const unsub = onSnapshot(query, (qs) => {
    const documents = qs.docs.map((docSnapshot) => docSnapshot.data())
    setMessages(documents)
})

Create and update documents

targeted, strict document creation

On the admin SDK, we can perform strict targeted document creations, aka create a document with a controlled ID and expect a failure if the document already exists:

docRef.create(data)

The client SDK doesn't offer an equivalent function. Instead, we can do a two-step transaction where we read for document existence then write the document conditionally.

random-ID document creation (add)

The client SDK prefers random-ID creation that always succeed because the document won't already exist by design (add):

addDoc(collectionRef, data)
// db.collection("message").add(data)

upsert (set)

An upsert works regardless if a document exists or not. It is destructive, as it override any existing document.

setDoc(docRef, data)
// docRef.set(data)

update documents

We assume the document already exists: we use the update pattern or the set with merge pattern.

The update function is a strict update: it correctly fails if the document doesn't exist.

Both update and set merge expect a change object. We type the change as a Partial or as a Pick of the document:

const change: Partial<User> = { displayName: "Johnny Appleseed" }
updateDoc(docRef, change)
// docRef.update(change)

update uses the provided fields to replace the existing ones, the other fields being left unchanged.

To mutate a single field in an object field, we target it with dot notation. If we target the object instead, the omitted sub-fields are deleted (different from set with merge)

// sub-field
const change: Partial<User> = { "address.city": "Lyon" }
updateDoc(docRef, change)

If TypeScript complains about the dot notation, we use the FieldPath overload:

updateDoc(docRef, new FieldPath("address", "city"), "Lyon")

partial update with set and merge

the merge option changes the meaning of set: we are now providing a change object, not the new object.

What we target in the change object is what gets changed, including sub-fields. The omitted sub-fields are preserved (deep merge): there is no need for dot notation:

const change = { address: { city: "Lyon" } } // only changes city in address

setDoc(docRef, change, { merge: true })
// docRef.set(data, { merge: true })

relative change (increment, decrement)

We ask the server to change the field by n, which can be positive or negative. The current value is unknown:

const change = {
    activityScore: increment(1), // or e.g. -1
}

// docRef.update({
//    count: FieldValue.increment(1),
// })

delete field

We ask the server to delete a field. This shortcuts the need to fetch the document first and store it second omitting the given field:

updateDoc(docRef, {
    fleet: deleteField(),
})

// docRef.update({
//    fleet: FieldValue.delete(),
// })

server timestamp field

Ask the server to generate a Firestore timestamp value.

updateDoc(docRef, {
    count: serverTimestamp(),
})

// docRef.update({
//    count: FieldValue.serverTimestamp(),
// })

delete document

deleteDoc(docRef)
// docRef.delete()

Batch writes

We gather writes in a batch object and ask Firebase to perform (commit) all the writes at once.

The operation is atomic: if one write fails, the others fail as well. This prevents a broken state where only some documents are updated.

A single network request is sent.

Note: the functions are namespaced on batch both for the client SDK and the admin SDK.

batch changes then commit

Collect up to 500 writes in a batch object, and execute them with commit().

In this example, we use update(), but we could also use set() or other methods.

const batch = writeBatch(db)
// const batch = db.batch() // admin SDK

const change = { timezone: "Europe/London" }

batch.update(docRef1, change)
batch.update(docRef2, change)
// ..
// ..

await batch.commit()

other batch operations

batch.set(docRef, data)
batch.set(docRef, change, { merge: true })
batch.update(docRef, change)
batch.delete(docRef)
batch.create(docRef, data) // Admin SDK

Transactions

Read and write atomically with transactions

conceptual

Transactions are for operations where a preliminary read is required to determine the applicability, nature and extent of a tentative write.

A naive read and write is subject to desync: by the time the write order hits the database, the motivating condition may have changed: the data can change between the database read and the database write, as simple reads have no lock effect.

A transaction is a guarantee that by the time the database commits the write, the data hasn't changed since the initial read, so that the check that was performed on the data is still relevant.

For example, if credits is positive and sufficient, the condition is fulfilled, but by the time we are about to commit the purchase, we want credits not to have changed since the read.

implementation

The simplest way to guarantee it is to lock the document between the read and the write. The Admin SDK locks the document during the read to write time-window.

The client SDK doesn't lock the document, because the time between the read and write orders can become overly long and degrade the UX for the rest of users. Instead:

  • the SDK is aware of the document's version (by the time it was read), and asks the database to only perform the write if the document is still of this version (possibly tracked with an updateTime flag or equivalent).
  • The database allows changes initiated by other operations, if any.
  • On receiving the conditional write order, the database enforces the condition: it proceeds only if the document hasn't changed. Otherwise, it rejects the transaction: it is up to the client SDK to attempt a new transaction. The client SDK does retry by default, up to 5 total attempts. (retry strategy).

This pattern is called emulated optimistic concurrency.

See also: Transaction serializability and isolation (Firestore).

the runTransaction function

runTransaction expects a callback. transaction is a helper that holds the read and write methods (get, update, set).

Note that we await reads, but don't await writes, since writes are grouped up on the transaction object and sent all at once.

In case of failed preconditions, we abort the transaction with a throw:

await runTransaction(db, async (transaction) => {
    // read
    const snapshot = await transaction.get(docRef)

    // check condition
    if (!snapshot.data()) throw Error("No such event!")
    const count = snapshot.data().count
    if (count >= 10) throw Error("Sorry, event is full!") // Abort

    // proceed
    transaction.update(docRef, { count: count + 1 })
})

// admin SDK
// await db.runTransaction(async (transaction) => {
// identical API
// })

Timestamp value type (advanced)

Note: storing dates as ISO strings is simpler and more portable.

As the Firestore database comes with a native value type for storing dates called timestamp, we describe using this pattern in this article. The Firestore SDK comes with a Timestamp type that represents a timestamp field.

storing timestamps

As we attempt to store data, the SDK detects Date and Timestamp fields and assumes we want to store them as timestamps.

const user = {
    createdAt: new Date(),
    createdAt_: Timestamp.now(),
}

When preparing data for the HTTP request, the SDK serializes Date objects and Timestamp objects to plain objects with a single timestampValue property:

{
  "createdAt": { "timestampValue": "2025-10-07T18:47:13.279000000Z" },
  "createdAt_": { "timestampValue": "2025-10-07T18:47:13.279000000Z" }
},

The database detects this pattern and sets the fields as timestamps.

receiving timestamps

The SDK's Timestamp type represents database timestamps. As we receive timestamp fields from the database, the Firestore SDK instantiates them as Timestamp objects.

Firestore Security rules

We define the security rules in the Firebase console or in a firestore.rules file, referenced by firebase.json. Firebase doesn't bill reads and writes denied by security rules.

rules version

rules_version = "2"

firestore scope

We start by scoping the rules to cloud.firestore

service cloud.firestore {
    // ...
    }

database scope

We scope the rules to the current database. The security rules only affect the current database, so this can be seen as superfluous. But we use the wildcard when querying separate documents (see below).

match /databases/{database}/documents {
    // ...
}

set rules for a given collection

We target a collection. The document ID wildcard holds the requested document's ID. We can name it with what the document represents:

match /users/{user_id} { // document ID wildcard
    	// ...
}

operations and condition

allow operation, operation: if condition;

operations

read
create
update
delete

authentication, user ID

If the user is not authenticated, request.auth is null. We can filter out unauthenticated users:

allow read: if request.auth != null;

The user's authentication uid (if logged in) is available as request.auth.uid:

request.auth.uid

Note: if auth is null, trying to read uid triggers a failsafe mechanism that denies the request.

authorize specific documents

Document ID based authorization: We authorize the operation if the document's ID matches some condition:

    match /players/{player_id} {
         allow read: if request.auth.uid == player_id;
    }

Field value based authorization: we authorize the operation based on the value of a specific field. Either from the requested document or from the uploaded document.

  • resource.data represents the requested document.
  • request.resource.data represents the uploaded document
  • For example, we check the requested document's owner property against auth.uid. In this case, we ignore the uploaded document's data which can be tampered with.
    match /planets/{planet_id} {
         allow read: if request.auth.uid == resource.data.owner.id;
    }

If the document is missing the field, the request is denied.

authorization based on separate documents

We read a document with get(). It is a billed read.

first example (do not use)

This unlocks a pattern where we read authorization data in a separate document, such as in the user document, which would store the user's entitlements:

get(/databases/$(database) / documents / users / $(request.auth.uid)).data.rank

For example, we read the user's rank in the database to authorize a write on any character:

    match /characters/{character_id} {
         allow update: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.rank == "Game Master";
    }

Note: We should use Firebase Auth custom claims instead:

request.auth.token.rank == "Game Master";

second example

For example, we read the player's character's zone to determine if it can read the requested overworld character:

match /overworld_characters/{overworld_character} {
     allow read: if get(/databases/$(database)/documents/characters/$(request.auth.uid)).data.zone == resource.data.zone;
}

Note:

  • if a query matches 10 documents, the get() is run only once, so it only triggers a single read
  • if a query, by its nature, could match at least one document that is not authorized by the security rule, the entire query is rejected. This forces the client to query only authorized documents. In the example, it forces the client to use a where() clause instead of fetching all overworld_characters. That is, we cannot rely on security rules to filter documents out of a broad query.

payload validation

request.resource.data is the request's payload. We can validate critical fields:

	// simple check
request.resource.data.age >= 0

  // check against auth uid.
request.auth.uid == request.resource.data.uid;

  // check both requested document and uploaded document
  allow update,delete: if
  request.auth.uid == resource.data.uid
  &&
  request.auth.uid == request.resource.data.uid;

Alternative: We can perform validation in Cloud Functions and forbid writes coming from the client.

Storage

reference

file terminology

Firebase Storage is a wrapper around Google's Cloud Storage, a cloud storage service. It is technically an object storage service because it stores immutable objects in a flat bucket, instead of files in a hierarchical filesystem.

Firebase Storage reintroduces the concept of files, folders and file hierarchy, and uses that terminology exclusively. Among other things, It does so by naming objects with their hierarchical path such as public/abc.png.

project's default bucket (implementation detail)

A Firebase project is given a default bucket, with a given URI:

"gs://<PROJECT-ID>.firebasestorage.app"
"gs://<PROJECT-ID>.appspot.com" // old default bucket URIs

The bucket's URI serves to distinguish it from other buckets.

  • It is made of two components: a gs:// prefix and a domain name.
  • The default bucket's domain uses the project's name as a subdomain, which makes it globally unique.
  • If we add another bucket, we pick a globally unique name by ourselves:
"gs://<GLOBALLY-UNIQUE-ID>" // non-default bucket URI

The URIs only act as identifiers. There is no matching HTTP endpoint and no server listening to, for example, abc.firebasestorage.app.

storage helper

We get a storage helper for use in various storage related functions:

  • We don't name it storage because Firebase already exports a storage variable. We use another name such as storageService or bucket.
  • The client SDK uses the default bucket unless we specify another one in the initializer:
const storageService = getStorage(app)
const storageService = getStorage(app, "gs://...")

// const bucket = getStorage().bucket(); // admin SDK

File references and metadata

file path

A file path uniquely identifies a file in the bucket. It includes the file extension. It starts from the bucket's root.

file reference

  • We use references to interact with files
  • A file reference does not guarantee the file existence.
  • We build them with file (full) paths:
const fileRef = ref(storage, "tts/2F14Izjv.mp3")
// const fileRef = bucket.file("tts/2F14Izjv.mp3") // admin SDK

Note: ref can also build folder references.

The properties are of limited use:

ref.bucket // "abc.firebasestorage.app"
ref.fullPath // "tts/abc.mp3"
ref.name // "abc.mp3"

// computed references
ref.parent // ref(storage, "tts")
ref.root // ref(storage, "/")

file metadata

A file metadata, of type FullMetadata, or FileMetadata on the admin SDK, contains various information about the file:

metadata.size // 1048576 (bytes)
metadata.contentType // "audio/mpeg" (MIME type)
metadata.timeCreated // "2026-01-04T12:34:56.789Z"

metadata.ref // file reference

// repeat from fileRef
// metadata.bucket
// metadata.fullPath
// metadata.name

We fetch an existing file's metadata:

const metadata = await getMetadata(fileRef)

// admin SDK
// const [metadata] = await fileRef.getMetadata()

List files and folders

folders and prefix terminology

The API describes folders as prefixes, but the docs also mention folders.

folder existence

A file, by its name alone, can create one or more nested folders. It occurs when its name contains subpaths. For example, abc/def/hello.pdf creates two folders: abc and def. Those folders are an artificial byproduct.

By design, those folders can't be empty, because they derive from a nested file.

use a folder reference to list its content

We build a folder reference to list its content. It outputs a shallow list: we see the top level files and folders.

The list discriminates files (items) from folders (prefixes), putting them into separate arrays, but both arrays are typed with the same StorageReference element type.

Note: list() is a capped version that expects a count limit.

folderRef = ref(storage, "uploads")

const result = await listAll(folderRef)
const result = await list(folderRef, { maxResults: 100 })

result.items // StorageReference[]
result.prefixes // StorageReference[]

Read, download files

general considerations

  • The client SDK's functions are subject to access-control rules.

    • Some functions allow the user, after access-control, to save a bearer URL which is not subject to security rules. This pattern is a one-off access control.
  • Download workflows are influenced by the browser restrictions regarding the file's URL.

get a HTTP URL on the client

We request a read URL. Access control is performed during such request.

The returned URL is a bearer URL, which is not subject to access-control. We consume it as a regular URL, outside the realm and control of the Storage SDK.

Note: the URL remains valid unless manually revoked at the file level in the Firebase Console, or with the admin SDK.

getDownloadURL(fileRef).then(url => ...)

consume a cross-origin HTTP URL on the client (browser-specific)

The bucket's file lives on a separate origin than the origin that ships the web-app. For the browser, the file's URL is cross-origin. The challenges and patterns to consume a cross-origin URL are not specific to Firebase.

The way we consume the URL determines if CORS headers are necessary:

  • The browser allows cross-origin URLs in media elements' src attribute (hot linking), with no CORS header requirement.
  • The browser allows navigating to cross-origin URLs (basic browser behavior). For example, we navigate to an image in a new tab.
  • The browser doesn't allow background fetch of cross-origin resources unless explicit CORS headers are present on the server. This applies to fetch() and functions that rely on it.

Buckets do not have permissive CORS headers by default, but we can add them on demand. CORS headers whitelist one, several or all domains. We use gcloud to whitelist our domain (see the dedicated CORS chapter).

download a Blob with the client SDK

A blob is an opaque object that we fetch and transform to a local URL for easier saving. When using getBlob():

  • access rules are enforced
  • CORS headers are required (it uses fetch() under the hood)

We create a local (same-origin) URL out of the blob, to avoid the browser restrictions against cross-origin URLs. It restores the ability to download content through a single click, without navigating to a different URL (see below).

getBlob(fileRef).then((blob) => {
    // create a local URL and trigger download imperatively
})

add the download attribute to an anchor tag with a local URL

The download attribute on anchor tags (<a href="" download>) offers one-click downloads for same-origin URLs or local URLs.

For cross-origin URLs, clicking the anchor tag triggers standard browser navigation instead: the browser navigates to the resource and shows its full URL.

create a local URL out of a blob (browser specific)

This example also triggers download programmatically, and revokes the local URL for clean up. We set the user-facing file name by setting the download attribute:

// 3. Create a local URL out of the blob
const objectURL = URL.createObjectURL(blob)

// 4. Use the local URL and trigger the download
const link = document.createElement("a")
link.href = objectURL
link.download = img.id + ".png"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)

// 5. Clean up by revoking the local URL
URL.revokeObjectURL(objectURL)

Upload data

client SDK

upload a Blob or a File

We prepare some data in a JavaScript Blob or File object, and upload it to the reference:

const result = await uploadBytes(fileRef, file) // File object
  • The upload is a non-conditional upsert which overrides existing files.
  • It makes the file immediately downloadable through the SDK read functions.
  • On success, we receive an UploadResult, which wraps the bucket file's metadata and the file reference.
result.metadata // FullMetadata
result.ref

(advanced) upload and track the progress

For each tick, we receive a snapshot. We may show the upload progress.

const uploadTask = uploadBytesResumable(ref, file)

uploadTask.on(
    "state_changed",
    /* on snapshot */
    function (snapshot) {
        // snapshot.bytesTransferred
        // snapshot.totalBytes
        // snapshot.state // "paused" | "running"
    },
    function (error) {},
    function () {
        /* on completion */
        getDownloadURL(uploadTask.snapshot.ref).then(/**/)
    },
)

admin SDK

upload a Node.js Buffer

We prepare some data in a Node.js Buffer, and upload it to the reference.

await fileRef.save(imageBuffer, {
    resumable: false,
    metadata: {
        contentType: `image/png`,
        cacheControl: "public, max-age=31536000, immutable",
    },
})

Note: it doesn't make the file downloadable for clients: a client getDownloadURL() fails. This is because the underlying Firebase-specific download token in the GC storage object's metadata is missing.

make it downloadable

To make it downloadable for clients, we then use the admin SDK's getDownloadURL(). It adds a permanent download token to the underlying GC storage. It also returns the bearer URL (tokenized URL that embeds this very access token, and is not subject to security rules).

We can store it in a database, return it to the client, or discard it and let the client SDK generates the URL on its own with getDownloadURL() (since it is now downloadable).

const url = await getDownloadURL(fileRef)

We can invalidate the access token from the Firebase console. It makes the file non-downloadable. The bearer URL becomes invalid.

advanced: read and write the token

The token, if present, is in the File's metadata field. We should avoid setting this field manually when using save(). We use getDownloadURL instead (see full example below).

metadata: {
  firebaseStorageDownloadTokens: token
}

We can revoke the token:

await file.setMetadata({
    metadata: {
        firebaseStorageDownloadTokens: null,
    },
})

Or rotate the token

await file.setMetadata({
    metadata: {
        firebaseStorageDownloadTokens: randomUUID(),
    },
})

upload image example (admin SDK)

We upload an image and make it readable by clients. We may store the bypass URL.

// 1.0 create a file reference
const fileRef = bucket.file(`generated/${userID}/cat.png`)

// 1.1 create a Buffer object
const imageBuffer = base64ToBuffer(base64Data)

// 1.2 upload the Buffer object
await fileRef.save(imageBuffer, {
    resumable: false,
    metadata: {
        contentType: `image/png`,
        cacheControl: "public, max-age=31536000, immutable",
    },
})
//  1.3 make it readable by client SDKs (generate a token).
const url = await getDownloadURL(fileRef)

//  1.4 store the bypass URL (if applicable)
//  ...

Setting the bucket CORS header

Some read operations require the web client's domain to be whitelisted, through a bucket-side CORS header.

read operations that require a CORS whitelist

Browser reads relying on background fetch rather than navigating to the URL require a CORS whitelist:

  • getBlob(fileRef) to get a Blob, which uses fetch() under the hood.
  • getBytes(fileRef) to get an ArrayBuffer, which uses fetch() under the hood.
  • using fetch() manually with a bearer (tokenized) URL.

white-listing the domain

We add authorized domains to cors.json and send it to Google through the CLI:

cors.json

[
    {
        "origin": ["https://imagetales.io", "http://localhost:5173"],
        "method": ["GET"],
        "maxAgeSeconds": 3600
    }
]

Register cors.json:

gcloud storage buckets update gs://abc.firebasestorage.app --cors-file=cors.json

For debugging, we can get a description of the existing bucket CORS config:

gcloud storage buckets describe gs://abc.firebasestorage.app --format="default(cors_config)"

Cloud Functions

Cloud Functions are serverless functions: we run code on servers operated by Google.

As it is a secure environment, we can run sensitive tasks: authenticate requests, perform server-side validation, use API keys, make sensitive database writes, and more.

Functions trigger on direct requests, or on events happening in the Firebase ecosystem, such as the registration of new users through Firebase Auth. They can also be triggered by a Cron job.

trigger on direct requests: two options

The first option is to configure and establish a bare-bones REST-API endpoint, called a HTTP function. We configure the endpoint with an Express.js like API.

import { onRequest } from "firebase-functions/https"

The second option is to define a Callable function, a pattern that involves both a server SDK and a client SDK, which together do some heavy lifting on behalf of the developer, such as managing authentication, defining the type of the request's data and response, and offering streaming responses.

import { onCall } from "firebase-functions/https"

select and deploy functions

The main file determines which functions are deployed by default: all the ones that it exports. We determine the main file in package.json. Since it must be a JavaScript file, we refer to the TypeScript compiler output file:

{
    "main": "lib/index.js"
}

It is usually a barrel file that re-exports functions from their own files:

export { requestPlayer } from "./requestPlayer.js"

We deploy functions imperatively, all of them or some of them:

firebase deploy --only functions
firebase deploy --only functions:FOO
firebase deploy --only functions:FOO,functions:BAR

We can delete a function imperatively:

firebase functions:delete FOO

configure TypeScript

We use a workflow that transpiles to JS since the functions are deployed as JavaScript functions.

The convention is to store TypeScript code in src/ and transpile towards lib/. The main file is lib/index.js.

tsconfig.json configures the transpilation, targeting the Node.js runtime:

{
    "compilerOptions": {
        "module": "NodeNext",
        "esModuleInterop": true,
        "moduleResolution": "nodenext",
        "noImplicitReturns": true,
        "outDir": "./lib",
        "rootDir": "./src",
        "sourceMap": true,
        "target": "es2022",
        "skipLibCheck": true,

        /* Linting */
        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noFallthroughCasesInSwitch": true,
        "verbatimModuleSyntax": true
    },
    "compileOnSave": true,
    "include": ["src"]
}

use of the admin SDK

The use of the admin SDK is a natural fit from Cloud functions.

Define Callable functions

The code we run in Callable functions has access to the user's authentication status along with the request's data.

Callable functions support streaming responses: we describe the pattern in a dedicated section.

Overview and syntax

synopsis

onCall<ReqData, Promise<ResData>>(options, callback)
onCall<ReqData, Promise<ResData>>(callback)

the callback

The callback has access to the request object (CallableRequest), which exposes auth and data.

We define the callback async so it returns a promise. The connection is kept open until the promise settles.

;async (req) => {}
;async ({ auth, data }) => {}
  • auth is undefined when the request is unauthenticated. It has uid otherwise.
  • ReqData defines the data sent by clients.
  • ResData defines what the callback returns.
onCall<ReqData, Promise<ResData>>(async (req) => {
    req.auth // AuthData | undefined
    req.auth?.uid
    req.data // ReqData
    return { message: "" } // ResData
})

add options

The options object, of type CallableOptions, a subclass of GlobalOptions, sets the region, the secrets, and the number of instances and the concurrency for each instance:

const options: CallableOptions = {
    concurrency: 1,
    minInstances: 1,
    maxInstances: 1,
    region: "europe-west1",
}

An instance can handle several requests at once. By default, Firebase runs with no minimum and no maximum.

  • Since there are no minimum (minInstances defaults to 0), Firebase doesn't run any instance when the endpoint is inactive. When a request is received, it spins up an instance, which requires some time (cold start), and keeps it alive for a while.
  • To avoid cold starts, we can set minInstance to 1, which keeps the instance warm at all time but costs much more.
  • We can limit maxInstances to 1 if we prefer throttling traffic instead of scaling up.

concurrency sets how many requests a single instance processes in parallel. Since it can process multiple requests in parallel by default, We only set it to 1 if we want a clean instance for each request or, if we also limit maxInstances to 1, if we want to process one request at a time.

Streaming version

Streaming the response means to send small chunks of data with sendChunk().

The third type argument (StreamData) defines what kind of chunk we stream. We usually stream string chunks.

The request exposes acceptsStreaming, which we read to check if the client supports streaming. When it does, the callback has access to an extra response argument, on which we call sendChunk().

onCall<T, U, V>(options, callback) // streaming Callable
onCall<ReqData, Promise<ResData>, StreamData>(async (request, response) => {
    if (response.acceptsStreaming) {
        response?.sendChunk("abc") // StreamData
        response?.sendChunk("def")
    } else return { message: ".." } // fallback
})

Patterns

halt and send an error immediately

We throw an HttpsError with a specific error code which conforms to a predefined list. It defaults to internal error if omitted.

throw new HttpsError("unauthenticated", "unauthenticated")

logger

logger.debug("")
logger.info("")
logger.warn("")
logger.error("")

Callable v1 (deprecated)

define the function

functions.https.onCall(async (data, context) => {
    const auth = context.auth
    const message = data.message
    return { message: ".." }
})

the context object

The context object provides the authentication details, if any, such as the email, and the request metadata such as the IP address, or the raw HTTP request. It is of type CallableContext

check authentication

if (!context.auth) {
    throw functions.https.HttpsError("unauthenticated", "you must be authenticated")
}

Invoke Callable functions

On the client, we invoke callable functions through specific handlers provided by the SDK.

configure a functions helper with the firebase project and the region

  • Since a client can interact with separate Firebase projects, we specify the project we target. We do so by providing the app helper, which already identifies the project.
  • Since Cloud function instances are region-specific, we specify which instance region we target. If omitted, the client SDK targets us-central1, which errors if no instance runs there:
const functions = getFunctions(app, "europe-west1")

get a handle over the Callable function

We provide the function's name and the type arguments:

const requestPokemonCF = httpsCallable<ReqData, ResData>(functions, "requestPokemon")

invoke and handle the result

  • The request data, if any, is of type ReqData in our example.
  • The returned value is of type HttpsCallableResult<ResData>, which is a container over the actual data, found in the data property, of type ResData in our example:
const result = await requestPokemonCF({ number: 151 })
result.data // ResData

HTTP functions

overview

Configure and establish a bare-bones REST-API endpoint, called an HTTP function. We use an Express.js like API. We respond with JSON, HTML, or plain text:

export const sayHello = onRequest((req, res) => {
    res.send("Hello from Firebase!")
})

add options

const options: HttpsOptions = {
    region: "europe-west1",
    cors: true,
}
export const sayHello = onRequest(options, (req, res) => {})

ExpressJS concepts and syntax

req and res objects have the shape of expressJS req and res objects. We can add middleware.

call the endpoint: standard HTTP request (not Firebase specific)

We read the function's URL at deploy time.

We consume endpoints like regular REST API endpoints. The deployed endpoint URLs look like this:

https://requestPlanet-x82jak2-ew.a.run.app

Run functions on Auth events

Register functions that react to Auth events. Blocking functions can deny the registration of a user. Non blocking functions run after the authentication event has occurred.

Blocking functions

run a function before the user is added to Firebase Auth

We perform validation, and, if applicable, deny the registration by throwing an error. Firebase Auth aborts user creation on throw. The Auth client SDK receives such error and can display it to the user.

export const onRegisterBlocking = beforeUserCreated(options, async (event) => {
    const userRecord = event.data // AuthUserRecord === UserRecord
    // userRecord.uid
    // userRecord.email

    // 1.0 validate
    if (userRecord?.email?.includes("@hotmail.com")) {
        throw new HttpsError("invalid-argument", "don't use hotmail")
    }
    // 2.0 perform block side effects
    // e.g. add a user document to the database:
    await addUserDocFor(userRecord)
    return
})

Non blocking functions

The non blocking functions run after a user has been created (or deleted) by Firebase Auth.

As of writing, there is no v2 version for the non blocking functions:

auth.user().onCreate(async (user) => {})
auth.user().onDelete(async (user) => {})

example: add the user to the Firestore database (or delete it)

We read the auth user's uid and act accordingly:

export const onRegisterNonBlocking = region("europe-west1")
    .auth.user()
    .onCreate(async (user) => {
        const { uid, email } = user
        // add to db
        await db.doc("users/" + uid).set({ uid, email })
        // delete from db
        await db.doc("users/" + uid).delete()
    })

on Firestore and Storage events

on Firestore events

Run Cloud functions on database events. They are non-blocking: they run after writes. As they don't prevent writes, we don't call it validation. Instead we use the term sanitization.

sanitize data post-write

List of Cloud Firestore triggers.

For example, onDocumentWritten triggers on:

  • onDocumentCreated
  • onDocumentUpdated
  • onDocumentDeleted
export const onUserWritten = onDocumentWritten("users/{docId}", (event) => {
    const docId = event.params.docId // string

    const change = event.data
    // Change<QueryDocumentSnapshot | DocumentSnapshot> | undefined

    const before = change?.before.data() // DocumentData | undefined
    const after = change?.after.data() // DocumentData | undefined
})

on Storage events

sanitize data post-upload

the user uploads a file to Firebase Storage. Sanitize data post-upload. For example:

import { onObjectFinalized } from "firebase-functions/v2/storage"

export const generateThumbnail = onObjectFinalized(async (event) => {
    const file = event.data

    // const fileBucket = file.bucket;
    // const filePath = file.name;
    // const contentType = file.contentType;
    // const metageneration = file.metageneration;
    // // Number of times metadata has been generated. New objects have a value of 1.
})

Dates and Timestamps serialization

ISO strings are the better choice

When interacting with Callable Functions, it's best to represent dates as ISO strings. The value and the type stay consistent when sending and receiving on the client and on Cloud functions.

In this article, we explain what happens when we send Date and Timestamp objects to Callable Functions or when we receive them from Callable Functions. Before being sent, both Date and Timestamp are serialized to JSON.

sending Date and Timestamp to Callable Functions (do not use)

Timestamp is a Firestore specific type and doesn't get a special treatment: it serializes to an object with seconds and nanoseconds (through toJSON()).

// on the Cloud function receiving end
type T = { seconds: number; nanoseconds: number }

Date fields serialize to an ISO string (through toJSON()):

date: "2023-10-08T07:54:47.527Z"

When receiving in Cloud functions, we have a type difference between RequestData on the client and RequestData in Cloud functions.

We could manually instantiate Timestamp or Date instances, but it requires manual processing:

new Date(date) // build a Date
new Timestamp(timestamp.seconds, timestamp.nanoseconds) // build a Timestamp

returning data from Callable functions

If we attempt to return a Date object, it serializes to an ISO string as well, through the same mechanism.

If we attempt to return a Timestamp object, it serializes to the internal representation specific to the admin SDK, which is different than the client SDK's Timestamp representation:

possible an object with _seconds and _nanoseconds. We should avoid this pattern:

// on the client SDK receiving end
type T = { _seconds: number; _nanoseconds: number }

Environment variables

Firebase provides a way to register and manage secrets through the CLI. We then read them from Cloud functions, both from deployed functions and from functions running locally in the emulator.

As such, we don't need to store secrets in local .env files.

Manage secrets

set, read and destroy secrets through the CLI

firebase functions:secrets:set ABC_API_KEY
firebase functions:secrets:access ABC_API_KEY
firebase functions:secrets:destroy ABC_API_KEY

gcloud secrets list --project <PROJECT_ID>

Note: the secrets are available both locally (function emulator) and for deployed functions.

read secrets from cloud functions

We declare the secrets requirements in the function options. We then read them from process.env:

const options: CallableOptions = {
    // ..
    secrets: ["ABC_API_KEY"],
}

onCall<ReqData, Promise<ResData>>(options, async (request) => {
    const abcKey = process.env.ABC_API_KEY
})

Https functions:

const options: HttpsOptions = {
    // ..
    secrets: ["ABC_API_KEY"],
}

onRequest(options, (req, res) => {
    const abcKey = process.env.ABC_API_KEY
})

.env file pattern

The .env file pattern should be only used if the CLI pattern is not available or not applicable. We set the env variables in a .env file:

ABC_API_KEY=xxx

The key is then made available to cloud functions the same way as seen above, at process.env, both locally and for deployed functions, as long as the key is listed in the options.

On deploy, the .env file is automatically detected and deployed along functions. See env-variables docs

Debug Functions locally

start the functions emulator

We run the functions with other emulators, or on their own:

firebase emulators:start
firebase emulators:start --import emulator-data --export-on-exit

firebase emulators:start --only functions
npm run serve

invoke callable functions through the SDK

We redirect client invocations towards the emulated functions, but only on localhost:

if (location.hostname === "localhost") {
    // ...
    connectFunctionsEmulator(functions, "localhost", 5001)
}

invoke callable functions outside the client SDK

We can invoke callable functions outside the client SDK as long as the function emulator is running.

The emulator exposes them on specific URLs. We get the URLs when starting the emulators:

http://localhost:5001/imagetale/europe-west1/get_images

Callable functions can be invoked:

  • directly, with a local HTTP request
  • through the Firebase REPL shell.

In both cases, the request's data must be present in the data key of the request's body (JSON). The data key must exist.

Note: We turn off authentication requirements when calling callables outside the client SDK

calling functions with curl

We can invoke callable functions with curl:

curl -H "Content-Type: application/json" \
  -d '{ "data": { } }' \
  http://localhost:5001/imagetale/europe-west1/get_images

calling functions with the Firebase REPL

functions:shell starts the functions emulator and an interactive CLI shell from which we invoke callable functions.

firebase functions:shell

We provide the data key and its content, which represents the request's data:

generateExample({ data: { word: "你好" } })

Schedule Cron jobs

periodic code execution: schedule a Cron job

We schedule a Cron job, with the help of Google Cloud's Cloud Scheduler. We register a job with onSchedule. We set:

  • the periodicity, using strings such as every day 00:00 or every 8 hours.
  • the timezone (IANA string), the region and the callback function:
export const updateRankingsCRON = onSchedule(
    {
        schedule: "every day 00:00",
        timeZone: "Europe/London",
        region: "europe-west1",
    },
    async () => {
        // ...
    },
)

The older API (v1) relied on pubsub instead:

export const updateRankingsCRON = functions.pubsub
    .schedule("every 8 hours")
    .timeZone("Europe/London")
    .onRun(async (context) => {
        // ..
    })