Captain Codeman Captain Codeman

Lazy-Loading Firebase with SvelteKit

Achieve top Lighthouse LCP Scores

Google’s Firebase team recently released a new Modular Firebase Web SDK which promises to reduce the amount of Firebase code in your app by making it “tree-shakable”. This means your bundler only needs to add the code you actually use, not the entire library.

Big Bundles Bad!

It’s defintiely a step in the right direction, but slightly disappointing that we didn’t get anything truly, ~~“svelte”~~ erm … I’ll say “tiny”, such as this 2kb firebase auth package because the Firebase bundles still become pretty large pretty quickly.

Even worse, they no longer appear to offer the option to just load the Firebase libs from a CDN. Even though they were big, you could at least lazy-load them so they weren’t in your app bundle at all. Now they push you to bundling their code in your app which of course makes things simpler to implement but can cause performance issues.

Adding several hundred kilobytes of JavaScript to your app can slow your startup time, limit your Lighthouse Score and cripple your Core Web Vitals. Ouch. No one wants that. But how do you use the new Modular Firebase SDK and still achieve fast app startup times and lazy-loading?

We’ll explore how to use Firebase Auth specifically with Svelte / SvelteKit. Our aim is to make the Firebase code small and lazy, so it is only ever loaded if it’s needed and it doesn’t slow down our app startup. At the same time, it should hopefully be a useful guide on how Svelte Stores work and can be used.

Firebase Auth in a Svelte Store

First of all, what is a Svelte Store? Well, it’s a simple way to share some state within our app which will trigger UI updates anytime the state changes. Stores are small, elegant but amazingly powerful as we’ll see.

Let’s start with our basic auth store. What would we want it to contain? Well, the auth state for starters together with some methods to allow us to trigger signing in and signing out. So let’s start with this simple stubbed out implementation:

import { readable } from 'svelte/store'

function createAuth() {
  const { subscribe } = readable(undefined)

  function sign_in() {
    // trigger sign-in
  }

  function sign_out() {
    // trigger sign-out
  }

  return {
    subscribe,
    sign_in,
    sign_out,
  }
}

export const auth = createAuth()

I like to encapsulate the store code inside a factory function, in this case createAuth, and we call that to create the auth instance that we can import in the rest of the app. The value of the auth store can be used in Svelte components by prefixing it with a $ symbol and Svelte automatically handles subscribing and unsubscribing for us. Any time the store value changes, anything depending on it will be updated to. Magic!

Here’s how we might import it, render the state value and call the methods it provides:

<script lang="ts">
  import { auth } from '$lib/auth'
</script>

Auth state is {$auth}

<button on:click={auth.sign_in}>Sign In</button>
<button on:click={auth.sign_out}>Sign Out</button>

Modelling Firebase Auth State

Of course right now that’s going to always show that the auth state is undefined because that is the value used to initialize the store. There are actually three auth states to handle with Firebase - authenticated (signed in), unauthenticated (signed out) and unknown. That’s because there is always a period before the firebase lib has initialized and checked the auth state where the state is really undetermined, so undefined seems like the perfect way to represent it. We’re going to use null to indicate that the user is signed out and will set the Firebase user object when the user is signed in.

An alternative would be to have a separate flag to indicate if the status was known yet or store an unresolved Promise in the store that is resolved when the status is known. I like using undefined as it’s simple, a single value but allows for all three states.

Read-Only Svelte Stores

Worth noting is that our store is read-only. It is of course created as a readable store, which means it can’t be updated externally even from within the createAuth function where it’s defined. But even if it were to be initialized as a writable store the fact that we only return the subscribe function is what makes it read-only to the rest of the Svelte app. If a store is managing it’s own state, it shouldn’t allow itself to be updated from outside.

This is important - if you don’t want someone to mis-use your store, always think about whether it should be writable. In this case, someone may imagine that setting the state to null would sign the user out - it wouldn’t, but it would make for an inconsistent UX. Even if that someone is you, it helps to avoid accidental bugs (e.g. from a typo making a comparison into an assignment).

// don't make stores that should manage their own state writable!
// this would just make for an inconsistent UI
$auth = null

Setting Internal Store State

So if the store is only readable, how do we ever set the auth state inside it? Well, the next piece of magic is that Svelte stores can take an additional start / stop function as a parameter. This function is run anytime the first subscriber attaches to the store and returns a function that is called whenever the last subscriber disconnects. A set function is passed in (what a writable store returns) which allows the store state to be set, even asynchronously, so the store can do it’s own thing. It’s very simple, but incredibly powerful.

This is the function signature showing how it could log when a subscriber first connects and then when the last subscriber disconnects. Nothing would happen if additional subscribers connect and disconnect in between.

const { subscribe } = readable<User>(undefined, set => {
  console.log('someone is watching us!')
  return () => console.log('where did everybody go?')
})

Let’s step through what our store start / stop function is going to do.

Start / Stop Implementation

First, we are going to want to return the stop function that can be called when all subscribers have disconnected and can stop us listening to the firebase auth state. You might think that the Auth Status will always be displayed, so it doesn’t matter, but it’s good practice to “do things right” and the same patterns will apply to other stores later (e.g. subscribing to data from Firestore).

Initially, this will be a no-op function and we’ll return it at the end. The reason for making it a no-op is that there’s a chance that something connects and disconnects before other parts of the code has executed, so it needs to be defined because it would still be called.

const { subscribe } = readable(undefined, set => {
  let unsubscribe = () => {}

  // TODO

  return unsubscribe
})

Execute on Client Only

The code that we’re going to run is asynchronous, so to allow us to use the nicer async / await syntax we’re going to add an asynchronous function and call it (we can’t make the start / stop function itself async). The firebase auth code only runs on the client browser so to keep this store SSR friendly we’re going to take advantage of SvelteKit and use the browser flag it provides. On the server, this code will be tree-shaken out and won’t execute. While we’re adding an import for that, let’s also import the Firesbase User type so the rest of our code can use strong-typing when accessing the store:

import { browser } from '$app/env'
import type { User } from 'firebase/auth'

// ...other code...

const { subscribe } = readable<User>(undefined, set => {
  let unsubscribe = () => {}

  async function init() {
    // TODO
  }

  if (browser) {
    init()
  }

  return unsubscribe
})

Initialize Firebase App

Finally, on to the implementation itself! For this we want to dynamically import the firebase auth package but there are actually two parts to this. First, we need to initialize a firebase app and because this can be used by all firebase services (auth, firestore, storage etc…) we will separate this out into it’s own small module:

import { initializeApp } from 'firebase/app'

export const app = initializeApp({
  apiKey: "ABCaSyA6xqywOoYGNK5_1234_999ABCXYZ123",
  authDomain: "example.firebaseapp.com",
  databaseURL: "https://example.firebaseio.com",
  projectId: "example",
  storageBucket: "example.appspot.com",
  messagingSenderId: "9387492384792",
  appId: "1:8347625983984:web:de5a90cfd28dc8bf"
})

Import Firebase Code

We’ll import this inside our init function together with the functions we need to use from the firebase/auth package:

const { app } = await import('./app')
const { getAuth, onAuthStateChanged } = await import('firebase/auth')

Note that we could avoid the cumulative latency of awaiting them in sequence by instead using await Promise.all([ ... ]) to wait for them to load in parallel, but the code would be more complex and the gain is minor in this case.

The getAuth function simply initializes and returns the firebase auth service for the app, then the onAuthStateChanged function provides a listener to the auth state.

const auth = getAuth(app)
unsubscribe = onAuthStateChanged(auth, set)

Anytime the auth state changes, such as a user signing in or out, the function passed to it will be called. In this case, we simply want to set the store state to the user which will either be a Firebase User object or null, so we just pass on the set function that the store provided to our start / stop function.

This is equivalent to the slightly more verbose:

unsubscribe = onAuthStateChanged(auth, user => set(user))

We also now re-set the subsubscribe variable to the return value of the onAuthStateChanged function. This provides the way for our listener to stop handling updates when the final subscriber disconnects.

More Complex Auth Scenarios

This would also be the place to handle more complex scenarios such as exchanging the firebase auth token for a custom minted one or calling an endpoint to set a server-side session cookie to enable Server Side Rendering.

Custom Store Methods

Now we’ll move on to the custom store methods that will allow triggering sign-in and sign-out of firebase auth. Because we are going to use the same firebase auth service in these it makes sense to change our earlier init function slightly and capture the auth variable at the createAuth scope. Although we could re-import and re-call getAuth (and it wouldn’t be as inefficient as it sounds) it is simply exta code and simpler this way.

Implementing the custom functions is quite straightforward. We dynamically import the methods that they use and call them. Currently, all the firebase auth code ends up bundled into one file, but it’s possible in future that Vite / SvelteKit will conspire to make our imports even more granular for added efficency in future.

async function sign_in() {
  const { signInWithRedirect, GoogleAuthProvider } = await import('firebase/auth')
  await signInWithRedirect(auth, new GoogleAuthProvider())
}

async function sign_out() {
  const { signOut } = await import('firebase/auth')
  await signOut(auth)
}

AuthStatus.svelte Component

Before we show the complete code, let’s go back to the auth status and show a proper example with the three different states being handled. Any reference to $auth will be completely typed and provide all the Firebase User object properties.

<script lang="ts">
  import { auth } from './firebase/auth'
</script>

{#if $auth === undefined}
  <p>Waiting auth status</p>
{:else}
  {#if $auth === null}
  <p>Anonymous Visitor</p>
  <button on:click={auth.sign_in}>Sign In</button>
  {:else}
    <p>Welcome {$auth.displayName} ({$auth.email}})</p>
    <button on:click={auth.sign_out}>Sign Out</button>
  {/if}
{/if}

It’s also possible to make an auth component that will render a <slot> if the user is authenticated or a login component if they are not, as a simple auth-guard. This works great if you have a part of your app that requires authentication (esp. if you can put it in a SvelteKit __layout.svelte component)

Lazy Loaded Components

One final question, is why we lazy-load the firebase libs and not the Auth Status component itself? Well, for a few reasons:

  • It’s generally more work to lazy-load a svelte component for negligable additional benefit (the Svelte code is already being loaded for the app)
  • It’s easier to have the “lazy” flow through when other stores are concerned, if we’re loading data based on the user auth status for instance
  • Because the client libs aren’t SSR compatible, it’s the perfect place to circuit-break the loading of them

Complete Code

Here is the completed auth store code. You can also checkout a more complete svelte example github repo.

import { readable } from 'svelte/store'
import { browser } from '$app/env'
import type { Auth, User } from 'firebase/auth'

function createAuth() {
  let auth: Auth

  const { subscribe } = readable<User>(undefined, set => {
    let unsubscribe = () => {}

    async function init() {
      if (browser) {
        const { app } = await import('./app')
        const { getAuth, onAuthStateChanged } = await import('firebase/auth')

        auth = getAuth(app)

        unsubscribe = onAuthStateChanged(auth, set)
      } else {
        // TODO: set auth on server from session (?)
      }
    }

    init()

    return unsubscribe
  })

  async function sign_in() {
    const { signInWithRedirect, GoogleAuthProvider } = await import('firebase/auth')
    await signInWithRedirect(auth, new GoogleAuthProvider())
  }

  async function sign_out() {
    const { signOut } = await import('firebase/auth')
    await signOut(auth)
  }

  return {
    subscribe,
    sign_in,
    sign_out,
  }
}

export const auth = createAuth()

In a future article, I’ll share some tips and methods for consuming Firebase Firestore data using multiple Svelte Stores.