Open-source project
discourse/message_bus avatar
discourse/message_bus

message_bus uses Rack Hijack for long-poll connections in Rack applications

A reliable and robust messaging bus for Ruby and Rack

1,659 stars129 forksRubyMIT

At a glance

What is it?
message_bus is an MIT licensed Rack middleware extracted from Discourse that implements server-to-server channels and a polling, long-polling or streaming protocol for web clients, taking over the socket through Rack Hijack. Targeting logic runs per poll on the server, and targeted messages are never queued for non-recipients.
Who is it for?
Adopt message_bus if you are running a Rails or Rack application that already has a way to fan messages out across processes and want server-push over long polling. Do not adopt it as a standalone pub/sub service, since the README describes middleware inside your existing Rack stack rather than an independent server, and note that the gem requires Ruby 3.3 or newer.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 49 days ago.
What is it written in?
Mainly Ruby, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Rack Hijack is the mechanism, and everything else follows

MessageBus is Rack middleware that adds a messaging bus to any Rails, Sinatra or pure Rack application. It is extracted from Discourse and is described as being used in thousands of production Discourse sites at scale.

The mechanism that makes concurrent waiting cheap is Rack Hijack. The README states that since long-polling is implemented using Rack Hijack and Thin::Async, all common Ruby web servers, Pitchfork/Unicorn, Puma, Thin and Passenger, can run MessageBus and handle a large number of concurrent connections that wait on messages.

That is the architectural claim in one sentence, and it is worth parsing. A long-poll request occupies a connection for the duration of the wait, which sounds expensive until you realise MessageBus can take control of the underlying socket. Instead of N waiting clients pinning N workers, the process holds N mostly idle sockets. The README puts the capability as thousands of concurrent long polls on all popular Ruby web servers.

It also runs as middleware and does not require a dedicated server, with background work minimised so it does not interfere with existing non-MessageBus traffic. That last clause is the one that matters in a shared Rails process: a feature that only exists on your chat page should not degrade the rest of the site.

The protocol is dual. There is a Server to Server channel based protocol, and a Server to Web Client protocol using polling, long-polling, or long-polling plus streaming. A client that cannot hold a connection open can still poll.

Channels with a backlog you can read positionally

The server-to-server API is four calls, and the shape of the subscription signature is the part to understand.

Publishing returns a message id:

ruby
message_id = MessageBus.publish "/channel", "message"

Subscribing takes an optional backlog offset as a second positional argument, and the semantics are worth being precise about because they are easy to misread:

ruby
MessageBus.subscribe "/channel" do |msg|
  # block called in a background thread when message is received
end

MessageBus.subscribe "/channel", 0 do |msg|
  # block called in a background thread when message is received
end

MessageBus.subscribe "/channel", 5 do |msg|
  # block called in a background thread when message is received
end

No offset means only messages published from the moment of subscribing. Offset 0 means the entire backlog. Offset 5 means the backlog starting at message 6, which is the convention that matters: the argument is a zero-based message index, not a message id. Off-by-one errors here produce a duplicated or dropped message at the start of every stream.

The block runs in a background thread, not in the request thread, so anything it touches needs to be thread safe.

Two more calls give you the read side of the same mechanism: `last_id` returns the id of the last message on a channel, and `backlog` returns all messages after some id. Together they are what a client uses to resume a stream after a dropped connection, and they are also what a service uses to replay missed work.

What the README does not document is how long a backlog is retained. Since retention is what decides whether a backlog is a recovery mechanism or a best-effort replay, that gap is worth resolving against the generated docs at rubydoc.info before you build on it.

Targeting runs on every poll, which is why the lookups must be fast

Targeted messages are the feature that makes MessageBus usable for anything other than chat, and the mechanism behind them has a real cost characteristic.

You can target individual clients with the `client_ids` option. Beyond that, you configure `user_id_lookup` and `group_ids_lookup` with a Proc or Lambda that is called with a Rack specification environment, and then target users or groups when publishing:

ruby
MessageBus.publish "/channel", "hello", user_ids: [1, 2, 3]
MessageBus.publish "/channel", "hello", group_ids: [1, 2, 3]

The README's own comment inside the example states where the cost lands: this lookup occurs on JS-client polling, so that the server can retrieve the backlog for the client by considering, matching and filtering the `user_ids` set on published messages. And it states the fallback: if `user_id` is not set at publish time, any `user_id` returned here will receive the message.

