The "REALTIME_DISCONNECT: Websocket disconnected from Realtime server" error occurs when a WebSocket connection to Supabase Realtime unexpectedly closes. This can happen due to network interruptions, server-side timeouts, authentication token expiration, or quota limits. Implementing proper reconnection logic with exponential backoff is essential for maintaining reliable realtime features.
The "REALTIME_DISCONNECT: Websocket disconnected from Realtime server" error indicates that the WebSocket connection between your application and the Supabase Realtime server has been terminated. Unlike the REALTIME_TIMEOUT error which occurs during initial connection, REALTIME_DISCONNECT happens after a previously established connection drops. The Supabase Realtime service maintains heartbeat messages every 30 seconds to detect connection issues and automatically attempt reconnection with exponential backoff (1s, 2s, 5s, 10s). When a disconnect occurs, it's typically due to: 1. **Network failures** - Client loses internet connectivity or network changes (WiFi switching) 2. **Server-side timeouts** - Supabase server disconnects idle connections after a period 3. **Authentication expiration** - JWT token expires during a long-lived connection 4. **Rate limiting** - Project exceeds realtime quotas (too many messages, connections, or channel joins) 5. **Server maintenance** - Supabase performing updates or maintenance on realtime infrastructure 6. **Firewall/proxy issues** - Intermediary infrastructure terminating idle WebSocket connections
Add comprehensive handling for all subscription status changes:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(supabaseUrl, supabaseKey)
const channel = supabase.channel('my-channel')
channel
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'messages'
}, (payload) => {
console.log('Change received:', payload)
})
.subscribe((status) => {
console.log('Subscription status:', status)
switch(status) {
case 'SUBSCRIBED':
console.log('✓ Successfully subscribed to channel')
// Resume any paused operations
break
case 'CHANNEL_ERROR':
console.error('✗ Channel error - attempting to recover')
// Connection failed or dropped, will auto-reconnect
break
case 'TIMED_OUT':
console.error('✗ Connection timed out - will retry')
// Connection timeout, auto-retry will happen
break
case 'CLOSED':
console.log('Channel closed - unsubscribing')
// User intentionally unsubscribed
break
default:
console.log('Unknown status:', status)
}
})Key status codes to handle:
- SUBSCRIBED (0): Connection healthy, ready to receive updates
- CHANNEL_ERROR (1): Error occurred, automatic reconnection in progress
- TIMED_OUT (2): Connection timeout, retry with exponential backoff
- CLOSED (3): Channel intentionally closed
Build a robust reconnection manager:
class RealtimeConnectionManager {
constructor(supabase, options = {}) {
this.supabase = supabase
this.maxRetries = options.maxRetries || 10
this.baseDelay = options.baseDelay || 1000 // 1 second
this.maxDelay = options.maxDelay || 30000 // 30 seconds
this.retryCount = 0
this.channel = null
this.lastSuccessfulConnection = null
}
async connect(channelName) {
try {
this.channel = this.supabase.channel(channelName)
this.channel
.on('postgres_changes', {
event: '*',
schema: 'public'
}, (payload) => {
console.log('Received change:', payload)
this.lastSuccessfulConnection = Date.now()
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
console.log('Connected successfully')
this.retryCount = 0 // Reset on success
this.lastSuccessfulConnection = Date.now()
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
this.handleDisconnect()
}
})
} catch (error) {
this.handleDisconnect(error)
}
}
handleDisconnect(error) {
console.error('Realtime disconnected:', error)
if (this.retryCount < this.maxRetries) {
// Calculate exponential backoff with jitter
const exponentialDelay = Math.pow(2, this.retryCount) * this.baseDelay
const jitter = Math.random() * 1000 // Add up to 1 second random jitter
const delay = Math.min(exponentialDelay + jitter, this.maxDelay)
this.retryCount++
console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.retryCount}/${this.maxRetries})`)
setTimeout(() => {
this.connect(this.channel?.name || 'default')
}, delay)
} else {
console.error('Max reconnection attempts reached. Manual intervention needed.')
// Emit event to notify application
this.emit('max-retries-reached')
}
}
disconnect() {
this.retryCount = 0
if (this.channel) {
this.supabase.removeChannel(this.channel)
}
}
emit(event) {
// Implement your own event emitter or use callbacks
console.log('Event:', event)
}
}
// Usage
const manager = new RealtimeConnectionManager(supabase, {
maxRetries: 10,
baseDelay: 1000,
maxDelay: 30000
})
await manager.connect('my-channel')Verify network configuration to prevent unexpected disconnections:
# Test WebSocket connectivity
npm install -g wscat
wscat -c "wss://YOUR_PROJECT_REF.supabase.co/realtime/v1"
# Verify DNS resolution
nslookup YOUR_PROJECT_REF.supabase.co
# Test with curl
curl -i -N \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
-H "Sec-WebSocket-Version: 13" \
https://YOUR_PROJECT_REF.supabase.co/realtime/v1Configuration steps:
1. Whitelist Supabase domain in corporate firewalls:
- Add *.supabase.co to allowed domains
- Allow port 443 (wss uses port 443 like HTTPS)
2. Disable firewall idle timeout for WebSocket connections:
- Configure proxy to not timeout idle WebSocket connections
- Some proxies close connections after 5-15 minutes of inactivity
3. Test from different networks:
- Try from home/mobile network vs corporate network
- Test with VPN enabled/disabled
4. Check system clock:
- Incorrect time can cause SSL/TLS issues
- Verify system time is synchronized: date
Prevent disconnections due to token expiration:
// Implement token refresh before expiration
async function setupTokenRefresh(supabase) {
const { data: { session } } = await supabase.auth.getSession()
if (session?.expires_at) {
// Calculate when token expires
const expiresAt = session.expires_at * 1000 // Convert to milliseconds
const now = Date.now()
const timeUntilExpiry = expiresAt - now
// Refresh token 5 minutes before expiration
const refreshTime = timeUntilExpiry - (5 * 60 * 1000)
setTimeout(async () => {
console.log('Refreshing authentication token...')
const { data, error } = await supabase.auth.refreshSession()
if (error) {
console.error('Token refresh failed:', error)
} else {
console.log('Token refreshed successfully')
// Restart token refresh cycle
setupTokenRefresh(supabase)
}
}, Math.max(refreshTime, 0))
}
}
// Setup on app initialization
setupTokenRefresh(supabase)
// Also refresh on auth state change
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'TOKEN_REFRESHED') {
console.log('Token automatically refreshed')
setupTokenRefresh(supabase)
}
})Additional authentication strategies:
1. Use session persistence to restore auth across page reloads
2. Listen to session changes and reconnect realtime when auth updates
3. Handle silent sign-out and reconnect after fresh authentication
4. Validate token on server-side for sensitive operations
Avoid quota-related disconnections:
// Check current plan limitations
const planLimits = {
free: {
maxConcurrentConnections: 2,
maxChannelsPerConnection: 100,
maxEventsPerSecond: 100,
maxChannelJoinsPerSecond: 5
},
pro: {
maxConcurrentConnections: 'unlimited',
maxChannelsPerConnection: 500,
maxEventsPerSecond: 'unlimited',
maxChannelJoinsPerSecond: 50
}
}
// Implement message rate limiting
class MessageRateLimiter {
constructor(maxMessagesPerSecond = 10) {
this.maxMessagesPerSecond = maxMessagesPerSecond
this.messageTimestamps = []
}
canSend() {
const now = Date.now()
// Remove old timestamps outside 1-second window
this.messageTimestamps = this.messageTimestamps.filter(
ts => now - ts < 1000
)
if (this.messageTimestamps.length < this.maxMessagesPerSecond) {
this.messageTimestamps.push(now)
return true
}
return false
}
async sendSafe(channel, event, payload) {
if (!this.canSend()) {
console.warn('Rate limit exceeded, queuing message...')
// Implement queue or back off
return false
}
channel.send({
type: 'broadcast',
event: event,
payload: payload
})
return true
}
}
// Monitor connection health
async function monitorConnectionHealth(channel) {
let lastMessageTime = Date.now()
channel.on('heartbeat', (payload) => {
lastMessageTime = Date.now()
})
// Check if connection is stale
setInterval(() => {
const timeSinceLastMessage = Date.now() - lastMessageTime
const maxStaleTime = 60000 // 60 seconds
if (timeSinceLastMessage > maxStaleTime) {
console.warn('No messages for', timeSinceLastMessage, 'ms - connection may be stale')
// Trigger reconnection
}
}, 30000) // Check every 30 seconds
}Quota management tips:
1. Share channels instead of creating separate ones per user
2. Use channel presence instead of broadcast for real-time sync
3. Batch messages instead of sending individual updates
4. Upgrade plan if consistently hitting Free tier limits
Handle disconnections gracefully with data persistence:
class PersistentRealtimeClient {
constructor(supabase, options = {}) {
this.supabase = supabase
this.isOnline = navigator.onLine
this.pendingChanges = []
this.cachedData = new Map()
// Listen for network changes
window.addEventListener('online', () => this.handleOnline())
window.addEventListener('offline', () => this.handleOffline())
}
handleOffline() {
console.log('Application offline - queuing changes')
this.isOnline = false
// Stop attempting reconnections
}
async handleOnline() {
console.log('Application online - syncing changes')
this.isOnline = true
// Process pending changes
await this.processPendingChanges()
// Reconnect realtime
}
async processPendingChanges() {
for (const change of this.pendingChanges) {
try {
await this.applyChange(change)
this.pendingChanges = this.pendingChanges.filter(c => c !== change)
} catch (error) {
console.error('Failed to apply change:', error)
}
}
}
async applyChange(change) {
// Send change to server
const { error } = await this.supabase
.from('table_name')
.update(change)
.eq('id', change.id)
if (error) throw error
}
cacheMessage(key, data) {
this.cachedData.set(key, {
data,
timestamp: Date.now()
})
}
getCached(key) {
return this.cachedData.get(key)?.data
}
subscribe(channel) {
return channel.subscribe((status) => {
if (status === 'SUBSCRIBED' && this.pendingChanges.length > 0) {
// Sync pending changes
this.processPendingChanges()
}
})
}
}
// Usage with React
import { useEffect, useState } from 'react'
function useRealtimeWithPersistence(channel) {
const [data, setData] = useState([])
const [isConnected, setIsConnected] = useState(false)
useEffect(() => {
const client = new PersistentRealtimeClient(supabase)
const subscription = client.subscribe(channel)
.on('postgres_changes', { event: '*', schema: 'public', table: 'messages' }, (payload) => {
setData(prev => [...prev, payload.new])
client.cacheMessage('messages', [...data, payload.new])
})
.subscribe((status) => {
setIsConnected(status === 'SUBSCRIBED')
})
// Load cached data on mount
const cached = client.getCached('messages')
if (cached) {
setData(cached)
}
return () => {
supabase.removeChannel(subscription)
}
}, [])
return { data, isConnected }
}Enable comprehensive logging to diagnose disconnect issues:
// Create a logging wrapper for Realtime debugging
class RealtimeDebugger {
constructor(supabase) {
this.supabase = supabase
this.eventLog = []
this.connectionLog = []
}
subscribeWithLogging(channel) {
return channel
.subscribe((status) => {
const logEntry = {
timestamp: new Date().toISOString(),
status,
sessionStorage: {
isOnline: navigator.onLine,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
}
}
this.connectionLog.push(logEntry)
console.log('[Realtime Debug]', logEntry)
// Upload logs periodically for analysis
if (this.connectionLog.length % 10 === 0) {
this.uploadLogs()
}
})
}
logEvent(eventType, payload) {
const logEntry = {
timestamp: Date.now(),
eventType,
payload,
browserInfo: {
userAgent: navigator.userAgent,
connection: navigator.connection?.effectiveType,
onLine: navigator.onLine
}
}
this.eventLog.push(logEntry)
console.log('[Event Log]', logEntry)
}
async uploadLogs() {
try {
// Send logs to your backend for analysis
await fetch('/api/realtime-logs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connectionLog: this.connectionLog.slice(-50), // Last 50 entries
eventLog: this.eventLog.slice(-100) // Last 100 entries
})
})
} catch (error) {
console.error('Failed to upload logs:', error)
}
}
getConnectionStats() {
const disconnects = this.connectionLog.filter(e =>
e.status === 'CHANNEL_ERROR' || e.status === 'TIMED_OUT'
).length
const connects = this.connectionLog.filter(e =>
e.status === 'SUBSCRIBED'
).length
return {
totalConnections: connects,
totalDisconnects: disconnects,
ratio: connects > 0 ? (disconnects / connects).toFixed(2) : 0
}
}
}
// Enable debug logging in Supabase client
const supabase = createClient(supabaseUrl, supabaseKey, {
realtime: {
logLevel: 'debug'
}
})
const debugger = new RealtimeDebugger(supabase)
const channel = supabase.channel('debug-channel')
debugger.subscribeWithLogging(channel)
// Periodically log stats
setInterval(() => {
console.log('Realtime Stats:', debugger.getConnectionStats())
}, 60000) // Every minuteCheck Supabase Status and Community:
1. Visit [status.supabase.com](https://status.supabase.com) for platform status
2. Check [github.com/supabase/realtime](https://github.com/supabase/realtime) for known issues
3. Search Discord or discussions for similar reports
4. Contact support with: connection logs, reproduction steps, affected users count
## Supabase Realtime Disconnect Deep Dive
### Automatic Reconnection Behavior
The @supabase/realtime-js client implements automatic reconnection:
- Heartbeat: Sent every 30 seconds to keep connection alive
- Backoff strategy: 1s → 2s → 5s → 10s exponential backoff
- Max retries: Continues indefinitely after initial connection established
- Fail-fast on initial: Throws error if cannot connect initially
### Mobile App Considerations
Mobile applications have special challenges:
1. Network switching: Switching from WiFi to cellular creates brief disconnect
2. App backgrounding: iOS/Android may close connections when app backgrounded
3. Lock screen: Some devices terminate idle connections when locked
4. Battery optimization: Low power mode may affect connection stability
Solutions:
- Implement heartbeat/keep-alive on client
- Resume subscriptions when app returns to foreground
- Use local notification when realtime reconnects
- Avoid restarting connection immediately after backgrounding
### Browser-Specific Issues
1. Shared Workers: Multiple tabs create separate WebSocket connections
- Solution: Use Shared Workers to multiplex connections
2. Service Workers: Can interfere with WebSocket reconnection
3. Incognito Mode: Some restrictions on WebSocket duration
4. Plugin interference: Security/privacy plugins blocking WebSockets
### Quota-Related Disconnects
Supabase disconnects if quotas exceeded:
- Free tier: 2 concurrent connections per project
- Message throughput: Disconnect if exceeds rate limits
- Channel joins: Limited joins per second
- Presence updates: Limited presence message frequency
Monitor with: SELECT COUNT(*) FROM pg_stat_activity WHERE application_name LIKE '%realtime%'
### Network Proxy Issues
Some corporate environments have problematic proxy setups:
1. SSL Inspection: Breaks WebSocket TLS negotiation
2. Idle timeout: Proxy closes idle connections after 5-15 minutes
3. Compression: Proxy tries to compress WebSocket frames (breaks protocol)
4. Rate limiting: Proxy limits connection frequency
Workarounds:
- Add proxy certificate to system trust store
- Configure proxy timeout settings higher
- Disable proxy for Supabase domains if possible
- Use VPN as alternative
### Performance Metrics to Monitor
Track these to understand reconnection patterns:
1. Connection success rate: (Total successful connects) / (Total attempts)
2. Time to reconnect: Average time from disconnect to SUBSCRIBED
3. Disconnect frequency: Disconnects per user per day
4. Message latency: Time from database change to client receipt
5. Error rate: Failed operations / total operations
### Fallback Architecture
For critical applications, implement multi-layer fallback:
// Tier 1: Realtime (immediate updates)
const realtimeChannel = supabase.channel('tier1')
// Tier 2: Polling (every 5 seconds if realtime down)
let pollInterval
realtimeChannel.subscribe((status) => {
if (status !== 'SUBSCRIBED' && !pollInterval) {
console.log('Realtime down, starting poll')
pollInterval = setInterval(async () => {
const { data } = await supabase.from('table').select()
// Process data
}, 5000)
} else if (status === 'SUBSCRIBED' && pollInterval) {
console.log('Realtime restored, stopping poll')
clearInterval(pollInterval)
pollInterval = null
}
})
// Tier 3: Manual refresh (user triggers)
button.addEventListener('click', async () => {
const { data } = await supabase.from('table').select()
// Update UI
})This ensures users always get updates, with automatic degradation to slower methods during realtime outages.
email_conflict_identity_not_deletable: Cannot delete identity because of email conflict
How to fix "Cannot delete identity because of email conflict" in Supabase
mfa_challenge_expired: MFA challenge has expired
How to fix "mfa_challenge_expired: MFA challenge has expired" in Supabase
conflict: Database conflict, usually related to concurrent requests
How to fix "database conflict usually related to concurrent requests" in Supabase
phone_exists: Phone number already exists
How to fix "phone_exists" in Supabase
StorageApiError: resource_already_exists
StorageApiError: Resource already exists