Skip to main content

Command Palette

Search for a command to run...

How I Fixed Slow Convex Storage Assets With Convex

Published
7 min read
How I Fixed Slow Convex Storage Assets With Convex

The problem

We had a simple problem. Query prefetch was working. Images and audio still felt cold. That meant the next screen could have its data ready, but still show an image pop in late, or start audio late. That feels bad. It makes the app feel slower than it really is.

The first thing we checked

We traced the actual asset requests. The built in Convex storage URLs looked like this.

https://<deployment>.convex.cloud/api/storage/<storageId>

Then we checked the response headers. The important detail was the cache policy. The built in storage response was not behaving like a strong public immutable asset. That was the root issue. The browser could cache it, but not in the strongest way we wanted for repeated image and audio usage.

The constraint

We did not want to move assets out of Convex. That is important. The goal was not to replace Convex storage. The goal was to keep assets in Convex, and serve them better.

The fix in one sentence

We kept assets in Convex storage, added our own cached asset route on convex.site, rewrote storage URLs to that route on the client, and made preloading much stricter for both images and audio.

Step 1, create a cached asset route in Convex

The first fix was backend. We added a custom HTTP route that reads from Convex storage and serves the file with stronger caching headers. This is the important pattern.

import { httpRouter } from 'convex/server'
import type { Id } from './_generated/dataModel'
import { httpAction } from './_generated/server'

const http = httpRouter()

const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365
const CACHE_CONTROL = `public, max-age=\({ONE_YEAR_IN_SECONDS}, s-maxage=\){ONE_YEAR_IN_SECONDS}, immutable`

http.route({
  pathPrefix: '/cached-assets/',
  method: 'GET',
  handler: httpAction(async (ctx, request) => {
    const url = new URL(request.url)
    const storageId = decodeURIComponent(
      url.pathname.slice('/cached-assets/'.length)
    ) as Id<'_storage'>

    const metadata = await ctx.storage.getMetadata(storageId)
    if (!metadata) {
      return new Response('Asset not found', { status: 404 })
    }

    const etag = `\"${metadata.sha256}\"`
    const ifNoneMatch = request.headers.get('if-none-match')

    if (ifNoneMatch?.includes(etag)) {
      return new Response(null, {
        status: 304,
        headers: {
          'Cache-Control': CACHE_CONTROL,
          'CDN-Cache-Control': CACHE_CONTROL,
          ETag: etag,
        },
      })
    }

    const blob = await ctx.storage.get(storageId)
    if (!blob) {
      return new Response('Asset not found', { status: 404 })
    }

    return new Response(blob, {
      headers: {
        'Access-Control-Allow-Origin': '*',
        'Cache-Control': CACHE_CONTROL,
        'CDN-Cache-Control': CACHE_CONTROL,
        'Content-Type':
          metadata.contentType ?? blob.type ?? 'application/octet-stream',
        'Cross-Origin-Resource-Policy': 'cross-origin',
        ETag: etag,
      },
    })
  }),
})

export default http

This gives you a stable URL shape and proper immutable caching, while still reading from Convex storage.

Step 2, normalize every Convex storage URL on the client

Once we had a better route, we needed to make the app use it everywhere. The clean way is one helper.

const BUILT_IN_STORAGE_PATH_PREFIX = '/api/storage/'
const CACHED_ASSET_PATH_PREFIX = '/cached-assets/'

function getCachedAssetUrl(url: string | null | undefined) {
  if (!url) {
    return null
  }

  if (
    url.startsWith('/') ||
    url.startsWith('data:') ||
    url.startsWith('blob:')
  ) {
    return url
  }

  const assetUrl = new URL(url)

  if (!assetUrl.pathname.startsWith(BUILT_IN_STORAGE_PATH_PREFIX)) {
    return url
  }

  const storageId = assetUrl.pathname.slice(BUILT_IN_STORAGE_PATH_PREFIX.length)
  const convexSiteUrl = (
    import.meta.env.VITE_CONVEX_SITE_URL as string
  ).replace(/\/$/, '')

  return `\({convexSiteUrl}\){CACHED_ASSET_PATH_PREFIX}${encodeURIComponent(storageId)}`
}

That helper matters a lot. Without it, you end up fixing one component at a time forever.

Step 3, make image preloading strict

Our old image preloader was too optimistic. It marked assets as preloaded too early. That is not enough. You want to dedupe in flight work, wait for real load, and wait for decode if possible. This is the pattern.

const preloadedImagePaths = new Set<string>()
const inFlightImagePreloads = new Map<string, Promise<void>>()

