← Back to blog
TUTORIAL6 min read

Real-Time Dashboards Without the Firehose: Filtered Subscriptions

HB
Henco Burger
March 17, 2026

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. 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 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 match one of those keys.

import { NoLag } from '@nolag/js-sdk'

const client = NoLag('YOUR_TOKEN')

// Connect to your app and room
const room = client.setApp('my-dashboard').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 however many are on screen
  ],
})

room.on('orders', (message) => {
  updateOrderRow(message.payload)
})

The infrastructure evaluates the filter on the server side before the message is transmitted. If the message's entity key is not in your filter list, the message is never sent to your connection. Your client receives only what it asked for.

Publishers do not change anything. They publish to the topic as normal, including an entity key in the message:

// Server publishes an order update
const room = client.setApp('my-dashboard').setRoom('orders-room')

room.publish('orders', {
  key: 'order_abc123',
  payload: {
    orderId: 'order_abc123',
    status: 'shipped',
    updatedAt: new Date().toISOString(),
  }
})

Subscribers with order_abc123 in their filter list receive this. Subscribers without it do not. The logic lives entirely in the infrastructure, not in application code.

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:

// User navigates to page 2 of orders
const room = client.setApp('my-dashboard').setRoom('orders-room')

room.subscribe('orders', {
  filters: currentPageOrderIds,
})

room.on('orders', handleOrderUpdate)

// Later, when the user changes page or applies a filter
room.subscribe('orders', {
  filters: newPageOrderIds,
})

// The old filters are replaced atomically.
// You start receiving updates for the new set immediately.

This keeps the subscription lifecycle clean. You open one subscription when the dashboard mounts, swap the filters whenever the visible set changes, and close the subscription when the user navigates away. No reconnections, no subscription bookkeeping beyond a single handle.

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 SDK. You do not need any special configuration or server-side code changes. Publish messages with a key field, subscribe with a filters array, and the infrastructure handles the rest. Update filters dynamically with subscription.updateFilters() as your UI state changes.

The dashboard example above, with 50,000 orders and 10 on screen at a time, is a workload NoLag handles without any special tuning. The filtering logic runs at the connection layer, before messages touch your application tier.