The usual way to show encode progress is to poll: the browser asks your API for the media record every few seconds, your API asks FastPix, and the interface redraws when the status flips to ready. For one upload in one tab that is fine, and anyone reading the code a year later can follow it.
It stops being fine on a page holding twelve uploads: twelve timers, all firing whether anything changed or not, and the poll that catches the flip lands a full interval after the row was written. Supabase Realtime removes the timers, but only once the row exists in Postgres, and in a video pipeline a backend worker writes that row rather than the person watching for it.
That single fact changes the SQL, the grants and the pending state, and it is the part we kept getting wrong while wiring our own Supabase integration. What follows is the publication and grant SQL a private schema needs, a subscriber that survives a token refresh and a dropped event, and what we show before the first event lands.
TL;DR
fastpix-worker writes the row on the service role, not the browser, so Realtime is the only part of the chain a client can hear. Publish the table, grant authenticated usage and select (Postgres Changes checks that separately on a private schema), add a select policy, and subscribe with a signed-in client. Re-authorize on session refresh, re-fetch on every SUBSCRIBED, and treat events as invalidations. Our 10-second drain is your floor.
Who writes the status row
In the Realtime examples you can find online a signed-in person inserts a row and their own browser hears it come back, so author and subscriber are one session and the interface can render the change before the server confirms it. Nothing about a video pipeline works that way.
Here the author is fastpix-worker, an edge function we run on the service role. It wakes when a cron job drains the queue, re-fetches the media from the FastPix API rather than trusting the webhook payload, and writes fastpix.media. The browser that started the upload is a stranger to that write.
Three consequences follow from that split.
- The write always succeeds. The service role bypasses row level security, so a table with no policies still accepts the worker, and a broken policy never shows up as a failed write.
- Your grants and policy govern the read. Realtime applies the subscriber's permissions, not the writer's, so a browser missing either connects, reports
SUBSCRIBED, and hears nothing. - There is no echo to render. Only FastPix knows whether the encode finished, so the browser has nothing to show optimistically.
Publish the table, then grant select
The grant is the statement that catches people out. fastpix is a private schema, and Postgres Changes on a private schema only delivers to a role holding usage on the schema and select on the table, which Supabase checks separately from the policy.
-- 1. publish the table Realtime should watch
alter publication supabase_realtime add table fastpix.media;
-- 2. private schema: Postgres Changes checks these grants,
-- separately from the policy below
grant usage on schema fastpix to authenticated;
grant select on fastpix.media to authenticated;
grant select on public.media_owners to authenticated;
-- 3. the subscriber's own read policy, which Realtime enforces
create policy "subscriber reads own media"
on fastpix.media
for select
to authenticated
using (exists (
select 1 from public.media_owners o
where o.media_id = fastpix.media."mediaId"
and o.owner_id = (select auth.uid())
));
-- 4. optional: put the old row in every UPDATE payload
alter table fastpix.media replica identity full;"mediaId" takes double quotes because our schema mirrors the API's camelCase, so an unquoted mediaId folds to mediaid and fails with column "mediaid" does not exist. public.media_owners is your own table, one row per asset and the user who started it, kept snake_case so only the fastpix side needs quoting. It is the same table and policy we use in Supabase row-level security for video tables.
replica identity full earns its place when you want the previous row in the payload, so the client can ignore writes that did not move the status. It also makes Postgres log every column of the old row on each update, a real increase in write-ahead log volume on a table as wide as fastpix.media. Turn it on when you need the old row, not by default.
One naming change is coming: Supabase is retiring the legacy anon and service_role API keys by the end of 2026, in favour of sb_publishable_* and sb_secret_*. The database roles keep their names, so the policy above is unaffected. The key in your client code is not.
Those four statements are what `init` writes into your migrations, so running it against a scratch branch and diffing the result is the fastest way to see whether the grant is your problem.
Subscribe with one channel, not twelve
Open one channel for the page and filter inside the handler, rather than one per asset. Our first version created media:${mediaId} per upload, twelve websocket channels on a twelve-upload page, and Supabase's own guidance runs the other way: one channel, a broader filter, routing in your code.
Watch fastpix.media, not fastpix.uploads. An upload session ends when the bytes finish arriving, minutes before the video can play, so subscribing to it reports the transfer and then goes quiet for the whole of processing, which is exactly the window the user is staring at.
import { createClient } from '@supabase/supabase-js'
// A signed-in client. Created with the anon key and no session, the
// connection's role is `anon`, the policy above is scoped to
// `authenticated`, and the channel subscribes and then hears nothing.
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
const { data: { session } } = await supabase.auth.getSession()
supabase.realtime.setAuth(session?.access_token)
export function watchMedia(ids: Set<string>, refetch: () => Promise<void>) {
const channel = supabase
.channel('media-status')
.on(
'postgres_changes',
{ event: '*', schema: 'fastpix', table: 'media' },
(payload) => {
if (payload.eventType === 'DELETE') return
if (ids.has((payload.new as MediaRow).mediaId)) refetch()
},
)
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') await refetch()
if (status === 'CHANNEL_ERROR') console.error('realtime channel error')
})
return () => supabase.removeChannel(channel)
}The policy is still doing the real filtering, so the ids.has check is a rendering concern rather than a security boundary. If you keep a server-side filter, remember it is not SQL: Realtime parses mediaId=eq.abc123 itself, so it takes no quotes and the case must match the column. Write mediaid=eq.abc123 and the channel subscribes, matches nothing, and reports no error.
Keep fastpix.live_streams away from the browser entirely, because it stores streamKey, srtSecret, srtPlaybackStreamId and srtPlaybackSecret, any of which lets a stranger broadcast into your account.
Why Supabase Realtime goes quiet
Two reasons, and neither logs anything. Realtime authorizes a channel against the access token it was opened with, so when Supabase refreshes the session the channel keeps the old JWT and stops delivering the moment that token expires, usually an hour in. Re-authorize on every refresh and the channel recovers in place.
supabase.auth.onAuthStateChange((_event, session) => {
supabase.realtime.setAuth(session?.access_token)
})The second reason is that postgres_changes is not guaranteed delivery. It drops messages under load, on reconnect and whenever the client falls behind, and nothing tells you, so a state machine that transitions only on events strands the UI on "Processing" the one time a ready event goes missing. That is the failure our own status screens hit first.
Treat every event as an invalidation rather than as state. Re-fetch the row by id when an event arrives and on every SUBSCRIBED callback, including the ones that fire after a reconnect, and the interface converges whether the event survived the trip or not. That is what refetch does in the snippet above, reading through your public view.
Where the delay lives
Supabase measures Realtime from the insert onward, which is accurate and answers a different question than your reader is asking, because their clock started when the encode finished. Everything between belongs to you.
| Hop | What happens | What it adds |
|---|---|---|
| FastPix finishes the encode | The provider fires the webhook | Not visible from your app |
| fastpix-webhook runs | Verifies the base64 signature, enqueues to pgmq | Milliseconds |
| The event waits in the queue | Nothing runs until the next drain | 0 to 10 seconds |
| The drain cron fires | Starts fastpix-worker | One invocation |
| fastpix-worker claims events | Re-fetches each resource from the FastPix API | A round trip per event |
| The worker writes the row | An insert or update on fastpix.media | Milliseconds |
| Realtime reads the write-ahead log | Pushes to the channel | The hop Supabase's numbers describe |
The drain interval is a floor no client-side change removes, and our worker reads a fixed ten messages per run, so a burst larger than ten waits for a second drain before the rest of the rows move. An interface promising instant updates on top of that chain is telling the reader something untrue, while one that says queued, then processing, then ready is describing what we actually do.
Do not take those numbers from an article, though. Both are settings you can read and change, so time one upload end to end and find out where your own chain spends its seconds.
Design the pending state
The browser knows when its own upload finished, and that timestamp starts a pending window nothing in Postgres can shorten.
| State | Starts when | Ends when |
|---|---|---|
| Uploading | The file starts moving | The uploader reports completion |
| Queued | The upload completes | The first read or event for that mediaId |
| Processing | The status arrives as processing | A read or event carries a ready status |
| Ready | The ready status arrives | Nothing |
| Stalled | The pending budget expires | The next successful read |
Two rules keep that window honest. Do not spin, because a spinner reads as "a moment" and the queue promises no such thing. Name the state Queued and show an elapsed counter, which turns waiting into information.
Set a budget, then read again. At about a minute with no change, re-read through your public view rather than escalating to an error, which is the same fallback the dropped-event case needs. A webhook that never arrived is a reconcile problem, and npx @fastpix/supabase reconcile re-reads the last 24 hours to heal it.
Three failures that log nothing
Events arrive and nothing drains. The cron jobs read their target URL and service key from Vault, and without those secrets webhooks land, sit in pgmq indefinitely, and no error is reported anywhere. Run the triage query first:
select "type", "processStatus", "lastError"
from fastpix.webhook_events
order by "receivedAt" desc limit 10;No rows at all means FastPix is not reaching the webhook, or the signature check is failing. Rows stuck at received mean nothing is draining, so recheck the Vault secrets. Rows at failed carry the answer in lastError, usually a wrong token ID or secret. Locally the functions URL must be host.docker.internal, because Postgres resolves it from inside its own container.
The webhook returns `401` and FastPix refuses to save the URL. Two restarts exist here and they fix different things. npx @fastpix/supabase init writes verify_jwt = false for the webhook into config.toml, and the stack reads config.toml at start, so the fix is npx supabase stop && npx supabase start. Restarting functions serve fixes the other one: that process reads supabase/functions/.env once, so a signing secret pasted afterwards does nothing until you restart it. Swapping the two costs an afternoon, which is how we know.
Rows land and the browser hears nothing. Work through it in this order. The table is missing from the supabase_realtime publication. authenticated holds no grant on the private schema. There is no select policy. The client never signed in, so its role is anon. The channel's token has expired. Or the filter string's case does not match the column.
Run one upload through Supabase Realtime
Install with npx @fastpix/supabase init, restart the whole stack so the webhook stops returning 401, then point a FastPix webhook at the deployed fastpix-webhook function:
npx supabase stop && npx supabase start # config.toml, so the whole stack
npx supabase functions serve # .env, so this process onlyUpload one file, then watch fastpix.webhook_events fill and your channel fire on the same mediaId. Setup order, variables and the triage table are in our integration docs, and watching one upload flip a row needs only the free plan, ten videos, no card.
Frequently Asked Questions (FAQs)
Can Supabase Realtime show video encode status?
Yes, once the status is a row in Postgres. Realtime broadcasts changes to tables in the supabase_realtime publication, so a worker that writes the provider's encode status into fastpix.media gives the browser something to subscribe to. Realtime never reaches outside Postgres, so it can only report a change after something has written it down.
Why does my Supabase Realtime subscription connect but receive no events?
Work through six causes in order. The table is not in the supabase_realtime publication. The role holds no usage and select grant, which Postgres Changes checks separately on a private schema. There is no select policy. The client was created with an anon key and never signed in, so its role is anon. The channel's access token expired and setAuth was never called again. Or the filter string's case does not match the column.
Do camelCase columns need double quotes in Supabase SQL?
In SQL, yes. Postgres folds an unquoted identifier to lower case, so mediaId becomes mediaid and the statement fails with column "mediaid" does not exist. In a Realtime filter string the opposite holds. Realtime parses that filter rather than Postgres, so it takes no quotes at all. The column name has to be written in its exact case.
How fast is Supabase Realtime when the change comes from a webhook?
Realtime is fast from the write onward, and the write is not where the delay sits. FastPix fires a webhook, an edge function enqueues it, a cron job drains the queue every 10 seconds, and a worker re-fetches the resource before writing the row. The drain interval is the floor, and no client-side change removes it.
Is Supabase postgres_changes guaranteed delivery?
No. Messages are dropped under load, on reconnect and whenever the client falls behind, and nothing tells you it happened. Treat every event as an invalidation rather than as state, re-fetch the row by id on arrival and on every SUBSCRIBED callback, and the UI converges even when an event is lost.
Is it safe to subscribe a browser to the fastpix tables?
Only to media, and only behind a grant and a select policy. The live_streams table holds streamKey, srtSecret, srtPlaybackStreamId and srtPlaybackSecret, plus more keys inside simulcastResponses, which are enough for a stranger to broadcast into your account. The webhook_events table stores raw payloads that can contain the same values. Expose a view in public carrying only the columns your interface renders.






