-
-
Notifications
You must be signed in to change notification settings - Fork 406
Description
Bug Report: Broadcast and Presence API causing channel crashes in self-hosted Realtime
Summary
When using Supabase Realtime v2.63.0 (and earlier versions) in a self-hosted Docker environment with the default tenant configuration, any broadcast or presence operation causes the channel to crash with UndefinedFunctionError: function RealtimeWeb.RealtimeChannel.handle_out/3 is undefined or private.
This crash terminates the entire channel, breaking postgres_changes subscriptions and causing message loss in real-time applications.
Affected Versions
- ❌ v2.25.35 (tested - bug present)
- ❌ v2.28.32 (tested - schema incompatibility)
- ❌ v2.33.66 (tested - RLIMIT_NOFILE error)
- ❌ v2.63.0 (tested - bug still present)
Environment
- Setup: Self-hosted Supabase with Docker Compose
- Realtime version:
supabase/realtime:v2.63.0 - PostgreSQL:
supabase/postgres:15.8.1.085 - Client:
@supabase/supabase-jsv2.81.0 - Tenant ID:
realtime(default) - OS: Linux (Docker)
- Node.js: v22.x
Steps to Reproduce
1. Docker Compose Configuration
version: '3.8'
services:
realtime:
image: supabase/realtime:v2.63.0
container_name: supabase-realtime
restart: unless-stopped
environment:
PORT: 4000
DB_HOST: db
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: postgres
DB_NAME: postgres
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long
SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: 10000
APP_NAME: realtime
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
depends_on:
db:
condition: service_healthy
command: >
sh -c "/app/bin/migrate && /app/bin/realtime eval 'Realtime.Release.seeds(Realtime.Repo)' && /app/bin/server"2. Client Code - Broadcast Example (Triggers Bug)
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'http://localhost:54321',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'
)
// Create channel
const channel = supabase.channel('chat-room:general')
// Subscribe to postgres_changes (THIS WORKS FINE)
channel.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
},
(payload) => {
console.log('Message received:', payload)
}
)
// Send broadcast (THIS CRASHES THE ENTIRE CHANNEL)
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
channel.send({
type: 'broadcast',
event: 'typing',
payload: {
user_name: 'John',
timestamp: new Date().toISOString()
}
})
}
})3. Client Code - Presence Example (Also Crashes)
// Track presence (ALSO CRASHES THE CHANNEL)
channel.track({
user_name: 'John',
online_at: new Date().toISOString()
})Expected Behavior
- Broadcast events should be sent successfully without crashing the channel
- Presence tracking should work without terminating the channel
postgres_changessubscriptions should remain active after broadcast/presence operations- Multiple clients should be able to exchange broadcast messages reliably
Actual Behavior
Complete Error Log from Realtime Container
12:00:25.226 project=realtime external_id=realtime [error] GenServer #PID<0.3899.0> terminating
** (UndefinedFunctionError) function RealtimeWeb.RealtimeChannel.handle_out/3 is undefined or private
(realtime 2.63.0) RealtimeWeb.RealtimeChannel.handle_out("broadcast", %{
"event" => "typing",
"payload" => %{
"timestamp" => "2025-11-14T12:00:25.226Z",
"user_name" => "Usuario_6970"
},
"type" => "broadcast"
}, %Phoenix.Socket{
assigns: %{
log_level: :error,
tenant: "realtime",
tenant_topic: "realtime:chat-room:general",
channel_name: "chat-room:general",
jwt_secret: "iNjicxc4+llvc9wovDvqymwfnj9teWMlyOIbJ8Fh6j2WNU8CIJ2ZgjR6MUIKqSmeDmvpsKLsZ9jgXJmQPpwL8w==",
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
presence_enabled?: false,
pg_change_params: [%{
id: "81c772f8-c151-11f0-8c6d-424f085fba4f",
params: %{
"event" => "INSERT",
"filter" => "room_id=eq.ee45c875-cfc1-4a88-a59b-575fa831d4aa",
"schema" => "public",
"table" => "messages"
},
channel_pid: #PID<0.3903.0>,
claims: %{"exp" => 1983812996, "iss" => "supabase-demo", "role" => "anon"}
}],
...
},
channel: RealtimeWeb.RealtimeChannel,
channel_pid: #PID<0.3899.0>,
endpoint: RealtimeWeb.Endpoint,
handler: RealtimeWeb.UserSocket,
id: "user_socket:realtime",
joined: true,
topic: "realtime:chat-room:general",
transport: :websocket,
...
})
Last message: %Phoenix.Socket.Broadcast{
topic: "realtime:chat-room:general",
event: "broadcast",
payload: %{
"event" => "typing",
"payload" => %{
"timestamp" => "2025-11-14T12:00:25.226Z",
"user_name" => "Usuario_6970"
},
"type" => "broadcast"
}
}Consequences
- Channel crashes immediately: GenServer terminates on first broadcast
- Message loss:
postgres_changessubscription is destroyed - Auto-reconnect loop: Channel reconnects but crashes again on next broadcast
- Infinite crash cycle: Makes broadcast/presence completely unusable
Root Cause Analysis
Based on this GitHub issue comment, the problem occurs when tenant_id is literally named "realtime", causing internal name conflicts in the Elixir/Phoenix application.
Evidence from Error Logs
The key indicators in the error:
tenant: "realtime" // ← PROBLEM: Reserved internal name
topic: "realtime:chat-room:general"
tenant_topic: "realtime:chat-room:general"The function RealtimeWeb.RealtimeChannel.handle_out/3 is being invoked but doesn't exist in the module, suggesting an incorrect code path when the tenant uses the reserved name "realtime".
Database Tenant Configuration
-- Query to check tenant configuration
SELECT external_id, name FROM _realtime.tenants;
-- Result showing problematic default:
-- external_id | name
-- -------------+--------------
-- realtime | realtime ← This is the problemAttempted Workarounds
❌ Attempt 1: Change channel name prefix
// Tried using different channel prefix
const channel = supabase.channel('chat-room:general') // instead of 'room:general'
// Result: Bug persists
// Reason: tenant_id is still "realtime" regardless of channel name❌ Attempt 2: Upgrade Realtime versions
Tested multiple versions looking for one without the bug:
-
v2.28.32: Schema incompatibility - missing
enable_authorizationcolumnERROR 42703 (undefined_column) column t0.enable_authorization does not exist -
v2.33.66: Environment variable error
/app/run.sh: line 6: RLIMIT_NOFILE: unbound variable -
v2.63.0: Same
handle_out/3error persists
❌ Attempt 3: Manually change tenant in database
-- Attempt to rename tenant
BEGIN;
UPDATE _realtime.extensions
SET tenant_external_id = 'realtime-local'
WHERE tenant_external_id = 'realtime';
UPDATE _realtime.tenants
SET external_id = 'realtime-local', name = 'realtime-local'
WHERE external_id = 'realtime';
COMMIT;
-- Result: Breaks system expecting default "realtime" tenant
-- Error: TenantNotFound: Tenant not found: realtime✅ Current Workaround (Not Ideal)
Completely disable broadcast and presence features - only use postgres_changes:
// Only use postgres_changes (works perfectly)
channel.on('postgres_changes', { ... }, callback)
// DO NOT use these (they crash):
// channel.send({ type: 'broadcast', ... }) // ❌ CRASHES
// channel.track({ ... }) // ❌ CRASHESThis maintains message delivery but loses all real-time collaboration features.
Impact Assessment
This bug makes broadcast and presence APIs completely unusable in self-hosted Supabase with default configuration, breaking:
| Feature | Status | Impact |
|---|---|---|
Message delivery (postgres_changes) |
✅ Works | Core functionality intact |
Typing indicators (broadcast) |
❌ Broken | No typing status |
Active users (presence) |
❌ Broken | Can't track who's online |
| Real-time collaboration | ❌ Broken | No ephemeral state sharing |
| Custom broadcast events | ❌ Broken | All broadcast features unavailable |
Use Cases Affected
- Chat applications with typing indicators
- Collaborative editing with cursor positions
- Online presence tracking
- Real-time gaming state
- Live cursors/selections
- Any feature requiring ephemeral state broadcast
Proposed Solutions
Option 1: Fix handle_out/3 (Recommended)
Add proper handling for broadcast messages in RealtimeWeb.RealtimeChannel module:
# Add missing function clause in RealtimeWeb.RealtimeChannel
def handle_out("broadcast", payload, socket) do
# Implement proper broadcast handling
push(socket, "broadcast", payload)
{:noreply, socket}
endBenefits: Fixes the issue without breaking existing deployments
Option 2: Prevent Reserved Tenant Names
Add validation during tenant seeding to prevent using "realtime":
# In Realtime.Release.seeds/1
defp validate_tenant_name(name) do
if name == "realtime" do
raise "Tenant name 'realtime' is reserved. Use a different name like 'realtime-local' or 'my-app'"
end
endBenefits: Prevents the issue from occurring in new deployments
Option 3: Auto-rename Default Tenant
Change the seeding script to use a safe default name:
# Change default tenant from "realtime" to "realtime-default"
@default_tenant_id "realtime-default" # instead of "realtime"Benefits: Fixes the issue for all new deployments automatically
Option 4: Documentation & Migration Guide
Provide clear documentation and migration steps:
- Document that
tenant_idmust NOT be "realtime" - Provide SQL migration script to safely rename tenant
- Update example docker-compose files
- Add troubleshooting guide for this error
Benefits: Helps existing users fix their deployments
Additional Context
What Works (postgres_changes)
// This configuration works perfectly - NO crashes
channel
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages'
}, (payload) => {
console.log('New message:', payload)
})
.subscribe()
// Messages are delivered reliably with zero issuesWhat Breaks (broadcast/presence)
// Both of these crash the channel immediately:
// Broadcast
channel.send({
type: 'broadcast',
event: 'typing',
payload: { user: 'john' }
}) // ❌ CRASH
// Presence
channel.track({
user: 'john',
status: 'online'
}) // ❌ CRASHQuestions for Maintainers
- Is
tenant_id="realtime"intentionally reserved? If so, this should be documented. - Why does
handle_out/3not exist for the broadcast message type? - What is the recommended way to change tenant_id in self-hosted setups?
- Are there other reserved tenant names we should avoid?
- Is there a migration path for existing deployments using "realtime"?
Reproduction Repository
A minimal reproduction repository is available with:
- Complete Docker Compose setup
- TypeScript client code demonstrating the bug
- Step-by-step instructions to reproduce
- Logs showing the crash
Contact me if you need access to the reproduction case.
Request
This bug is blocking real-time collaboration features in self-hosted Supabase deployments. Could you please:
- Confirm if this is a known issue with
tenant_id="realtime" - Provide guidance on safely changing the tenant_id
- Consider implementing one of the proposed solutions
- Add validation/warnings if using reserved tenant names
Thank you for your work on Supabase Realtime!
Related Issues
- Possibly related to: fix: Use INSERT instead of UPDATE #863
- Similar tenant naming issues discussed in Discord
Note: This issue only affects self-hosted Supabase with default configuration. Supabase Cloud deployments are not affected as they use different tenant naming.