Using RoaringBitmap for Message Broker Consumer State in Rust

Updated at July 2026

Imagine we are building a small message broker.

Apps publish messages to topics, and consumers subscribe to those topics. Each consumer pulls messages independently and has its own progress.

The topic log stores messages once:

0
1
2
3
4
0 -> message A
1 -> message B
2 -> message C
3 -> message D
4 -> message E

But each consumer has its own view of what still needs to be processed.

For example, email-worker may still need:

0
0, 1, 2, 3, 4

while analytics-worker may only need:

0
2, 3, 4

So pending delivery state should not live on the message itself.

The topic owns the messages.

The consumer owns the pending state.

That pending state is basically a set of message indexes:

0
pending = messages this consumer still needs to process

This is where RoaringBitmap is useful.

The Rust roaring crate provides RoaringBitmap, a compressed set of u32 integers with operations such as insert, remove, contains, min, iter, and serialized_size.

For a message broker, this fits naturally:

0
pending message indexes = set of u32

Basic message model

We start with a simple message type:

0
1
2
3
4
5
6
7
8
9
#[derive(Debug, Clone)]
struct Message {
    payload: String,
}

#[derive(Debug, Clone)]
struct PulledMessage {
    index: u32,
    payload: String,
}

The topic stores messages in append-only order:

0
1
2
3
#[derive(Default)]
struct Topic {
    messages: Vec<Message>,
}

A message does not know which consumers have processed it.

That information belongs to each consumer.

1. Naive Vec implementation

The simplest implementation is a Vec<u32> containing pending message indexes.

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#[derive(Default)]
struct ConsumerVecState {
    pending: Vec<u32>,
}

impl Topic {
    fn publish_for_vec(&mut self, consumer: &mut ConsumerVecState, payload: String) {
        let index = self.messages.len() as u32;

        self.messages.push(Message { payload });
        consumer.pending.push(index);
    }
}

impl ConsumerVecState {
    fn pull_next(&mut self, topic: &Topic) -> Option<PulledMessage> {
        let next_index = *self.pending.iter().min()?;

        self.pending.retain(|index| *index != next_index);

        let message = topic.messages.get(next_index as usize)?;

        Some(PulledMessage {
            index: next_index,
            payload: message.payload.clone(),
        })
    }

    fn ack(&mut self, _index: u32) {
        // The message was already removed from pending when pulled.
    }

    fn nack(&mut self, index: u32) {
        if !self.pending.contains(&index) {
            self.pending.push(index);
        }
    }
}

Example usage:

0
1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
    let mut topic = Topic::default();
    let mut consumer = ConsumerVecState::default();

    topic.publish_for_vec(&mut consumer, "send email".to_string());
    topic.publish_for_vec(&mut consumer, "resize image".to_string());

    let message = consumer.pull_next(&topic).unwrap();

    println!("Pulled message: {:?}", message);

    consumer.ack(message.index);
}

This works, but the hot path is expensive.

Finding the next message scans the whole list:

0
self.pending.iter().min()

Removing the message also scans the list:

0
self.pending.retain(...)

So the performance shape looks like this:

0
1
2
Find next pending message: O(n)
Remove pending message:    O(n)
Reinsert on nack:          O(n), because of contains

For a small queue, that is fine.

For many pending messages per consumer, it becomes expensive.

2. HashSet implementation

A HashSet<u32> improves insertion, removal, and membership checks.

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
use std::collections::HashSet;

#[derive(Default)]
struct ConsumerHashSetState {
    pending: HashSet<u32>,
}

impl Topic {
    fn publish_for_hashset(&mut self, consumer: &mut ConsumerHashSetState, payload: String) {
        let index = self.messages.len() as u32;

        self.messages.push(Message { payload });
        consumer.pending.insert(index);
    }
}

