"Subscriptions"

Subscribe to queries and receive realtime updates when data changes.

How subscriptions work

Tether uses WebSocket connections to push updates to clients. When you subscribe to a query, you receive the initial data immediately, then automatic updates whenever a mutation affects the subscribed data.

Flow:

  1. Client subscribes to query — the client opens a WebSocket connection and registers a subscription.
  2. Initial data returned — the server immediately responds with the current query result.
  3. Updates pushed on changes — whenever a mutation affects the subscribed data, the server pushes an update.

Who receives data

What a connection receives depends on how it authenticates when it opens:

Connection Receives
Publishable key (?key=tthr_pk_...), with an optional end-user token Fresh results of deployed query functions, run as that user with RLS applied. Raw table queries get a refetch signal instead.
Secret API key, or a dashboard or CLI session of a project member (?token=...) Full results, logs, and cron triggers for server connections
No credentials A refetch signal (invalidate) when a subscribed query changes, never data

A key only reaches its own environment. Function logs stream only to full-access connections in the same environment.

Subscribing in Vue/Nuxt

Use the useQuery composable for reactive subscriptions. It's auto-imported by the Nuxt module:

// This automatically subscribes and updates when data changes
const { data, isLoading, error } = useQuery('todos.list', { completed: false });

Subscribing in React

Use the vanilla client's subscribe method with React's useEffect. The React SDK is in development.

import { useEffect, useState } from 'react';
import { tether } from '../lib/tether';

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const unsubscribe = tether.subscribe(
      'todos.list',
      { completed: false },
      (data) => {
        setTodos(data);
        setLoading(false);
      }
    );
    return () => unsubscribe();
  }, []);

  if (loading) return <p>Loading...</p>;

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Manual subscriptions

For more control, use the low-level subscription API:

import { TetherClient } from '@tthr/client';

const client = new TetherClient({
  url: 'https://tether-api.yourdomain.com',
  projectId: 'your-project-id',
  authToken: () => getAuthToken(), // optional
});

// Subscribe to a query
const unsubscribe = client.subscribe(
  'todos.list',
  { completed: false },
  (data) => {
    console.log('Todos updated:', data);
  }
);

// Unsubscribe when done
unsubscribe();

Update behaviour

Subscription updates are complete snapshots, not diffs. When data changes, you receive the full query result. This simplifies client-side state management and ensures consistency.

Limits

Limit Value
Subscriptions per connection 200
Connections per IP address 50 per project, 200 across all projects
Message size 64 KB
Client messages 20 a second, with bursts up to 250. A connection that sends faster is closed.
Fresh results after one write 100 subscription groups. Past that, subscribers are told to refetch instead.

Connections are checked again every 5 minutes. When the key or token a connection opened with has been revoked or has expired, or its member has been removed, the connection is closed, and the client reconnects with whatever credentials it has now.

Connection resilience

Tether automatically handles connection issues with built-in resilience features.

Heartbeat

The client sends periodic ping messages every 30 seconds to detect connection health. If no response is received within 10 seconds, the client automatically reconnects.

Automatic reconnection

On disconnection, the client reconnects with exponential backoff: the wait doubles from 1 second up to 30 seconds, with a random part so clients that dropped together don't all return at once. It keeps trying until it's back, so a slow deploy never leaves a page disconnected for good. Calling disconnect() stops it.

Token renewal

A socket keeps the end-user token it connected with. To carry on past that token's exp, the client sends a fresh one over the open socket:

{ "type": "auth", "token": "<new JWT>" }

The server answers { "type": "authenticated", "expires_at": 1767225600 }, or an error with code AUTH_REJECTED when the token isn't valid or is for a different user (the socket keeps its old token; reconnect to switch user). The JavaScript client does this a minute before expiry.

If the token runs out anyway, the server closes the socket with code 4401 ("Auth expired") within about a minute of its exp, rather than leaving it receiving data under the old identity. Reconnect with a fresh token.

Sync on reconnect

When reconnecting, the client sends its lastSyncTime to receive any changes that occurred while disconnected. The server maintains a change queue with:

  • Maximum 1000 entries per project
  • Maximum age of 5 minutes
  • Per-query isolation

For disconnections longer than 5 minutes, clients receive fresh data without change replay.

Versioning

Each data message includes a version number. Clients can detect missed updates if versions are non-sequential:

{
  "type": "data",
  "id": "sub1",
  "data": [...],
  "meta": { "version": 42 }
}

Seeing who is connected

The project Overview tab in the dashboard lists every open WebSocket connection for the selected environment, with its subscriptions and the signed-in user id. See Observability.