function preloadImage(path: string) {
  const normalizedPath = getCachedAssetUrl(path)
  if (!normalizedPath) {
    return Promise.resolve()
  }

  if (preloadedImagePaths.has(normalizedPath)) {
    return Promise.resolve()
  }

  const existing = inFlightImagePreloads.get(normalizedPath)
  if (existing) {
    return existing
  }

  const preloadPromise = new Promise<void>((resolve) => {
    const image = new Image()
    let hasSettled = false

    function settle(didLoad: boolean) {
      if (hasSettled) return
      hasSettled = true
      inFlightImagePreloads.delete(normalizedPath)
      if (didLoad) {
        preloadedImagePaths.add(normalizedPath)
      }
      resolve()
    }

    image.decoding = 'async'
    image.addEventListener(
      'load',
      () => {
        if (typeof image.decode === 'function') {
          void image
            .decode()
            .catch(() => {})
            .finally(() => settle(true))
          return
        }

        settle(true)
      },
      { once: true }
    )

    image.addEventListener('error', () => settle(false), { once: true })
    image.src = normalizedPath
  })

  inFlightImagePreloads.set(normalizedPath, preloadPromise)
  return preloadPromise
}

Step 4, preload audio as a first class thing

Audio needs the same treatment. If you only solve images, the app still feels cold. This is the audio version of the same idea.

const preloadedAudioUrls = new Set<string>()
const inFlightAudioPreloads = new Map<string, Promise<void>>()

function preloadAudioUrl(url: string) {
  const normalizedUrl = getCachedAssetUrl(url)
  if (!normalizedUrl) {
    return Promise.resolve()
  }

  if (preloadedAudioUrls.has(normalizedUrl)) {
    return Promise.resolve()
  }

  const existing = inFlightAudioPreloads.get(normalizedUrl)
  if (existing) {
    return existing
  }

  const preloadPromise = new Promise<void>((resolve) => {
    const audio = new Audio()
    let hasSettled = false

    function settle(didLoad: boolean) {
      if (hasSettled) return
      hasSettled = true
      inFlightAudioPreloads.delete(normalizedUrl)
      if (didLoad) {
        preloadedAudioUrls.add(normalizedUrl)
      }
      resolve()
    }

    audio.preload = 'auto'
    audio.addEventListener('canplaythrough', () => settle(true), { once: true })
    audio.addEventListener('loadeddata', () => settle(true), { once: true })
    audio.addEventListener('error', () => settle(false), { once: true })
    audio.src = normalizedUrl
    audio.load()
  })

  inFlightAudioPreloads.set(normalizedUrl, preloadPromise)
  return preloadPromise
}

Step 5, make runtime playback use the same normalized URLs

This part is easy to miss. If preload warms one URL and playback uses another URL, you lose the benefit. So your playback layer should normalize URLs too.

function getOrCreateAudio(path: string): HTMLAudioElement {
  const cachedPath = getCachedAssetUrl(path) ?? path
  const cached = state.cache.get(cachedPath)
  if (cached) return cached

  const audio = new Audio(cachedPath)
  state.cache.set(cachedPath, audio)
  return audio
}

We applied the same rule in our sound manager, entry voice manager, route music controller, and celebration audio flow.

Step 6, warm the network early

Even with perfect preload logic, the first request still pays DNS and TLS if the browser has not warmed those origins yet. So we warmed both Convex origins early.

<link rel="dns-prefetch" href="%VITE_CONVEX_URL%" />
<link rel="preconnect" href="%VITE_CONVEX_URL%" crossorigin />
<link rel="dns-prefetch" href="%VITE_CONVEX_SITE_URL%" />
<link rel="preconnect" href="%VITE_CONVEX_SITE_URL%" crossorigin />

That does not solve everything by itself. But it helps the first useful request.

Step 7, prefetch media, not just queries

This was the final important lesson. Query prefetch is not enough for a media heavy app. You also need to preload the exact image and audio for the next screen. This is the pattern we used on hover.

prefetchGameplayScreen(
  {
    chapterId,
    worldId,
    replayMode: false,
  },
  {
    onResult: (result) => {
      if (!result) {
        return
      }

      void preloadImages(
        [result.level.imageUrl, result.world.imageUrl].filter(
          (url): url is string => Boolean(url)
        )
      )

      void preloadAudioUrls(
        [
          result.level.wordAudioUrl,
          result.world.musicUrl,
          result.world.completionVoiceUrl,
        ].filter((url): url is string => Boolean(url))
      )
    },
  }
)

This is what makes the next screen actually feel warm.

What changed after the fix

After this change, the app started doing four things together. It warmed the connection earlier. It rewrote Convex storage URLs to a better cached Convex route. It deduped in flight image and audio preloads. It preloaded the next screen media before navigation. That combination is what mattered.

What this does not solve

The very first uncached request for a brand new asset can still cost real network time. That is normal. You cannot remove physics. What this fix does is remove the repeated cold feeling.

The main lesson

If your next screen is media heavy, query prefetch is only half the job. You need asset delivery, asset caching, and asset preload to be part of the architecture too. If you are using Convex, you can solve that cleanly without moving assets out of Convex.