Skip to content

feat(realtime)!: support asynchronous encode/decode codecs - #1684

Draft
spydon wants to merge 2 commits into
mainfrom
realtime-async-codec
Draft

feat(realtime)!: support asynchronous encode/decode codecs#1684
spydon wants to merge 2 commits into
mainfrom
realtime-async-codec

Conversation

@spydon

@spydon spydon commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Closes #1401

What

The realtime codec overrides (RealtimeEncode / RealtimeDecode, added in #1397) were synchronous and worked on raw Map<String, dynamic>s, so jsonEncode/jsonDecode of every message ran on the main isolate. A large payload blocks the event loop, and a background-isolate codec could not be plugged in at all.

Both typedefs now work on a RealtimeMessage and return a Future:

Before After
Object Function(Map<String, dynamic>) Future<Object> Function(RealtimeMessage)
Map<String, dynamic> Function(Object) Future<RealtimeMessage> Function(Object)
final isolate = YAJsonIsolate();

await Supabase.initialize(
  url: url,
  anonKey: anonKey,
  realtimeClientOptions: RealtimeClientOptions(
    encode: (message) => isolate.encode(message.toJson()),
    decode: (frame) async =>
        RealtimeMessage.fromJson(await isolate.decode(frame as String)),
  ),
);

RealtimeClientOptions carries the two callbacks through SupabaseClient and Supabase.initialize, so a supabase_flutter user never has to construct a RealtimeClient by hand.

RealtimeMessage

A new exported class carrying joinRef, ref, topic, event and payload, with toJson([version]) and RealtimeMessage.fromJson(json, [version]) converting to and from the shape each protocol version puts on the wire (positional array for 2.0.0, object for 1.0.0, both defaulting to 2.0.0). A codec only has to turn that shape into bytes and back rather than hand-building the positional array.

It replaces the internal Message class. An @internal RealtimeMessage.outgoing builds one from a ChannelEvent and replaces Bindings in the payload with their serializable shape, which matters more now: a Binding holds a callback and could never be sent to an isolate.

Ordering

Introducing awaits into the message pipeline is the risky part, so both directions are chained:

  • Encoding starts as soon as a message is pushed, but the write to the sink is chained onto the previous pending write, so frames reach the socket in push order even when a later encode finishes first.
  • Incoming frames chain the same way, so messages are dispatched to the channels in receive order.
  • The chain future never completes with an error, so a failed encode or decode drops only its own message instead of stalling the ones behind it.

Fast path

encode and decode are null unless one is passed, and the built-in codec is used while they are. That codec is synchronous, so a client that does not override it writes and dispatches without a microtask hop, and no FutureOr is needed anywhere.

Tests

  • async_codec_test.dart: completion-order scrambling in both directions, an immediate codec queued behind a slow one, failure isolation, buffered flush ordering, and the no-hop fast path for the built-in codec.

  • realtime_message_test.dart: event naming, binding stripping, and toJson/fromJson round trips per protocol version.

  • An integration test runs a real Isolate.run codec against the local Realtime server, delaying each broadcast in reverse order so the encodes and decodes complete backwards. It fails without the chaining and passes with it.

  • serializer_test.dart, socket_test.dart and heartbeat_test.dart ported to the new types.

  • client_test.dart covers the RealtimeClientOptions passthrough reaching the realtime client, and the default staying null.

sdk-compliance.yaml registers the new RealtimeMessage and RealtimeClientOptions symbols; the symbol and drift checks pass locally.

Performance

The message class costs nothing to introduce. Benchmarked against the old map-based path (best of 7 rounds per variant), a RealtimeMessage replaces a LinkedHashMap allocation in each direction and turns hash lookups plus as casts in the dispatch into field reads:

payload encode decode
118 B broadcast -17% (1.06 → 0.87 µs) -32% (0.59 → 0.40 µs)
1.1 KB row -10% (3.82 → 3.45 µs) -10% (2.15 → 1.94 µs)
1 MB blob -0.5% (603 → 600 µs) -0.1% (569 → 568 µs)

AOT numbers track the same way. The 1 MB case is flat because jsonEncode/jsonDecode is essentially all of it, which is the case the async codec exists to move off the event loop.

Breaking

Documented in MIGRATION.md.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 50d1be7a-db80-41a7-921e-78ae7b2f46e0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

spydon added 2 commits August 12, 2026 14:56
The codec overrides were synchronous and worked on raw maps, so the JSON
work always ran on the main isolate and a large payload could block the
event loop.

RealtimeEncode and RealtimeDecode now take and return a RealtimeMessage
and return a Future, so serialization can run on a background isolate.
RealtimeMessage carries joinRef, ref, topic, event and payload and
converts to and from the shape a protocol version puts on the wire, so a
codec only has to turn that shape into bytes and back.

Encoding starts as soon as a message is pushed, but the write to the sink
is chained onto the previous pending write, so frames reach the socket in
push order even when a later encode completes first. Incoming frames
chain the same way, so messages are dispatched in receive order. A failed
encode or decode drops only its own message.

encode and decode are null unless one is passed, and the built-in codec
is used while they are. That codec is synchronous, so a client that does
not override it writes and dispatches without a microtask hop.

Closes #1401
Without this an isolate codec is only reachable by constructing a
RealtimeClient by hand, which a supabase_flutter user never does.
@spydon
spydon force-pushed the realtime-async-codec branch from 9009144 to 3973bbd Compare August 12, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

realtime: support async encode/decode for off-isolate (de)serialization

1 participant