Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,10 @@ export const functions: NavMenuConstant = {
name: 'Troubleshooting',
url: '/guides/functions/troubleshooting' as `/${string}`,
},
{
name: 'Worker timeouts and WebSocket drops',
url: '/troubleshooting/edge-functions-worker-timeouts-and-websocket-drops' as `/${string}`,
},
],
},
{
Expand Down Expand Up @@ -1783,6 +1787,10 @@ export const functions: NavMenuConstant = {
name: 'Image Transformation & Optimization',
url: '/guides/functions/examples/image-manipulation' as `/${string}`,
},
{
name: 'Resumable WebSockets with replay',
url: '/guides/functions/examples/resumable-websockets' as `/${string}`,
},
],
},
{
Expand Down Expand Up @@ -2781,6 +2789,18 @@ export const platform: NavMenuConstant = {
name: 'Branching',
url: '/guides/platform/manage-your-usage/branching' as `/${string}`,
},
{
name: 'Logs',
url: '/guides/platform/manage-your-usage/logs' as `/${string}`,
},
{
name: 'Logs Ingest',
url: '/guides/platform/manage-your-usage/logs-ingest' as `/${string}`,
},
{
name: 'Logs Query',
url: '/guides/platform/manage-your-usage/logs-query' as `/${string}`,
},
{
name: 'Log Drains',
url: '/guides/platform/manage-your-usage/log-drains' as `/${string}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Price price="0.50" /> per GB. You are only charged for usage exceeding your subscription plan's
quota.

| Plan | Quota | Over-Usage per GB |
| ---------- | ------ | ---------------------- |
| Free | 5 GB | - |
| Pro | 5 GB | <Price price="0.50" /> |
| Team | 5 GB | <Price price="0.50" /> |
| Enterprise | Custom | Custom |
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Price price="0.002" /> per GB. You are only charged for usage exceeding your subscription plan's
quota.

| Plan | Quota | Over-Usage per GB |
| ---------- | -------- | ----------------------- |
| Free | 1,000 GB | - |
| Pro | 1,000 GB | <Price price="0.002" /> |
| Team | 1,000 GB | <Price price="0.002" /> |
| Enterprise | Custom | Custom |
225 changes: 225 additions & 0 deletions apps/docs/content/guides/functions/examples/resumable-websockets.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
---
title: 'Resumable WebSockets with Edge Functions'
description: 'Build reconnect-safe WebSockets with event replay, idempotency keys, and graceful restarts.'
---

This example shows how to build a reconnect-safe chat stream on Supabase Edge Functions using:

- WebSocket upgrade + JWT auth
- Postgres-backed session and event persistence
- Event replay with `lastEventId`
- Idempotent user messages with `idempotency_key`
- Graceful client reconnects during worker restarts

Reference implementation: [Building Resumable WebSockets with Supabase Edge Functions and Postgres](https://blog.mansueli.com/building-resumable-websockets-with-supabase-edge-functions-and-postgres)

## Architecture

1. Client connects with a user JWT, plus optional `sessionId` and `lastEventId`.
2. Function verifies auth and either resumes or creates a session.
3. Every message is written to `ws_events` with an incrementing `id`.
4. On reconnect, server replays events where `id > lastEventId`.
5. Client updates local `lastEventId` and resumes without losing messages.

## Database schema

```sql
create extension if not exists pgcrypto;

create table ws_sessions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
created_at timestamptz default now(),
updated_at timestamptz default now(),
last_event_id bigint default 0
);

create table ws_events (
id bigint generated by default as identity primary key,
session_id uuid not null references ws_sessions(id) on delete cascade,
event_type text not null,
payload jsonb not null,
created_at timestamptz default now()
);
create index ws_events_session_id_id_idx on ws_events(session_id, id);

create table ws_idempotency_keys (
session_id uuid not null references ws_sessions(id) on delete cascade,
idempotency_key uuid not null,
primary key(session_id, idempotency_key)
);

create unlogged table ws_live_connections (
session_id uuid primary key,
connected_at timestamptz default now(),
last_seen_at timestamptz default now(),
edge_region text
);
```

## Edge Function (WebSocket proxy)

Use `supabase functions serve --no-verify-jwt` and validate JWT inside the function.

```ts
import { createAdminClient, createContextClient, verifyCredentials } from '@supabase/server/core'

const PREEMPTIVE_RESTART_MS = 340_000

function send(socket: WebSocket, payload: unknown) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(payload))
}
}

