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.






