---
title: Topics & Pub/Sub
description: Learn about NoLag topics and the pub/sub messaging model. Understand how topics are named, how they are addressed as app/room/topic, and how messages are routed.
---

# Topics & Pub/Sub

Topics are the foundation of NoLag's messaging system. Learn how to use topics for efficient pub/sub communication.

## What are Topics?

Topics are named channels that clients can subscribe to and publish messages on. When a message is published to a topic, every other subscribed client receives it in real-time. Publishers never receive their own messages, whatever options they pass, so append your own message to the UI locally when you send it.

Topics live inside rooms, and rooms inside apps. The fluent `client.setApp(slug).setRoom(slug)` context below adds the `app/room/` prefix for you. Pass the app slug exactly as the control plane returned it when you created the app: slugs get a random 4-hex suffix, so `my-app` is stored as something like `my-app-a3f9`.

```ts [TypeScript]
import { NoLag } from '@nolag/js-sdk'

const client = NoLag('your_access_token')
await client.connect()

const room = client.setApp('my-app').setRoom('general')

// Subscribe to a topic
room.subscribe('notifications')

// Listen for messages (data + meta)
room.on('notifications', (data, meta) => {
  console.log('Received:', data)
  console.log('Is replay:', meta.isReplay)
  console.log('Message ID:', meta.msgId)
  if (meta.filter) console.log('Filter:', meta.filter)
})

// Publish a message
room.emit('notifications', {
  type: 'alert',
  message: 'New notification!'
})
```
```python [Python]
from nolag import NoLag

client = NoLag('your_access_token')
await client.connect()

room = client.set_app('my-app').set_room('general')

# Subscribe to a topic
await room.subscribe('notifications')

# Listen for messages (data + meta)
def handle_notification(data, meta):
    print('Received:', data)
    print('Is replay:', meta.is_replay)

room.on('notifications', handle_notification)

# Publish a message
await room.emit('notifications', {
    'type': 'alert',
    'message': 'New notification!'
})
```
```go [Go]
package main

import (
    "fmt"

    nolag "github.com/NoLagApp/go-sdk"
)

func main() {
    client := nolag.New("your_access_token")
    client.Connect()

    room := client.SetApp("my-app").SetRoom("general")

    // Subscribe to a topic with message handler
    room.Subscribe("notifications", func(data any, meta nolag.MessageMeta) {
        fmt.Println("Received:", data)
        fmt.Println("Is replay:", meta.IsReplay)
    })

    // Publish a message
    room.Emit("notifications", map[string]any{
        "type":    "alert",
        "message": "New notification!",
    })
}
```

## Topic Naming

A topic name is a single token. The control plane validates it against `^[a-zA-Z0-9_:-]+$`, so letters, digits, `_`, `:` and `-` are allowed and `/` is not. Topics are declared on the app and inherited by every room in it.

- `messages` - chat messages
- `_typing` - typing indicators
- `order:status` - order status updates
- `sensor-readings` - device telemetry

The only hierarchy in NoLag is `app/room/topic`. There are no nested topic paths: what you would express as `users/123/notifications` elsewhere is a room `user-123` with a `notifications` topic.

### Naming Best Practices

- Use lowercase letters and hyphens
- Name the kind of data, not the entity: put the entity in the room name, or in a [filter](/docs/concepts/filters)
- Keep names stable; they are part of the app configuration, not something created on the fly
- Avoid special characters other than `_`, `:` and `-`

## Addressing a Topic

The room context is a convenience. The client itself works on full paths, and the two are interchangeable:

```ts [TypeScript]
// Room context adds the app/room prefix
const room = client.setApp('my-app-a3f9').setRoom('general')
room.subscribe('messages')
room.on('messages', (data) => console.log(data))

// Equivalent, addressing the full path directly
client.subscribe('my-app-a3f9/general/messages')
client.on('my-app-a3f9/general/messages', (data) => console.log(data))
```
```python [Python]
# Room context adds the app/room prefix
room = client.set_app('my-app-a3f9').set_room('general')
await room.subscribe('messages')
room.on('messages', lambda data, meta: print(data))

# Equivalent, addressing the full path directly
await client.subscribe('my-app-a3f9/general/messages')
client.on('my-app-a3f9/general/messages', lambda data, meta: print(data))
```
```go [Go]
// Room context adds the app/room prefix
room := client.SetApp("my-app-a3f9").SetRoom("general")
room.Subscribe("messages", func(data any, meta nolag.MessageMeta) {
    fmt.Println(data)
})

// Equivalent, addressing the full path directly
client.Subscribe("my-app-a3f9/general/messages", func(data any, meta nolag.MessageMeta) {
    fmt.Println(data)
})
```