Deno.serve(async (req) => {
const url = new URL(req.url)
const token = url.searchParams.get('token')
if (!token) return new Response('Missing token', { status: 401 })

const { data: auth, error } = await verifyCredentials({ token, apikey: null }, { auth: 'user' })
if (error || !auth?.userClaims?.id) {
return new Response('Unauthorized', { status: 401 })
}

const admin = createAdminClient()
const { socket, response } = Deno.upgradeWebSocket(req, { idleTimeout: 0 })

// Prevent EarlyDrop by keeping a pending promise until socket close.
let resolveClosed!: () => void
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve
})
// @ts-ignore
EdgeRuntime.waitUntil(closed)

const requestedSessionId = url.searchParams.get('sessionId')
const lastEventId = Number(url.searchParams.get('lastEventId') || 0)
const sessionId = requestedSessionId ?? crypto.randomUUID()

socket.onclose = () => {
resolveClosed()
}

socket.onmessage = async (event) => {
const msg = JSON.parse(event.data)

if (msg.type === 'user_message') {
const { error: idempotencyError } = await admin.from('ws_idempotency_keys').upsert(
{
session_id: sessionId,
idempotency_key: msg.idempotency_key,
},
{ onConflict: 'session_id,idempotency_key', ignoreDuplicates: true }
)

let userEvent

if (idempotencyError) {
// Conflict detected - this is a retry, fetch the existing event
const { data: existingEvent } = await admin
.from('ws_events')
.select()
.eq('session_id', sessionId)
.eq('idempotency_key', msg.idempotency_key)
.single()

userEvent = existingEvent
} else {
// New idempotency key - insert the event
const { data: newEvent } = await admin
.from('ws_events')
.insert({
session_id: sessionId,
event_type: 'user_message',
payload: { content: msg.content },
})
.select()
.single()

userEvent = newEvent
}

send(socket, {
type: 'user_message',
payload: userEvent?.payload,
event_id: userEvent?.id,
})
}
}

send(socket, { type: 'session_init', session_id: sessionId })

queueMicrotask(async () => {
const { data: replayEvents } = await admin
.from('ws_events')
.select('*')
.eq('session_id', sessionId)
.gt('id', lastEventId)
.order('id')

for (const event of replayEvents ?? []) {
send(socket, {
type: event.event_type,
payload: event.payload,
event_id: event.id,
replay: true,
})
}
})

setTimeout(() => {
send(socket, { type: 'server_restarting' })
socket.close(1012, 'Service restart')
}, PREEMPTIVE_RESTART_MS)

return response
})
```

## Browser client

The client stores `sessionId` and `lastEventId` in session storage, then reconnects with exponential backoff.

```ts
let sessionId = sessionStorage.getItem('ws_session_id')
let lastEventId = Number(sessionStorage.getItem('last_event_id') || 0)

