Organize Cloud Functions for max cold start performance and readability with TypeScript and Firebase

Doug Stevenson
Mar 2 · 7 min read

A while back I made a video about minimizing cold starts for functions deployed with the Firebase CLI using TypeScript:

It sums up the issue like this:

Suppose you have multiple functions exported from your index.ts that don’t all use the same static imports, like these two functions httpFn and firestoreFn:

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
admin.initializeApp()export const httpFn =
functions.https.onRequest(async (request, response) => {
// This function uses the Firebase Admin SDK
const snapshot = await admin.firestore()
.collection('users')
.doc('uid')
.get()
const data = snapshot.data()
response.send(data)
})
export const firestoreFn =
functions.firestore.document("users/{id}")
.onCreate(async (snapshot, context) => {
// This function does not use firebase-admin,
// and unnecessarily pays the cost of its import and init
console.log(snapshot.data())
return null
})

httpFn uses the Firebase Admin SDK to query Firestore, but firestoreFn does not. This means that firestoreFn pays an unnecessary cost to cold start performance by importing an initializing the Admin SDK at the global scope. You can get a cold start improvement for it (and reduced memory usage) by refactoring httpFn using TypeScript’s dynamic “async” import at the time of function invocation instead of a static import for the entire file, like this:

import * as functions from 'firebase-functions'// Don't import and init firebase-admin at the global scopelet is_admin_initialized = falseexport const httpFn =
functions.https.onRequest(async (request, response) => {
// Instead, dynamically import the Admin SDK here, in the
// body of the function.
const admin = await import('firebase-admin')
// Only initialize the admin SDK once per server instance
if (!is_admin_initialized) {
admin.initializeApp()
is_admin_initialized = true
}
const snapshot = await admin.firestore()
.collection('users')
.doc('x')
.get()
const data = snapshot.data()
response.send(data)
})

This works just fine. The import of firebase-admin above is asynchronous and returns a promise, so we can use await to continue after the import is done.

(One quick note— async imports are not repeated if the module was previously imported on the same server instance. The imported module is parsed and cached during the first import, then reused on subsequent imports.)

The cold start performance benefit is real for firestoreFn, since it no longer unnecessarily imports and initializes the Admin SDK at the global scope. However, this scheme causes some headaches if you need to scale up the amount code you need to write for the function with the async import. Things get tricky when you want to break up a long function into sub-functions, or share common utility functions with other code. Take a look at this refactored httpFn that delegates some work to a regular JavaScript function called getDocument:

export const httpFn =
functions.https.onRequest(async (request, response) => {
const admin = await import('firebase-admin')
// ... redacted admin.initializeApp() ...
// TypeScript understands the type of `admin` here.
// Would like to pass admin to getDocument, but you can’t...
const snapshot = await getDocument(admin)
const data = snapshot.data()
response.send(data)
})
// Below, where does the admin param type get specified?
// It’s no longer available in the global scope,
// so we can’t use it. Bummer.
async function getDocument(admin) {
// Also no IDE autocomplete support for admin here,
// because it doesn’t know the type
return admin.firestore().collection('users').doc('x').get()
}

The above code isn’t typesafe, and won’t even compile because the type of admin isn’t visible at the global scope of the script. Since we moved the import of the Admin SDK out of the global scope to inside the code of the Cloud Function definition, the use of that type is constrained to the body of that function, and nowhere else.

An alternative is to “hoist” getDocument into the Cloud Function scope like this:

export const httpFn =
functions.https.onRequest(async (request, response) => {
const admin = await import('firebase-admin')
// ...redacted admin.initializeApp()...
const snapshot = await getDocument()
const data = snapshot.data()
response.send(data)
// YUCK, getDocument isn't shareable outside this file
async function getDocument() {
return admin.firestore().collection('users').doc('x').get()
}
})

But this is kind of ugly, and you now can’t share getDocument with other code outside this file.

This is all just how TypeScript works. The entire situation, while efficient for cold start at runtime, is kind of annoying. It’s much more convenient to use a single static import at the top of the file to make the Admin SDK available to all code through the entire file. We just don’t want to pay the cost of that import unnecessarily for other Cloud Functions that don’t use it.

There’s another problem with code scale: when the number of Cloud Function definitions in a single index.ts grows very large, it becomes cumbersome to manage all that code together in that one file. It would be so much better to put each function implementation in separate files, to make each one easier to work with.

Fortunately, we can solve all of these issues with a more clever use of TypeScript async imports.

Here’s how to improve the situation