The lookup runs during client polling so the server can retrieve a backlog while filtering published messages by user or group. Keep the lookup fast because it is part of the polling path.

The rules for combining targeting options are stated precisely, and they are the kind of detail worth reading twice. If both `user_ids` and `group_ids` are supplied, the message targets clients matching either. If `client_ids` is supplied together with `user_ids` or `group_ids`, `client_ids` is applied unconditionally and messages are filtered further by user or group. Passing `nil` or `[]` to any of the three options is equivalent to allowing all values for that option.

The Rails and Devise example in the README shows the intended shape: a `user_id_lookup` block reading `req.session["warden.user.user.key"]`, which in practice is a database lookup on the request path.

Client-side filters run per message, and the example is a rate limit

Beyond identity targeting, you can filter what reaches the client based on the payload itself. Custom client message filters are registered with `MessageBus#register_client_message_filter`, and the README says this is useful for filtering away messages from the client based on the message's payload.

The worked example is a freshness limit, and it is more interesting than a toy:

ruby
MessageBus.register_client_message_filter('/test') do |message|
  (Time.now.to_i - message.data[:published_at]) <= 20
end

MessageBus.publish('/test/5', { data: "somedata", published_at: Time.now.to_i })

Read it as design guidance. If a client has been disconnected for a minute, replaying its entire backlog of position updates is usually worse than useless. A filter that drops anything older than twenty seconds converts a backlog replay into a clean resync, at the cost of one predicate evaluated per message per poll. That same predicate pattern is how you would implement a presence or rate filter.

The filter is registered against a channel path, so per-channel rules are the unit. If your filtering policy differs per message type rather than per channel, you would be better off publishing to distinct channels.

Error handling is the other extension point, configured with `on_middleware_error`, which receives the environment and the exception. The contract is explicit and worth noting: return a Rack result array of `[status, headers, body]` if you want to handle the error specially, or return `nil` to pass it on to the rest of the middleware chain.

That second option is what you want in a Rails application, where swallowing the error into a blank Rack response would hide it from the framework's exception handling.

Turning it off matters because the Postgres backend connects at boot

There is a shutdown mechanism, and the reason it exists is specific.

The README describes cases where it is undesirable for message_bus to start up on application start, naming a Rails application during the `db:create` rake task when using the Postgres backend, which would error trying to connect to the non-existent database in order to subscribe. The fix is to invoke `MessageBus.off` before the middleware chain is loaded, which in a Rails app the README suggests doing in an initializer, based on an environment variable or some other conditional means.

There is a finer control too. If you want to disable subscribing but still allow publications, you pass `MessageBus.off(disable_publish: false)`. The asymmetry is the point: a rake task that needs to write but not listen is a real case, and a global kill switch cannot express it.

This tells you something about the backends. The test compose file confirms two of them: `REDISURL` pointing at a `redis://redis:6379` service and `PGHOST`, `PGUSER` and `PGPASSWORD` pointing at a `postgres:11.0` service. Both are configured at the environment level, which means the backend is selected by configuration rather than by a compile-time choice, and the gem will attempt a connection at boot.

That boot-time connection is also why the off switch matters in tests. If your test suite creates and drops the database, MessageBus will try to connect to a database that does not exist yet, and the failure looks unrelated to your test.

Inspecting a channel with curl before writing any client code

The debugging section is short and genuinely useful, which is rarer than it should be.

When setting up MessageBus, it is useful to manually inspect channels before integrating a client application, and you can do it by curling MessageBus. The README gives a concrete invocation against a public Discourse instance:

bash
curl -H "Content-Type: application/x-www-form-urlencoded" -X POST --data "/message=0" https://chat.samsaffron.com/message-bus/client-id/poll\?dlp\=t

The shape generalises to your own deployment: post a channel name with a backlog offset of 0, at the client-id poll endpoint, and you get the whole backlog as JSON. Seeing actual messages settle most integration questions faster than reading the protocol documentation.

For local work the repository carries three examples: `examples/minimal/`, `examples/chat/` and `examples/bench/`. The compose file shows how the chat example is meant to be run, as a Rack application on the pitchfork server bound to all interfaces, with a Redis URL in the environment:

