Migration Guide
This guide covers what you need to change when upgrading the Flutter SDK from v3.x.x to v4.0.1. Most of the work is in PubSub — everything else is two corrections to the error catalog. (v4.0.0 was retracted; upgrade straight to v4.0.1.)
Nothing stops compiling: publish(), subscribe() and unsubscribe() keep the signatures they had in v3.x.x, and the new options and callbacks are optional named parameters. The behaviour changes, though, and section 1 needs a catch at every call site.
The batching, streamed history and options below take effect on rooms served by VideoSDK's new PubSub service. The SDK selects it automatically and the API is identical either way; on a room that falls back to the previous service, subscribe() options are ignored, onBatchReceived and onMessageDrop never fire, and all history arrives in a single onOldMessagesReceived call with isLast set to true. Failures throw and reach Events.error on both.
1. PubSub methods now throw
publish(), subscribe() and unsubscribe() throw a PubSubException when the call does not take effect. In v3.x.x they failed silently — the future completed and a rejected publish() looked exactly like a successful one.
Before
// A rejected publish looked exactly like a successful one.
room.pubSub.publish("CHAT", message, const PubSubPublishOptions(persist: true));
After
try {
await room.pubSub.publish("CHAT", message, const PubSubPublishOptions(persist: true));
msgTextController.clear();
} catch (e) {
// The message did not reach the room — leave the text in place to retry.
showSnackBar("Not sent: $e");
}
dispose() cannot await, so unsubscribe with .catchError there:
@override
void dispose() {
room.pubSub
.unsubscribe("CHAT", messageHandler)
.catchError((Object e) => log("Unsubscribe failed: $e"));
super.dispose();
}
Key points:
- Failures now surface at the call site as well as through
Events.error. The event still carries the same failure, so use it for centralized logging andcatchfor handling one call. - Dart cannot check this at compile time. An unawaited
onPressed: () => room.pubSub.publish(...)turns a rejection into an unhandled async error, so audit everypubSub.call site before shipping. unsubscribe()detaches your handler before anything that can throw, so adispose()teardown always completes.- A failed
subscribe()leaves nothing behind: the SDK takes your handler back out, so you will not receive messages for a topic whose subscribe was rejected. subscribe()returnsFuture<PubSubMessages>, so a.catchErroron it must return a value —PubSubMessages(messages: const [])will do.publish()andunsubscribe()resolve only once the server has answered, bounded by a 30s timeout, so a caught exception always refers to the call you awaited.
2. Typed PubSub exceptions
PubSubException is sealed, so you can switch over the exact subtype instead of matching on strings, and the analyzer tells you when a case is missing. Each subtype carries code, name and message, where message is the reason the server gave.
After
try {
await room.pubSub.publish("CHAT", message);
} on PubSubException catch (e) {
switch (e) {
case PubSubMeetingNotJoined():
showToast("Join the meeting before publishing");
case PubSubMeetingReconnecting():
showToast("Reconnecting — try again shortly");
case PubSubPublishFailed():
markUnsent(id, e.message);
default:
log(e.toString());
}
}
| Exception | Name | Code |
|---|---|---|
PubSubPublishFailed | PUBSUB_PUBLISH_FAILED | 4087 |
PubSubSubscribeFailed | PUBSUB_SUBSCRIBE_FAILED | 4088 |
PubSubUnsubscribeFailed | PUBSUB_UNSUBSCRIBE_FAILED | 4089 |
PubSubMeetingNotJoined | ERROR_ACTION_PERFORMED_BEFORE_MEETING_JOINED | 3022 |
PubSubMeetingReconnecting | ERROR_MEETING_RECONNECTING | 3027 |
Key points:
- A call that times out throws its method's exception with the reason in
message, rather than a separate type. nameis the same constant iOS returns fromVideoSDKError.type, which is useful when correlating logs across platforms.- The last two are meeting-state failures that already reached
Events.errorin v3.12.0; in v4.0.1 they throw as well. - Not every failure can throw — a subscription the server drops later has no call left to throw from and arrives as
PUBSUB_SUBSCRIBE_FAILEDonEvents.erroronly. - If you already log every
Events.error, expect new entries on rooms where these calls were quietly failing before. - Learn more: Error Events
3. History arrives through onOldMessagesReceived
In v3.x.x, subscribe() returned a PubSubMessages holding every persisted message for the topic, in one piece. History is now streamed in batches, oldest first, through the new onOldMessagesReceived callback, so a long conversation starts rendering immediately instead of waiting for the whole backlog.
Before
room.pubSub
.subscribe("CHAT", messageHandler)
.then((value) => setState(() => messages = value));
After
room.pubSub.subscribe(
"CHAT",
messageHandler,
onOldMessagesReceived: (batch, isLast) {
setState(() => messages.addAll(batch));
if (isLast) {
// Every persisted message for this topic has now been delivered.
scrollToBottom();
}
},
);
Key points:
- History arrives in multiple batches. Accumulate them and use
isLastto know when the final one has landed. - The return value's meaning depends on
options: with none,subscribe()still resolves with the complete history as in v3.x.x; as soon as you passoptionsit resolves on the first batch, so treatonOldMessagesReceivedas the source of truth. - Use
oldMessageLimit(see section 5) to cap how much history you fetch, or pass0to skip it entirely.
4. New subscribe() callbacks
Alongside onOldMessagesReceived, subscribe() accepts two more optional callbacks. All three are named parameters with no default behaviour, so a v3.x.x call that passes only a message handler is unchanged.
room.pubSub.subscribe(
"CHAT",
// Existing — fires once per live message.
(PubSubMessage message) { },
// New — persisted messages, streamed oldest first.
onOldMessagesReceived: (List<PubSubMessage> messages, bool isLast) { },
// New — the same live messages, grouped into a batch.
onBatchReceived: (List<PubSubMessage> messages) { },
// New — how many live messages could not be delivered to you.
onMessageDrop: (int droppedCount) { },
);
Key points:
- On a busy topic, prefer
onBatchReceivedover the per-message handler — onesetState()per batch instead of one per message. They deliver the same messages, so use one or the other, not both. onMessageDropreports a count, not the messages themselves. It fires when a subscriber fell too far behind, when anewMessageLimitcap skipped messages, and after a reconnect for anything that arrived while you were offline.- Under
PubSubRealtimeOverflow.dropthe SDK does not track sequence numbers, soonMessageDropnever fires and the loss is uncounted.
5. New subscribe() options
subscribe() accepts an optional options parameter of type PubSubSubscribeOptions, which controls how much history you fetch and what happens when messages arrive faster than your app can drain them.
room.pubSub.subscribe(
"CHAT",
messageHandler,
options: const PubSubSubscribeOptions(
oldMessageLimit: 100,
realtimeOverflow: PubSubRealtimeOverflow.queue,
maxQueue: 70,
),
onOldMessagesReceived: handleOldMessages,
);
Key points:
oldMessageLimit—null(default) replays all history,0replays none,Nreplays the last N. Only messages published withpersist: trueare stored at all.realtimeOverflow—queue(default) briefly holds messages for a subscriber that falls behind so it can catch up;dropdiscards them. Usedroponly where stale data is worthless, such as typing indicators.maxQueue— how far a subscriber may fall behind, counted in delivery batches. Only meaningful withqueue, and fixed by whichever subscriber reaches the topic first.newMessageLimit— caps how many live messages you accept per server batching window.nullor0(default) means unlimited.
Options are checked before anything is sent. A value that cannot work throws an ArgumentError — the same four rules iOS and Android reject:
| Rule | Example that throws |
|---|---|
oldMessageLimit must be >= 0, or null for all history | oldMessageLimit: -1 |
maxQueue must be >= 1, or null for the server default | maxQueue: 0 |
newMessageLimit must be >= 0, or null for unlimited | newMessageLimit: -5 |
maxQueue cannot be combined with PubSubRealtimeOverflow.drop | maxQueue: 70 with realtimeOverflow: PubSubRealtimeOverflow.drop |
An ArgumentError is a mistake in the call, not a failure of it — so it is not a PubSubException, on PubSubException catch will not catch it, and it never reaches Events.error. Fix the value rather than handling it.
6. Subscriptions survive a reconnect
In v3.x.x a PubSub subscription lived only as long as the connection, so apps that wanted to keep receiving messages after a network drop had to re-subscribe from a reconnect handler. The SDK now restores every active subscription itself.
Before
room.on(Events.roomStateChanged, (RoomState state) {
if (state == RoomState.connected) {
// Re-subscribing by hand, or messages stopped arriving.
room.pubSub.subscribe("CHAT", messageHandler);
}
});
After
room.on(Events.roomStateChanged, (RoomState state) {
if (state == RoomState.connected) {
// Nothing to do — the SDK has already restored the subscription.
}
});
Key points:
- Remove your manual re-subscribe. Every
subscribe()opens its own subscription, so calling it again after the SDK restored yours leaves two in place and the same message reaches your handler twice. - History is not replayed on a reconnect, since your app still holds those messages. Anything that arrived while you were offline is reported through
onMessageDropinstead. - History is replayed after a room switch, since that is a different conversation.
- If restoration fails you get
PUBSUB_SUBSCRIBE_FAILED(4088) onEvents.error, and on the event only — there is no call left to throw from.
7. PubSubMessage.sendOnly
PubSubMessage gained a sendOnly field carrying the participant IDs a message was addressed to, when it was published with PubSubPublishOptions.sendOnly. It is an empty list for a broadcast message.
void messageHandler(PubSubMessage message) {
if (message.sendOnly.isNotEmpty) {
// A private message, addressed to these participant IDs.
log("Private message for ${message.sendOnly}");
}
}
This is additive — the field was previously available only on the publishing side, so a receiver had no way to tell a direct message from a broadcast.
8. Breaking: two corrections to the error catalog
Two long-standing mistakes in the error catalog are fixed. Both are visible to your app, so check these if you match on an error's name or code inside your Events.error handler.
| What changed | Before | Now |
|---|---|---|
Name of the participant-limit error 4009 | MAX_PARTCIPANT_REACHED | MAX_PARTICIPANT_REACHED |
Code carried by the speaker-limit error 4010 | "4009" | "4010" |
Before
room.on(Events.error, (error) {
if (error['name'] == "MAX_PARTCIPANT_REACHED") {
showRoomFullDialog();
}
});
After
room.on(Events.error, (error) {
if (error['name'] == "MAX_PARTICIPANT_REACHED") {
showRoomFullDialog();
}
});
Key points:
- The first is a spelling fix —
PARTCIPANTwas missing its secondI. Any string comparison against the old spelling stops matching. - The second means the speaker-limit error now reports its own code. Code
4009previously arrived for both, so a handler keyed onerror['code'] == "4009"was firing for4010too and will now stop. - Learn more: Meeting Error Codes
Got a Question? Ask us on discord