Every path must name a room that exists. Rooms are created through the control plane (or `POST /apps/{appId}/rooms/ensure` when the app has `config.autoProvisionRooms=true`), never implicitly by the broker. A subscribe or publish to a room the broker does not know is answered with `unknown_topic` (42940) on the `error` event, and nothing is delivered.

There are no client-side wildcards. A subscription matches exactly one `app/room/topic`; to receive a subset of a topic's traffic, use [filters](/docs/concepts/filters).

## Message Structure

Messages can contain any JSON-serializable data:

```ts [TypeScript]
// Publish structured message
room.emit('chat', {
  type: 'chat_message',
  sender: {
    id: 'user-123',
    name: 'Alice'
  },
  content: 'Hello, World!',
  timestamp: Date.now(),
  metadata: {
    room: 'general',
    thread: null
  }
})
```
```python [Python]
# Publish structured message
await room.emit('chat', {
    'type': 'chat_message',
    'sender': {
        'id': 'user-123',
        'name': 'Alice'
    },
    'content': 'Hello, World!',
    'timestamp': time.time(),
    'metadata': {
        'room': 'general',
        'thread': None
    }
})
```
```go [Go]
// Publish structured message
room.Emit("chat", map[string]any{
    "type": "chat_message",
    "sender": map[string]string{
        "id":   "user-123",
        "name": "Alice",
    },
    "content":   "Hello, World!",
    "timestamp": time.Now().Unix(),
    "metadata": map[string]any{
        "room":   "general",
        "thread": nil,
    },
})
```

## Message Events

Subscribe to various events on a topic. Message handlers receive two arguments: the message `data` and a `meta` object containing:

- `isReplay` - `true` only for messages delivered by a [replay](/docs/concepts/replay), which happens for load-balanced worker groups only; an ordinary reconnect replays nothing
- `msgId` - Unique message ID (for acknowledgement)
- `filter` - The filter value the message was published with (if any)

```ts [TypeScript]
import { NoLagServerError } from '@nolag/js-sdk'

// Subscribe and handle events
room.subscribe('chat')

// Incoming messages (includes meta with isReplay, msgId, filter)
room.on('chat', (data, meta) => {
  console.log('Data:', data, 'Meta:', meta)
})

// Connection events
client.on('connect', () => {
  console.log('Successfully connected')
})

client.on('error', (err) => {
  if (err instanceof NoLagServerError) {
    // Broker-side refusal: unknown room, no permission, and so on
    console.error(err.code, err.error, err.topic, err.hint)
  } else {
    console.error('Connection error:', err)
  }
})
```
```python [Python]
# Subscribe and handle events
await room.subscribe('chat')

# Incoming messages (includes meta with isReplay, msgId, filter)
def handle_message(data, meta):
    print('Data:', data, 'Meta:', meta)

room.on('chat', handle_message)

# Connection events
def on_connect():
    print('Successfully connected')

def on_error(err):
    # NoLagServerError carries .code, .error, .topic and .hint
    print('Error:', err)

client.on('connect', on_connect)
client.on('error', on_error)
```
```go [Go]
// Subscribe with handler. Messages arrive directly in the callback
room.Subscribe("chat", func(data any, meta nolag.MessageMeta) {
    fmt.Println("Data:", data, "Meta:", meta)
})

// Connection events
client.On("connected", func(args ...any) {
    fmt.Println("Successfully connected")
})

// Broker-side errors (unknown room, no permission) arrive here,
// because Subscribe and Emit themselves are fire-and-forget
client.OnError(func(err *nolag.ServerError) {
    fmt.Println(err.Code, err.Name, err.Topic, err.Hint)
})
```

## Unsubscribing

Unsubscribe when you no longer need to receive messages:

```ts [TypeScript]
// Unsubscribe from a topic
room.unsubscribe('chat')

// Disconnect client (synchronous, no await needed)
client.disconnect()
```
```python [Python]
# Unsubscribe from a topic
await room.unsubscribe('chat')

# Disconnect client (synchronous)
client.disconnect()
```
```go [Go]
// Unsubscribe from a topic
room.Unsubscribe("chat")

// Disconnect client
client.Close()
```

## Next Steps

- [Topic Filters](/docs/concepts/filters) - narrow delivery to specific entities within a topic
- [Learn about Rooms](/docs/concepts/rooms) - how rooms are created and why the broker never creates them for you
- [Configure Quality of Service](/docs/concepts/qos)
- [Set up Access Control](/docs/concepts/acl)