yaml
command: bash -c "cd examples/chat && bundle install && bundle exec rackup --server pitchfork --host 0.0.0.0"

So the chat example is a working long-poll client talking to a real backend, and the bench example is presumably for the concurrency question.

The JS client ships separately from the gem, as a package named `message-bus-client` with the built asset at `assets/message-bus.js`, authored by Sam Saffron and Robin Ward, and it carries its own lint setup with eslint and a jasmine browser runner.

Ruby 3.3 is the floor, and the gem has no GitHub releases

Two maintenance facts.

The Ruby version policy is stated with unusual clarity. MessageBus only supports Ruby versions currently maintained by the Ruby core team, and as of 2026-09-04 the supported versions are Ruby 3.3, 3.4 and 4.0, with a minimum required version of Ruby 3.3.

That is stricter than most gems, and it is stated as a policy tied to the Ruby core team's maintenance window rather than to the gem's own convenience. The practical consequence is that the gem will not run on Ruby 3.2 or older, and there is no stated policy for what happens when the Ruby core team drops 3.3. Expect the floor to move, and expect that to be a breaking change for you rather than a feature.

The release record is thin on GitHub: the repository has no GitHub releases. There is a CHANGELOG file at the root and a `message_bus.gemspec`, so the versioning lives in the gem rather than in GitHub tags. Pin by gem version in your Gemfile.lock, not by Git reference.

On the last push, it was on 2026-09-04, which is the same date the README cites for the Ruby version matrix. That is a useful coincidence: the version support statement and the last change to the repository agree, so the policy quoted is current rather than aspirational.

The licence is MIT, with a LICENSE file at the repository root. The top level also holds a Dockerfile based on `ruby:3.3`, a Rakefile as the container's default command, and a Guardfile for test watching. Note that the Dockerfile installs PhantomJS 2.1.1 from a Bitbucket download, which is a build-environment detail that will age badly and is worth knowing about before you try to build the image.

Editorial conclusion

Adopt message_bus if you are running a Rails or Rack application that already has a way to fan messages out across processes and want server-push over long polling. Do not adopt it as a standalone pub/sub service, since the README describes middleware inside your existing Rack stack rather than an independent server, and note that the gem requires Ruby 3.3 or newer. Verify first that your backend is reachable from every process that will publish, and decide your retention policy for backlogs before you rely on them for anything durable.

Frequently asked questions

How do I install message_bus?

Add `gem 'message_bus'` to your Gemfile and run `bundle`, or install it directly with `gem install message_bus`. The gem requires Ruby 3.3 or newer; as of 2026-09-04 the supported versions are 3.3, 3.4 and 4.0.

Does message_bus need a separate server or a dedicated process?

No. It is implemented as Rack middleware and can be used by any Rails, Sinatra or pure Rack application, and the README states it does not require a dedicated server. It uses Rack Hijack with Thin::Async, which the README says lets Pitchfork/Unicorn, Puma, Thin and Passenger handle large numbers of concurrent waiting connections.

What is the difference between subscribing with and without a backlog offset?

No offset delivers only messages published from the moment of subscribing, offset 0 delivers the entire backlog, and offset 5 delivers the backlog starting at message 6. The callback block runs in a background thread.

How do I send a message to specific users or groups?

Configure `user_id_lookup` and `group_ids_lookup` with a Proc called with the Rack env, then pass `user_ids` or `group_ids` when publishing. The README notes the lookup happens on client polling, and that if `user_id` is not set on publish, any id returned by the lookup receives the message.

How do I stop message_bus from starting up?

Invoke `MessageBus.off` before the middleware chain loads, for example in a Rails initializer, which the README suggests when the `db:create` rake task errors against the Postgres backend. To keep publishing but stop subscribing, use `MessageBus.off(disable_publish: false)`.

How can I debug a message_bus channel?

Curl the client-id poll endpoint with a channel name and a backlog offset, as the README shows with a POST of `/message=0`. The repository also ships runnable examples at `examples/minimal/`, `examples/chat/` and `examples/bench/`.

Official sources

  1. discourse/message_bus on GitHub
  2. Issues
  3. License: MIT
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/discourse-message-bus.svg)](https://hysenlabs.com/projects/discourse-message-bus)

Was this useful?