Organise Firebase Functions Part 1 – Minimise Cold-Start Time

George
9 min readMay 10, 2020

Optimally speed up your Firebase Cloud Functions using these code patterns

This post uses tools found in better-firebase-functions npm package to help you speed up and organise your Firebase Cloud Functions and write better code.

Function Naming & Source Code Organisation

When writing Firebase functions, there are a few ways that you can structure your files and directories.

The most common way to begin writing functions is to simply have one index.ts file, which is the entry point of your application. This file then exports all of the cloud function triggers used in your application. This method might work initially for small projects, but soon you will find that you end up with a monolithic index.ts file with all of the code of your entire application.

It is also possible to store your logic/code in other files, and then import these into your index.ts entry point file for export as function triggers. Getting better, but still not ideal, as you need to keep track of all of your functions in various files, and ensure that you keep them in sync with your main index.ts entry point file. This method does not enforce any kind of organisational structure or naming convention for your files or functions. Your src folder can get really messy fast, especially with multiple devs.

functions/
- package.json
- lib/
- src/
- index.ts
- auth.ts
- chat.ts / These are all monolithic files...
- transactions.ts
- util.ts
- profiles.ts
- interfaces.ts
- trips.ts
- history.ts
- purchases.ts
- geoLocation.ts
- syncUsers.ts
- mods.ts
- admin/
- users.ts
- emails.ts
- moderators.ts
...

A mistake often made is naming files after what they do, rather than what is triggering them. The reason you want to name your files after their trigger is that this is essentially the API layer of your app.
What those files are meant to do, is connect their trigger.

Structure

A well-structured node application is often separated into layers. API / ROUTING, SERVICES, MODELS, ORM, etc…
When using Firebase, a lot of the heavy lifting is taken care of for you. Firebase is an RDT — Rapid Development Tool, that aims to support startups into maturity.

Ideally, you would want to find a middle-ground between a fully-fledged application architecture and a hacked-together monolithic source code file. There is a certain level of abstraction provided by Firebase, and the structure needs to be suitable for this level.

This is exemplified by the fact that much of the Firebase documentation couples together the business logic with data persistence. The reason these sometimes happen on the same architecture layer is because of the way something like, for instance, runTransaction works. You must pass in your business logic to the transaction function. This is also why it’s challenging to write an ORM layer for Firestore whilst retaining the ability to use transaction and batch, which provide important performance benefits.

I will show you my solutions to the above problem later in the guide. There is a fine balance between structure and optimisation that I’ve carved out after months of trial and error.

Here’s A Really Good Way to organise your files and directories. Since the function triggers are essentially the API (or routing) layer of your node app, their files should be named after their trigger, since that’s what they actually do, they link your business logic to a function trigger, hence creating your API — application programming interface.

functions/
- package.json
- lib/ (compiled JS)
- src/
- index.ts (entry point only contains exportFunctions() )
- api/ (all function triggers in API layer)
- auth/
- on-create.ts
- on-delete.ts
- http/
- callable-function-name.ts
- users/
- update-profile.ts
- db/
- users/
(named after collection)
- on-create.ts
(path matches trigger)
- services/ (imported by API, contains business logic)
- auth.ts
- transactions.ts
- orders.ts
- models/ (imported by services, contains logical entities)
- user.ts
- payment.ts
- product.ts

The API Layer

As you can see above, in the api folder, each function trigger has its own file, and each file is named after its trigger. Triggers are also grouped by their type.

Rules

  • Each file/module has one default export which is the Firebase trigger.
  • The API layer is in the api folder, which has subfolders db, auth, http, storage for different trigger types.
functions/
api/
db/
auth/
http/
storage/
  • Filenames are kebab-case (a best-practice due to case-sensitivity issues with different systems).
  • Files are named after their specific trigger type, on-write, on-delete, etc…
  • Sub-folders (ie the file-path) denote different collections, URLs, folders, etc… according to trigger type, so, for example, the directory structure should reflect the database structure.
api/
db/
users/ - collection name
onCreate.ts
onDelete.ts
profiles/
onWrite.ts
chat/
onUpdate.ts

Benefits

  • Scalable.
  • Organised, easy to follow and find.
  • Multiple devs can follow convention.
  • Efficient — rename a function by renaming its file.
  • No need to update index.ts when creating a new trigger (see why below).

exportFunctions()

Let me introduce you to the main utility found in better-firebase-functions, the exportFunctions() method.

import { exportFunctions } from 'better-firebase-functions'

Read on to understand the benefits and how to use it.

Organisation & Convention

This file structure scales to hundreds of functions easily and is easy to follow for all the devs working on your project. It’s trivial to find any function trigger in your API. Another benefit is easier deployment. exportFunctions automatically groups your folders as submodules, enabling you to do partial deployments like so:

firebase deploy --only functions:auth, db.users
// This may also be
firebase deploy --only functions:auth, functions:db.users

In the above example, we were able to specify the specific deployment of all the auth triggers, as well as the Firestore triggers that affect the users collection.

This is because of the way that the above folder structure is translated into function names by theexportFunctions utility:

List of function trigger names:auth-onCreate // Created from auth/on-create.ts automatically!
auth-onDelete
http-callableFunctionName
http-users-updateProfile
db-users-onCreate

Directories create dashed submodules that are able to be deployed in groups, whereas filenames are converted into camelCase, following best practices for both function names and filenames at once.

Performance & Cold-Start

One of the reasons cold-boot times increase as your project grows has to do with the way that Firebase Functions are deployed on Google servers. Each function trigger has its own “instance” which runs your code on deployment and then goes to “sleep”. So you can imagine that each function has its own “VM” that is sleeping, waiting to be triggered and run. During a cold-start (“waking up”), the function instance usually has to load all of the dependencies at the global scope of the app.