impl ConsumerHashSetState {
    fn pull_next(&mut self, topic: &Topic) -> Option<PulledMessage> {
        let next_index = *self.pending.iter().min()?;

        self.pending.remove(&next_index);

        let message = topic.messages.get(next_index as usize)?;

        Some(PulledMessage {
            index: next_index,
            payload: message.payload.clone(),
        })
    }

    fn ack(&mut self, _index: u32) {
        // The message was already removed from pending when pulled.
    }

    fn nack(&mut self, index: u32) {
        self.pending.insert(index);
    }
}

This improves several operations:

0
1
2
Remove pending message: average O(1)
Reinsert on nack:       average O(1)
Check membership:       average O(1)

But FIFO pulling is still awkward:

0
self.pending.iter().min()

A HashSet is unordered.

So finding the smallest pending index still requires scanning the whole set:

0
Find next pending message: O(n)

That is the main problem.

For a broker, pull_next() is the hot path. We do not want every pull to scan all pending messages.

3. RoaringBitmap implementation

Now we store pending message indexes in a RoaringBitmap.

0
1
[dependencies]
roaring = "0.11"

A RoaringBitmap is a compressed set of u32 integers.

That is a good fit for message indexes because indexes are already integers and are often dense or range-like.

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
use roaring::RoaringBitmap;

#[derive(Default)]
struct ConsumerRoaringState {
    pending: RoaringBitmap,
}

impl Topic {
    fn publish_for_roaring(&mut self, consumer: &mut ConsumerRoaringState, payload: String) {
        let index = self.messages.len() as u32;

        self.messages.push(Message { payload });
        consumer.pending.insert(index);
    }
}

impl ConsumerRoaringState {
    fn pull_next_fifo(&mut self, topic: &Topic) -> Option<PulledMessage> {
        let next_index = self.pending.min()?;

        self.pending.remove(next_index);

        let message = topic.messages.get(next_index as usize)?;

        Some(PulledMessage {
            index: next_index,
            payload: message.payload.clone(),
        })
    }

    fn pull_any(&mut self, topic: &Topic) -> Option<PulledMessage> {
        let next_index = self.pending.iter().next()?;

        self.pending.remove(next_index);

        let message = topic.messages.get(next_index as usize)?;

        Some(PulledMessage {
            index: next_index,
            payload: message.payload.clone(),
        })
    }

    fn ack(&mut self, _index: u32) {
        // The message was already removed from pending when pulled.
    }

    fn nack(&mut self, index: u32) {
        self.pending.insert(index);
    }
}

The important operation is this:

0
let next_index = self.pending.min()?;

That asks:

0
What is the smallest pending message index for this consumer?

Then this:

0
self.pending.remove(next_index);

means:

0
The consumer has pulled this message, so it is no longer pending.

If the consumer acks the message, nothing else has to happen to the pending set:

0
1
2
fn ack(&mut self, _index: u32) {
    // Already removed from pending.
}

If the consumer nacks the message, it goes back into pending:

0
self.pending.insert(index);

That gives us a simple lifecycle:

0
1
2
3
publish -> insert into pending
pull    -> remove from pending
ack     -> leave removed
nack    -> insert back into pending

Full runnable example

Cargo.toml:

0
1
2
3
4
5
6
[package]
name = "roaring-message-broker-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
roaring = "0.11"

src/main.rs:

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
use roaring::RoaringBitmap;

#[derive(Debug, Clone)]
struct Message {
    payload: String,
}

#[derive(Debug, Clone)]
struct PulledMessage {
    index: u32,
    payload: String,
}

#[derive(Default)]
struct Topic {
    messages: Vec<Message>,
}

#[derive(Default)]
struct ConsumerState {
    pending: RoaringBitmap,
}

impl Topic {
    fn publish(&mut self, consumer: &mut ConsumerState, payload: String) {
        let index = self.messages.len() as u32;

        self.messages.push(Message { payload });
        consumer.pending.insert(index);
    }
}

