Cost model · 2026-09-23
Running an audio host on counters and caches
How Soundfish keeps a public audio host cheap enough to leave running: R2 for bytes, Convex for control, counter rows instead of scans, edge-cached redirects, and bounded background work.
Bytes in one store, facts in another
The product splits into a content store and a control plane. Audio bytes, covers, and thumbnails live in Cloudflare R2 — object storage with no egress fee on delivery. Albums, tracks, revisions, engagement, tokens, and usage live in Convex as metadata rows. The control plane never serves a media byte; it answers small JSON projections and 302 redirects to presigned GETs.
That split is the cost model. Convex bills per function call, so every design question reduces to one: how often does a human gesture reach a Convex function?
Cache the answer, not just the bytes
The media, cover, and download routes resolve an asset, check reachability, and 302 to a presigned R2 URL. Those redirects are identity-free — the signed URL is a 24-hour bearer URL minted identically inside its window — so they carry Cache-Control: public, max-age=3600, s-maxage=300, stale-while-revalidate=600. A repeat play from a new visitor is served by the edge, not by a function.
Public JSON projections (the feed, artist profiles, published album views) sit on a 60-second shared cache; owner and draft-bearing responses stay no-store so private state can never reach a shared cache. The album, artist, and track pages themselves are ISR at ten minutes with on-demand revalidation on catalogue writes — the common read is a static file.
Counts without scans
Plays, hearts, and comments are maintained through counter rows updated inside the engagement mutations, not recomputed per read — a page view costs a handful of point reads regardless of how much activity the album has accumulated. Asset resolution goes through a dedicated by_asset index: resolving a media request is a constant few lookups, not a scan over every revision of every track.
The one background structure is just as bounded. Deleting an album or track schedules a sweep over its engagement rows — comments, hearts, counters — capped at 128 rows per round per table and 64 resources per call, rescheduling itself until empty. Transient play rows need no sweeper at all: they carry an expiry index the hourly prune already walks.
Bounded client behavior
The cheapest request is the one that never happens. Two useEngagement hooks on a player used to refetch on every focus and visibilitychange event — now refocus reloads are throttled to thirty seconds per hook while post-mutation reloads stay immediate. Engagement POSTs abort after fifteen seconds so a hung request cannot wedge the player's sending state. The audio element asks for preload="metadata", not the whole file.
Uploads are equally fenced: a presigned PUT goes straight to R2, deduplication keys on SHA-256, and the completion path is a bounded inspection lease, not a polling loop.
What it costs to stand still
Idle, the product is static pages, an object store, and a few scheduled sweeps — there is no per-listener streaming service and no media server to babysit. The free plan (one album, ten tracks, twenty-five revisions per track, two GiB) fits inside the same model because the numbers are engineered, not aspirational: bounded reads, bounded writes, bounded cleanup, and a registry (costs.json) that fails the build when a new data surface appears without an owner and a budget.
The short version
The discipline is unglamorous: serve the edge, point-read the index, bound the sweep, and let a link — not a process — do the sharing.