function connect(token: string) {
const url =
`wss://YOUR_PROJECT.functions.supabase.co/websocket-proxy` +
`?token=${encodeURIComponent(token)}` +
`&lastEventId=${lastEventId}` +
(sessionId ? `&sessionId=${sessionId}` : '')

const ws = new WebSocket(url)

ws.onmessage = (e) => {
const msg = JSON.parse(e.data)

if (msg.event_id) {
lastEventId = Math.max(lastEventId, msg.event_id)
sessionStorage.setItem('last_event_id', String(lastEventId))
}

if (msg.type === 'session_init') {
sessionId = msg.session_id
sessionStorage.setItem('ws_session_id', sessionId)
}
}
}
```

## Why this pattern works

- If the worker restarts, the client reconnects with the same session.
- Replay closes delivery gaps caused by reconnect windows.
- Idempotency keys prevent duplicate inserts when clients retry.
- `EdgeRuntime.waitUntil()` prevents unexpected early termination of idle-looking WebSocket workers.

## Next steps

- Add row-level security policies for all `ws_*` tables.
- Add a heartbeat and cleanup policy for stale sessions.
- Add structured event payload types and input validation.
- Add observability dashboards for disconnect rate and replay lag.
4 changes: 4 additions & 0 deletions apps/docs/content/guides/functions/websockets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This allows you to:
- Create WebSocket relay servers for external APIs
- Establish both incoming and outgoing WebSocket connections

For a production-ready reconnect pattern with session persistence and replay, see [Resumable WebSockets with Edge Functions](/docs/guides/functions/examples/resumable-websockets).

---

## Creating WebSocket servers
Expand Down Expand Up @@ -249,6 +251,8 @@ The maximum duration is capped based on the wall-clock, CPU, and memory limits.

</Admonition>

When using WebSockets, keep in mind that the HTTP request is considered complete after `Deno.upgradeWebSocket(req)` returns the response. To prevent early worker retirement while the socket is still open, keep an unresolved `EdgeRuntime.waitUntil()` promise that resolves in `socket.onclose`.

---

## Testing WebSockets locally
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/content/guides/platform/cost-control.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ When the Spend Cap is off, we recommend monitoring your usage and costs on the [
- [Disk Size](/docs/guides/platform/manage-your-usage/disk-size)
- [Egress](/docs/guides/platform/manage-your-usage/egress)
- [Edge Function Invocations](/docs/guides/platform/manage-your-usage/edge-function-invocations)
- [Logs Ingest](/docs/guides/platform/manage-your-usage/logs-ingest)
- [Logs Query](/docs/guides/platform/manage-your-usage/logs-query)
- [Monthly Active Users](/docs/guides/platform/manage-your-usage/monthly-active-users)
- [Monthly Active SSO Users](/docs/guides/platform/manage-your-usage/monthly-active-users-sso)
- [Monthly Active Third Party Users](/docs/guides/platform/manage-your-usage/monthly-active-users-third-party)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
id: 'manage-usage-logs-ingest'
title: 'Manage Logs Ingest usage'
---

<Admonition type="caution" title="Coming soon">

Logs pricing is being rolled out. Quotas and rates on this page are confirmed but billing enforcement is not yet live. This page will be updated when the rollout is complete.

</Admonition>

## What you are charged for

You are charged for the total volume of log data that Supabase ingests across all your project's services (Postgres, API gateway, Auth, Storage, Realtime, Edge Functions, etc.) during the billing cycle, measured in GB.

## How charges are calculated

Logs Ingest is charged per GB of log data ingested during the billing cycle.

### Usage on your invoice

Usage is shown as "Logs Ingest" on your invoice.

## Pricing

<$Partial path="billing/pricing/pricing_logs_ingest.mdx" />

## Billing examples

### Within quota

The organization's Logs Ingest usage is within the quota, so no charges for Logs Ingest apply.

| Line Item | Units | Costs |
| ------------------- | --------- | ------------------------ |
| Pro Plan | 1 | <Price price="25" /> |
| Compute Hours Micro | 744 hours | <Price price="10" /> |
| Logs Ingest | 2 GB | <Price price="0" /> |
| **Subtotal** | | **<Price price="35" />** |
| Compute Credits | | -<Price price="10" /> |
| **Total** | | **<Price price="25" />** |

### Exceeding quota

The organization's Logs Ingest usage exceeds the quota by 7 GB, incurring charges for this additional usage.

| Line Item | Units | Costs |
| ------------------- | --------- | -------------------------- |
| Pro Plan | 1 | <Price price="25" /> |
| Compute Hours Micro | 744 hours | <Price price="10" /> |
| Logs Ingest | 12 GB | <Price price="3.5" /> |
| **Subtotal** | | **<Price price="38.5" />** |
| Compute Credits | | -<Price price="10" /> |
| **Total** | | **<Price price="28.5" />** |

## View usage

You can view Logs Ingest usage on the [organization's usage page](/dashboard/org/_/usage) of the Dashboard. The page shows the usage of all projects by default. To view the usage for a specific project, select it from the dropdown. You can also select a different time period.

{/* TODO: Add screenshots once Studio surfaces are live */}

## Optimize usage

Every service in your Supabase project automatically generates Logs — you don't write them directly. Log volume scales with your application's traffic and behavior. To reduce ingest volume:

- **Reduce log-level verbosity** in your Edge Functions and server-side code (for example, `info` → `warn` in production).
- **Audit verbose logging in your application code.** Application-level logs forwarded to Supabase services count toward ingest.
- **Cap log payload size.** Large structured payloads inflate GB-billed volume quickly.
- **Investigate spikes.** Use the [**Logs Explorer**](/dashboard/project/_/logs-explorer) section of the Dashboard to find services or endpoints producing unusually high volume.

## Exceeding Quotas

<$Partial path="billing/exceeding_usage_quotas.mdx" />
Loading
Loading