Real-time dashboards have a fundamental mismatch at their core. The dashboard shows 10 orders at a time. The orders topic has 50,000 active orders. Without any filtering, every subscriber receives every update for every order, all day, even though they only care about the 10 currently on screen.
This is the firehose problem. It wastes bandwidth, taxes the client's rendering thread, and scales terribly. The fix is filtered subscriptions: subscribing to a topic but telling the infrastructure exactly which entities within that topic you care about.
The Unfiltered Problem in Detail
Imagine an e-commerce operations dashboard. The platform processes 500 order updates per second across 50,000 live orders, published by a dozen order services (a single NoLag connection may publish at most 50 messages a second, so volume like this always comes from several publishers). An operator loads the dashboard and subscribes to the orders topic. Their browser immediately starts receiving all 500 updates per second, rendering changes for orders they cannot even see. The tab uses hundreds of megabytes per hour of bandwidth. On a slower connection, frames drop and the UI stutters.
The typical band-aid is to debounce or throttle on the client side: only re-render every 250ms, discard updates for off-screen items. But you are still transmitting everything over the wire. The client is still doing work to parse and discard messages. The problem is just hidden behind a render optimization.
The right fix is infrastructure-level filtering: only send the client the messages it actually needs.
How Topic Filters Work
A topic filter is a set of routing keys you attach to a subscription. When you subscribe to a topic, you include a list of entity identifiers, and the infrastructure only delivers messages that were published with one of those keys.
import { NoLag } from '@nolag/js-sdk'
const client = NoLag('YOUR_TOKEN')
await client.connect() // subscribe before connect fails with "Not connected"
// APP_SLUG is the slug returned when you created the app (it has a random
// suffix); create the orders-room room in the control plane first.
const room = client.setApp(APP_SLUG).setRoom('orders-room')
// Subscribe to the orders topic with filters,
// only receive updates for the 10 orders currently on screen
room.subscribe('orders', {
filters: [
'order_abc123',
'order_def456',
'order_ghi789',
// ...up to 100 keys per topic
],
})
room.on('orders', (order, meta) => {
updateOrderRow(order) // the payload, as published
console.log(meta.filter) // the key it was published with, e.g. 'order_abc123'
})The infrastructure evaluates the filter on the server side before the message is transmitted. Each key is its own routing key on the broker, so there is no payload inspection and no schema for the broker to learn: a subscription with filters receives only messages published with one of its keys, and everything else is never sent to your connection.
Publishers do not change their topic. They publish as normal and name the entity key on the publish:
// An order service publishes an update, tagged with the order's key.
// It is a different actor from the dashboard: publishers never receive their own messages.
const room = client.setApp(APP_SLUG).setRoom('orders-room')
room.emit('orders', {
orderId: 'order_abc123',
status: 'shipped',
updatedAt: new Date().toISOString(),
}, { filter: 'order_abc123' })Subscribers with order_abc123 in their filter list receive this. Subscribers with a filter list that does not include it do not. The logic lives entirely in the infrastructure, not in application code.
One nuance is worth knowing before you rely on it. A subscription without filters is a wildcard: it receives every message on the topic, including the tagged ones, which is what an "all orders" admin view wants. And a message published without a filter reaches only those wildcard subscribers, never a filtered one. So filters narrow what a client asks for; they are not a privacy boundary. Use per-topic ACL for that.
Why Infrastructure-Level Filtering Matters
Client-side filtering is not a real solution. Even if you discard messages in JavaScript before updating the UI, you have still paid for the network transmission of those messages. On a mobile connection, that matters a lot. On a connection with a data cap, it matters even more.
Infrastructure-level filtering means the message is evaluated once, at the server, before any transmission happens. A client subscribed to 10 orders out of 50,000 receives roughly 0.02% of the total message volume on that topic. The savings are proportional to how selective the subscription is.
This also reduces load on your infrastructure. Fewer messages transmitted means fewer bytes processed per connection, which translates to more connections you can serve with the same resources.
Dynamic Filter Swapping
Dashboards are not static. Users navigate to a different page, apply a filter, or scroll to a different set of orders. When they do, the set of entities on screen changes, and the subscription filters need to change with it.
You can update filters on an active subscription without tearing it down and reconnecting:
// Dashboard mounts on page 1 of orders
const room = client.setApp(APP_SLUG).setRoom('orders-room')
room.subscribe('orders', {
filters: currentPageOrderIds,
})
room.on('orders', handleOrderUpdate)
// Later, when the user changes page: replace the whole set in one call.
// The broker diffs the old and new keys itself.
room.setFilters('orders', newPageOrderIds)
// Or adjust at the edges as rows scroll in and out of view
room.addFilters('orders', ['order_jkl012'])
room.removeFilters('orders', ['order_abc123'])
// An empty set switches the subscription back to the wildcard (everything)
room.setFilters('orders', [])This keeps the subscription lifecycle clean. You open one subscription when the dashboard mounts, swap the filters whenever the visible set changes, and unsubscribe when the user navigates away. No reconnections, no subscription bookkeeping beyond the topic name. Do not re-subscribe with new filters instead: a second subscribe on the same topic unsubscribes first, so there is a gap, whereas setFilters sends one frame and the broker applies the difference.
Key values may not contain /, #, + or |, and a subscription holds at most 100 keys per topic. If a screen can show more entities than that, page the subscription the same way you page the table.
A Concrete Bandwidth Example
Suppose the orders topic sends 500 updates per second. Each message is roughly 400 bytes. That is 200KB/s, or 720MB per hour, for an unfiltered subscriber.
A dashboard showing 10 orders with matching filters receives around 10 updates per second (assuming updates are distributed evenly). That is 4KB/s, or about 14MB per hour. A 50x reduction, and it scales. Show 20 orders and you receive roughly 20 updates per second. Show 5 and you receive fewer. The client's bandwidth usage is directly proportional to what it actually needs to show.
Why Mobile Makes This Non-Negotiable
On desktop over a fast connection, an unfiltered subscription is annoying but workable. On mobile over LTE or a congested Wi-Fi network, it is a product defect. Users notice when tabs drain their data plan. They notice when the app feels sluggish because it is processing hundreds of messages per second it does not need.
Mobile devices also have tighter CPU constraints. Parsing, deserializing, and discarding messages in JavaScript is not free. Every message your client receives that it does not use is CPU time spent on nothing. Infrastructure-level filtering means those messages are never serialized, transmitted, or parsed in the first place.
If you are building any kind of real-time dashboard that will be used on mobile, filtered subscriptions are not an optimization. They are the baseline architecture.
Getting Started with NoLag Filters
Filtered subscriptions are built into the NoLag client SDKs. You do not need any special configuration or server-side code changes. Publish with { filter: key }, subscribe with a filters array, and the infrastructure handles the rest. Update filters as your UI state changes with room.setFilters(topic, keys), room.addFilters and room.removeFilters. The filters guide covers the same API in Python and Go, and the AND-composite form for keys that must all match.
The dashboard example above, with 50,000 orders and 10 on screen at a time, is what filters exist for. The routing happens on the broker before a message ever reaches your connection, so the client's cost tracks what is on screen rather than what the platform is doing.