impl ConsumerState {
    fn pull_next_fifo(&mut self, topic: &Topic) -> Option<PulledMessage> {
        let index = self.pending.min()?;

        self.pending.remove(index);

        let message = topic.messages.get(index as usize)?;

        Some(PulledMessage {
            index,
            payload: message.payload.clone(),
        })
    }

    fn ack(&mut self, _index: u32) {
        // Already removed from pending.
    }

    fn nack(&mut self, index: u32) {
        self.pending.insert(index);
    }

    fn pending_count(&self) -> u64 {
        self.pending.len()
    }
}

fn main() {
    let mut topic = Topic::default();
    let mut consumer = ConsumerState::default();

    topic.publish(&mut consumer, "send email".to_string());
    topic.publish(&mut consumer, "resize image".to_string());
    topic.publish(&mut consumer, "generate invoice".to_string());

    let first = consumer.pull_next_fifo(&topic).unwrap();

    println!("Pulled first: {:?}", first);

    consumer.ack(first.index);

    let second = consumer.pull_next_fifo(&topic).unwrap();

    println!("Pulled second: {:?}", second);

    consumer.nack(second.index);

    let redelivered = consumer.pull_next_fifo(&topic).unwrap();

    println!("Redelivered: {:?}", redelivered);

    consumer.ack(redelivered.index);

    println!("Pending messages: {}", consumer.pending_count());
}

Example output:

0
1
2
3
Pulled first: PulledMessage { index: 0, payload: "send email" }
Pulled second: PulledMessage { index: 1, payload: "resize image" }
Redelivered: PulledMessage { index: 1, payload: "resize image" }
Pending messages: 1

Why this is faster than scanning the log

Without a pending set, the consumer might need to scan the whole topic log:

0
1
2
3
4
for index in 0..topic.messages.len() {
    if !acked.contains(index) {
        return Some(index);
    }
}

That becomes expensive because every pull may walk through old messages.

With a pending set, the consumer state already knows what is still available:

0
let index = pending.min()?;

So the broker does not ask:

0
Which message in the entire log has not been consumed yet?

It asks:

0
What is the next item in this consumer's pending set?

That is the key optimization.

The broker avoids repeatedly scanning irrelevant old messages.

Performance comparison

Let:

0
1
n = number of pending indexes
c = number of Roaring containers

A Roaring container groups part of the integer space. For u32 values, the high bits select the container and the low bits select the value inside that container.

StructurePull FIFOAck for pending stateNack / redeliveryMain issue
Vec<u32>O(n)O(1)O(n)Simple, but scans often
HashSet<u32>O(n)O(1)average O(1)Fast lookup, but unordered
RoaringBitmapO(1) for min() + remove costO(1)O(log c + container cost)Best fit for integer index sets

For RoaringBitmap, the operation is not compared against all pending messages.

It works through compressed integer containers.

For this use case, that matters because pulling the next message is usually:

0
1
let index = pending.min()?;
pending.remove(index);

The min() part does not scan all pending indexes.

The remove(index) part finds the relevant container and removes the value inside it.

So a more precise way to think about the pull cost is:

0
1
pull_next_fifo = min() + remove(index)
               = O(1) + O(log c + container cost)

This is usually much better than scanning n pending messages.

Space complexity

Time complexity is only half of the story.

For a broker, memory matters too because every consumer may have its own pending-message state.

Imagine:

0
1
2
10 topics
100 consumers per topic
1,000,000 pending indexes per consumer

That is a lot of per-consumer state.

Space complexity comparison

StructureSpace complexityPractical memory behavior
Vec<u32>O(n)Compact per value, but slow operations
HashSet<u32>O(n)Higher overhead due to hash table storage
RoaringBitmapO(c + compressed data)Often compact for dense integer indexes

A Vec<u32> stores every pending index directly:

0
[0, 1, 2, 3, 4, 5, ...]

That is memory-efficient, but operations like min, remove, and contains require scanning.

A HashSet<u32> also stores every pending index, but with hash table overhead. It improves lookup and removal, but usually uses more memory per value.