Before we move on, realize that there are three rules that need to be followed here:

  1. index.ts must export all the Cloud Functions we intend to deploy to a single project. This is just how the Firebase CLI works. The only way around this is to make a different workspace for each function for individual deployment, which is cumbersome.
  2. The only truly common module required by all Cloud Functions deployed by the Firebase CLI is firebase-functions. So, for performance reasons, that module, and only that module, will be imported at the global scope in index.ts.
  3. Each individual Cloud Function implementation should exist in its own file, to make it easier to work with, and it must only import the modules it needs at its own global scope.

Given these three rules, here’s a pattern we can use. First, implement each Cloud Function export in index.ts using an async import of the actual implementation from another TypeScript source file in a nested folder called “fn”. The naming convention should be clear:

import * as functions from 'firebase-functions'export const httpFn =
functions.https.onRequest(async (request, response) => {
// Move the implementation of this function to fn/httFn.ts
await (await import('./fn/httpFn')).default(request, response)
})
export const firestoreFn =
functions.firestore.document("users/{id}")
.onCreate(async (snapshot, context) => {
// Move the implementation of this function to fn/firestoreFn.ts
await (await import('./fn/firestoreFn')).default(snapshot, context)
})

The one line of implementation in each function says:

  1. Asynchronously import another source file, and wait until that’s done.
  2. Call its default exported function, passing along our parameters. It returns a promise, so wait until that’s done as well.

Now, within each per-function source file added under the “fn” folder, we’re free to import each necessary module used by that function at the global scope, while exporting only a single implementation function.

Here’s fn/httpFn.ts, which does import and initialize Firebase Admin:

import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
admin.initializeApp()// Note: Need to be explicit about parameter types here for HTTP
// type functions, to support type safety and IDE assistance.
export default async (
request: functions.https.Request,
response: functions.Response
) => {
// Here we can use the admin SDK to get a document
const snapshot = await admin.firestore()
.collection('users')
.doc('uid')
.get()
const data = snapshot.data()
response.send(data)
}

And here’s fn/firestoreFn.ts, which does not use the Firebase Admin SDK at all, so it does not pay its cost:

import * as functions from 'firebase-functions'// Again, explicit about parameters types for Firestore onCreate
// type functions.
export default async (
snapshot: functions.firestore.DocumentSnapshot,
context: functions.EventContext
) => {
console.log(snapshot.data())
return null
}

Note that both per-function files export a single “default” function, which is used by the per-function import in index.ts. Also note that I chose to be specific about the parameter and return types in the function declarations. This is important, so that I can work with type safety and IDE assistance in the body of the implementation function. If you use other types of triggers, you’ll have to dig up their proper parameters and return types from the API documentation or TypeScript bindings. Command-clicking a function name in VS Code helps to discover these definitions! (As a shortcut for you, I’ve provided a GitHub repo that shows skeletons of all the possible types of functions implemented like this.)

With the dynamically imported files fn/httpsFn.ts and fn/firestoreFn.ts above, the static imports and global scope for both are not evaluated in the context of the top-level index.ts, and only evaluated at the time of import. So, we can be sure that the parsing of each static import will be deferred until the first invocation of the Cloud Function that actually needs it.

With this organization, we can also refactor to easily to call utility functions that need to use the static imports. getDocuments below can now make use of admin at the global scope with full typing in fn/httpFn.ts:

import * as functions from 'firebase-functions'// The global import and init now only happen for this one function.
import * as admin from 'firebase-admin'
admin.initializeApp()
export default async (
request: functions.https.Request,
response: functions.Response
) {
const snapshot = await getDocument()
const data = snapshot.data()
response.send(data)
}
// No problems here using the global admin for the query
async function getDocument() {
return admin.firestore().collection('users').doc('x').get()
}

Great for both cold starts and code organization

Using async imports to move global imports out of index.ts and into other files, we have a project file structure that looks like this:

functions/
src/
index.ts
fn/
httpFn.ts
firestoreFn.ts

The benefits of all this refactoring are the following:

  • index.ts now only contains an import of firebase-functions, and function definitions, each implemented with a single async import and call to a delegate function.
  • Delegate functions are now free to use global imports in their own files for convenience.
  • This convenience does not cause any unnecessary cost to cold start performance.

Each function definition now essentially looks like this, with the details hidden behind an async import:

Be sure to check out my GitHub repo showing each distinct type of Cloud Function supported by the Firebase CLI.

Firebase Developers

Tutorials, deep-dives, and random musings from Firebase developers all around the world. Views expressed are those of the authors and don’t necessarily reflect those of Firebase or its parent companies.

Doug Stevenson

Written by

Director of Developer Relations at Mesmer (mesmerhq.com)

Firebase Developers

Tutorials, deep-dives, and random musings from Firebase developers all around the world. Views expressed are those of the authors and don’t necessarily reflect those of Firebase or its parent companies.

More From Medium

More from Firebase Developers

More from Firebase Developers

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade