Captain Codeman Captain Codeman

Prevent Waterfalls from Multiple SvelteKit Server Hooks in sequence

Parallel hooks.server handle functions for performance

Contents

Introduction

Waterfalls, making a series of async requests sequentially, can be a real performance killer that you should always want to avoid and SvelteKit provides features to enable that such as calling load functions in parallel.

But one place you may accidentally introduce your own waterfalls is when using multiple Server hooks handle functions. There are good reasons to do this, such as separating code into separate modules each dealing with different functionality, but if they are independent and async then when you use the sequence helper function to combine them it means that they could be slowing each other down. sequence makes them sequential (the clue’s in the name!) and sequental async = waterfalls = slower than it could be.

Examples of what some async handle functions would be might include:

  • Server-side auth validation
  • Lookup of SaaS tenant information
  • Page output caching using Redis
  • Populating locals with async fetched data

Now you could combine everything into one giant handle function with the code for different responsibilities all intertwined but there’s an easier way to keep the code modular while still allowing things to run in parallel.

The trick is to create a handle function that will wrap them and run them in parallel. This will need to adhere to the Server hook Handle type:

type Handle = (input: {
  event: RequestEvent;
  resolve(
    event: RequestEvent,
    opts?: ResolveOptions
  ): MaybePromise<Response>;
}) => MaybePromise<Response>;

But the child handle functions it calls will need to be slightly different because we don’t want all of them calling resolve(event) as a normal handler function would (this is used to pass control on to the next handler or to the internal SvelteKit rendering pipeline). Instead, we’ll create a simpler type for these child handlers to implement and a helper function to combine them:

import type { MaybePromise, RequestEvent } from '@sveltejs/kit'

export type ChildHandle = (input: { event: RequestEvent }) => MaybePromise<Response | void>

export function parallel(...handles: ChildHandle[]): Handle {
  return async ({ event, resolve }) => {
    const results = await Promise.all(handles.map(handle => handle({ event })))
    for (const result of results) {
      if (result) {
        return result
      }
    }
    return resolve(event)
  }
}

We can now create child handlers by implementing the ChildHandle type. In this example, we’re using the firebase-admin SDK to verify a session cookie and set the locals.user property for use by any server load function. Note this function doesn’t return anything - the locals.user property will be set if the request contained a valid session cookie, otherwise it will be null.

import type { ChildHandle } from './hooks.server'
import { auth } from './firebase.server'

export const handleAuth: ChildHandle = async ({ event }) => {
  const { locals, cookies } = event

  locals.user = null

  const session = cookies.get('session')

  if (session) {
    try {
      locals.user = await auth.verifySessionCookie(session)
    } catch (e: any) {
      console.error('error verifying session cookie', session, err)
    }
  }
}

Here’s another example that could set a locals.tenant property to provide context for a SaaS site, based on the host name mapped to the service. In this example, the handler may return a 404 error response if the hostname isn’t mapped to a valid tenant.

import type { Cookies, Handle, RequestEvent } from '@sveltejs/kit'
import type { ChildHandle } from './hooks.server'
import { getSiteByHost } from './firebase.server'

export const handleTenant: ChildHandle = async ({ event }) => {
  const { cookies, url } = event
	
  locals.tenant = await getSiteByHost(url.hostname)

  if (!locals.tenant) {
    return new Response(undefined, { status: 404 })
  }
}

Now we have these building blocks we can put them all together. The parallel function is a valid handler and could be used directly, but there’s a good chance you may have some handlers that need to run separately or make sense to run before or after any parallel groups, so it can still be combined with the sequence function too.

In this example, the request is first checked for being a potential bot and blocked if identified, saving the server doing any further database lookups and processing. Otherwise, the auth, tenant, and a logging handlers would all be run in parallel thereby speeding up performance.

Note that if any of these child functions returns a response, then it will be used as the response of the parallel function and the check is in the order of the handlers listed.

import { sequence } from '@sveltejs/kit/hooks'
import { createHandler } from 'svelte-kit-bot-block'
import { handleAuth } from './handle-auth'
import { handleTenant } from './handle-tenant'
import { handleLogging } from './handle-logging'

const handleBots = createHandler()

export const handle = sequence(
  handleBots,
  parallel(handleAuth, handleTenant, logging),
 )

Let me know what you think and if you can see any ways to improve this.