As your project grows, you will likely import more and more external libraries, classes, and instantiate more objects in memory. Each function will probably have its own specific list of dependencies. When you have a myriad of functions in one file, the global scope can become polluted.
For example:

import * as admin from ‘firebase-admin’;
import * as functions from ‘firebase-functions’;
import * as funcOneDep from ‘heavy-library’;
import * as funcTwoDep from ‘heavy-library’;
export const funcOne = functions.auth.onCreate(() => {
// This instance loads both funcOneDep and funcTwoDep into memory
funcOneDep.doStuff();
});
export const funcTwo = functions.auth.onCreate(() => {
// This instance loads both funcOneDep and funcTwoDep into memory
funcTwoDep.doStuff();
});

Initially, you may try to mitigate this via lazy-loading:

import * as functions from ‘firebase-functions’;
let funcTwoDep;
let funcOneDep;
export const funcTwo = functions.auth.onCreate(() => {
// This will only load heavy-library when function is triggered
funcTwoDep = funcTwoDep ?? require(‘heavy-library’);
funcTwoDep.doStuff();
});

But again, as your project grows, this becomes more cumbersome and complicated. You have to keep track of multiple let declarations and make sure you lazy load every dependency that your individual function might need, while making sure NOT to lazy-load dependencies that ALL of your functions will need, as variables that are outside of the scope of the function, per separate function instance, are kept in memory in between function invocations.

So, how does our exportFunctions() solve all of this?

Each function is contained within its own file, and the special way that exportFunctions() works ensures that each function instance only loads the dependencies needed for that particular function.

For example, you can do this:

// src/auth/onCreate.tsimport * as functions from ‘firebase-functions’;
import * as dep from ‘heavy-library’;
export default functions.auth.onCreate(() => {
// No need to lazy load!
dep.doStuff()
});

And the global scope of the previous function will be automatically separated from the global scope of the following function. The dependencies WILL NOT be loaded from the function above when executing the second function below:

// src/auth/onDelete.tsimport * as functions from ‘firebase-functions’;
import * as dep2 from ‘heavy-library’;
export default functions.auth.onDelete(() => {
// No need to lazy load!
dep2.doStuff()
});

This means that your global scope, lazy loading etc… is essentially “automagically managed” for you. You simply import exactly what each function needs at the top of its file, and this will never impact other function triggers. These imports are kept in-memory between function invocations (when not cold-booting).

Example

Entry Point

Your index.ts file will look something like this:

import { exportFunctions } from 'better-firebase-functions'exportFunctions({__filename, exports})

Two lines, that’s it!

It’s possible to highly customise this function and enable performance logging if need be, just read the documentation. A custom function can be supplied in the setup parameter to implement your very own naming convention.

Function Trigger Files

Let’s say you have a function, auth-onCreate, at the path functions/src/api/auth/on-create.ts

import { auth } from 'firebase-functions'
import userService from 'src/services/user'
export default auth.onCreate( userRecord => {
validate(userRecord)
return userService.PopulateUserData(userRecord) // Returns promise
}

And that’s the entire routing layer of your application. It simply links up the real world, or frontend, with the correct specific function found in your service layer, where your business logic is stored. Pretty neat, eh?

Customisations

You can customise the behaviour of exportFunctions() with the config object. For example:

exportFunctions({
__filename,
exports,
funcNameFromRelPath: myFuncNameAlgo,
extractTriggers: (module) => module?.namedExport;
});

The funcNameFromRelPath property is a function that accepts a string and returns a string. The input is a string which is the relative path to your module from your index file or otherwise specified root folder, for example, auth/onCreate.js or even just sample.func.js if the module is in the root folder. If specifying a custom function, it must take this path and convert it into a function name to be used with Firebase. The names can be dash separated to denote submodules or deploy groups, for example, by default, directories create deploy groups: auth-onCreate is the name returned by the default function when provided with auth/on-create.js.

The extractTrigger function allows you to customise how the function trigger is extracted from the module. The default behaviour is to simply look at the default export, which is targeted by export default. But you may customise this behaviour to extract a named export instead, for example, by using (module) => module?.namedExport. The input is the actual module as an object (CommonJS), and the expected output is the function trigger. If a trigger is not found, return a falsy value such as null or undefined. The export will automatically be skipped.

Blazing Fast Performance

When building this tool and writing this article, I discovered even more ways to further increase the performance of cold-starting function instances.

Bundling with Webpack – the backend function is in some ways, quite similar to a browser loading a webpage and executing it’s bundled JS. And so, similar techniques can be used to optimise backend JS when it comes to lambda-style backend functions (Like Firebase Functions)

It’s even possible to avoid having to use dynamic imports at all if that doesn’t work with your codebase, by using some smart entry point configuration and generating an index file with static imports.

I will describe the above-mentioned techniques in Part 2 — until then, if you found this post helpful and your company is looking to hire a diligent problem-solver, job offers are currently welcome :)

What’s next? The Service Layer.

In the next part of this series (coming soon), I’ll cover a new bundling tool I’m developing, as well as different techniques to manage this optimisation within your build process. I’ll cover this technique when using a NRWL managed monorepo as well. Since we’ve mostly covered the ‘API’ layer at this point, we’ll also discuss the how to optimally structure the next level down (the service layer) of your project code, in part 2.

--

--

George

Passionate about coding, web development, AI, STEM, entrepreneurship, SEO/Marketing/Sales and all things tech.