A RoaringBitmap stores integer sets in compressed containers.

That is especially useful when indexes are dense or range-like, for example:

0
0, 1, 2, 3, 4, 5, 6, 7, ...

or:

0
1
2
0..100000
120000..150000
200000..210000

Message indexes often have exactly this shape because they are generated sequentially.

That makes Roaring a strong fit for consumer pending state.

Measuring serialized size

The roaring crate exposes serialized_size().

This does not measure exact live heap memory, but it is useful for estimating how compact the bitmap is when stored on disk or sent over the network.

Example:

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use roaring::RoaringBitmap;
use std::collections::HashSet;
use std::mem::size_of;

fn main() {
    let pending_indexes: Vec<u32> = (0..1_000_000).collect();

    let vec_pending = pending_indexes.clone();

    let hashset_pending: HashSet<u32> = pending_indexes
        .iter()
        .copied()
        .collect();

    let roaring_pending: RoaringBitmap = pending_indexes
        .iter()
        .copied()
        .collect();

    let vec_bytes = vec_pending.capacity() * size_of::<u32>();

    println!("Vec approx bytes:       {vec_bytes}");
    println!("HashSet len:            {}", hashset_pending.len());
    println!("HashSet capacity:       {}", hashset_pending.capacity());
    println!("Roaring serialized:     {} bytes", roaring_pending.serialized_size());
}

Example output shape:

0
1
2
3
Vec approx bytes:       4000000
HashSet len:            1000000
HashSet capacity:       ...
Roaring serialized:     ...

The HashSet capacity is not a full memory measurement. It only shows the number of buckets the set can hold before resizing. Actual memory usage is higher because hash tables also need metadata.

The practical takeaway is:

0
1
2
3
4
5
6
7
8
9
10
11
12
Vec:
  low memory
  slow lookup/removal

HashSet:
  fast lookup/removal
  higher memory overhead
  no natural ordering

RoaringBitmap:
  compact for many integer sets
  ordered iteration
  good fit for sequential message indexes

What about in-flight messages?

This article only tracks pending because that is enough to explain the RoaringBitmap use case.

A real broker usually also needs to track pulled-but-not-acked messages.

For example, if a consumer pulls a message and then crashes, the broker needs to know that the message should eventually become available again.

That state is usually called something like:

0
1
2
3
in_flight
locked
leased
processing

But it does not have to be another RoaringBitmap.

In a real broker, it may be better represented as:

0
HashMap<u32, LockDeadline>

because in-flight messages usually need timeout metadata.

So the simplified model is:

0
1
pending:
  what still needs to be delivered

The real broker model may be:

0
1
2
3
4
pending:
  what is available to deliver

in_flight:
  what has been delivered but not acked yet

The RoaringBitmap optimization is mainly about pending.

When not to use RoaringBitmap

RoaringBitmap is not always the best choice.

For very small consumers with only a few pending messages, a Vec<u32> may be simpler and good enough.

If you only need membership checks and do not care about ordering, a HashSet<u32> may also be fine.

Roaring becomes more interesting when:

0
1
2
3
4
5
- you have many integer indexes
- indexes are often sequential or range-like
- you need ordered iteration
- you need the smallest pending index
- you want compact serialized state
- each consumer has its own pending set

That is why it fits this message broker use case well.

Final takeaway

The bitmap should not track what is inside the message.

It should track the consumer's pending work.

A useful mental model is:

0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Topic log:
  owns messages

Consumer state:
  pending bitmap = messages this consumer still needs

Publish:
  append message to topic log
  insert message index into pending

Pull:
  get the smallest pending index
  remove it from pending

Ack:
  keep it removed

Nack:
  insert it back into pending

This turns:

0
Scan the whole log to find work.

into:

0
Ask the consumer's pending set for the next index.

That is the core optimization.

RoaringBitmap works well here because message indexes are integer IDs, usually sequential, and naturally represented as a compact set.