low_latency_sync 0.1.7 copy "low_latency_sync: ^0.1.7" to clipboard
low_latency_sync: ^0.1.7 copied to clipboard

QUIC datagram and reliable stream transport for low-latency realtime multiplayer Flutter apps on Android and iOS.

low_latency_sync #

Flutter QUIC transport for LAN multiplayer and low-latency networking on Android and iOS. It combines realtime datagrams with reliable QUIC byte streams for peer-to-peer transport between Flutter apps.

low_latency_sync exposes small, binary-first transport primitives for Flutter apps that need to exchange latency-sensitive state as Uint8List payloads. The package API is application-agnostic: it does not impose a protocol, serialization format, tick rate, or domain model.

Capabilities #

  • The public Dart API exposes QUIC datagrams and reliable raw byte streams.
  • Android and iOS plugin entry points are available.
  • Client and server endpoints expose a shared QuicEndpoint API.
  • Incoming payloads are exposed as Stream<QuicDatagram>.
  • Connection lifecycle changes are exposed as Stream<QuicConnectionEvent>.
  • Outgoing payloads are sent as raw Uint8List datagrams.
  • Server datagram sends require a QUIC connectionId so replies target an established client connection.

The Android arm64-v8a and x86_64 native paths and the iOS arm64 and simulator paths are wired to the quiche_native backend. iOS has been validated on an iPad running iPadOS 18.7.9 for a QUIC handshake and DATAGRAM exchange on a local network. Unsupported or unavailable native paths can report QUIC_NOT_IMPLEMENTED.

Why QUIC #

QUIC provides encrypted transport, stream multiplexing, connection migration, and avoids TCP head-of-line blocking at the transport layer. QUIC datagrams add unreliable datagram delivery for latency-sensitive data where stale packets should be dropped instead of retransmitted.

Installation #

Add the package to your Flutter app:

dependencies:
  low_latency_sync: ^0.1.7

Then import the public API:

import 'package:low_latency_sync/low_latency_sync.dart';

Building from source #

The Dart and Flutter side builds with the standard Flutter toolchain:

flutter pub get
flutter analyze
flutter test

The native QUIC backend targets Cloudflare quiche. quiche is written in Rust, so native builds require extra tools in addition to Flutter.

Native credential modes #

Native builds support two credential modes:

Mode Use case Private key in app binary
external Apps that connect to a backend-owned QUIC server no
embedded Autonomous/offline apps without backend infrastructure yes

external is the default. In this mode the app can act as a QUIC client and no certificate or private key is compiled into the native library. Calling LowLatencyQuicServer.listen() requires embedded mode because the app must present a server certificate during the QUIC/TLS handshake.

Enable embedded only when the application must run without a backend:

  • LLS_QUIC_CREDENTIAL_MODE=embedded
  • LLS_QUIC_CERT_PEM_FILE pointing to a local PEM certificate file.
  • LLS_QUIC_KEY_PEM_FILE pointing to the matching local PEM private key file.

For local development, keep them outside version control, for example under certs/:

$env:LLS_QUIC_CREDENTIAL_MODE = "embedded"
$env:LLS_QUIC_CERT_PEM_FILE = "$PWD\certs\dev-cert.pem"
$env:LLS_QUIC_KEY_PEM_FILE = "$PWD\certs\dev-key.pem"

In embedded mode the private key is present in the compiled binary and should be treated as extractable. For backend-backed production apps, keep the server private key on the backend and ship the app in external mode.

The example app includes a committed self-signed development certificate under example/certs/ to demonstrate embedded mode:

cd example
$env:LLS_QUIC_CREDENTIAL_MODE = "embedded"
$env:LLS_QUIC_CERT_PEM_FILE = "$PWD\certs\example-dev-cert.pem"
$env:LLS_QUIC_KEY_PEM_FILE = "$PWD\certs\example-dev-key.pem"
flutter run

Those example credentials are public, intentionally non-secret, and only useful to demonstrate the build flow. Do not reuse them in real applications.

Android native requirements #

  • Flutter SDK with Android support.
  • Android SDK and Android NDK.
  • Java 17.
  • CMake.
  • Ninja.
  • Clang with libclang available through LIBCLANG_PATH or beside the host clang executable.
  • Rust toolchain: rustup and cargo.
  • Android Rust targets:
    • aarch64-linux-android
    • x86_64-linux-android
  • ANDROID_NDK_HOME or ANDROID_NDK_ROOT pointing to the installed NDK.

Prepare the Android quiche checkout and Rust targets:

.\tool\build_quiche_android.ps1

The Android Gradle configuration packages arm64-v8a and x86_64.

During the Android native build, CMake reads LLS_QUIC_CREDENTIAL_MODE. In embedded mode it reads the PEM files and generates a build-local C header. In external mode it generates an empty credential header.

iOS native requirements #

iOS native builds require macOS:

  • Flutter SDK with iOS support.
  • Xcode and command line tools.
  • Rust toolchain: rustup and cargo.
  • iOS Rust targets:
    • aarch64-apple-ios
    • aarch64-apple-ios-sim
    • x86_64-apple-ios

Prepare the iOS quiche checkout and Rust targets:

./tool/build_quiche_ios.sh

This produces ios/Frameworks/Quiche.xcframework, consumed by the CocoaPods plugin. The framework is generated locally and is not versioned. Build and compile-check the iOS implementation with:

./tool/check_ios_compile.sh
cd example
flutter build ios --simulator --debug
flutter build ios --debug --no-codesign

For a local-network test on a physical iPhone or iPad, add NSLocalNetworkUsageDescription to the host app's Info.plist and accept the system alert on first use. While that alert is displayed, iOS can temporarily reject the first UDP transmission; the transport keeps the QUIC packet pending and retries it after the user responds.

Native artifacts #

The expected native artifact layout is defined in native/quiche/artifacts.json.

The repository and the pub.flutter-io.cn archive deliberately exclude generated libquiche.so and libquiche.a binaries. Build them locally before compiling an application that uses the native backend:

# Windows / Android
.\tool\build_quiche_android.ps1
# macOS / iOS
./tool/build_quiche_ios.sh

The Android command creates the ABI-specific .so files in native/quiche/android/jniLibs/; the macOS command creates the iOS static libraries and ios/Frameworks/Quiche.xcframework.

Check that native quiche artifacts are present:

.\tool\check_quiche_artifacts.ps1

The quiche build scripts pin their source revision and produce the platform artifacts expected by the plugin configuration.

Usage #

Connect a client #

import 'dart:typed_data';

import 'package:low_latency_sync/low_latency_sync.dart';

final client = LowLatencyQuicClient();

Future<void> startClient() async {
  await client.connect(
    host: '192.168.1.10',
    port: 4433,
    serverName: 'localhost',
    alpn: 'low-latency-sync/1',
  );

  client.datagrams.listen((datagram) {
    final Uint8List bytes = datagram.bytes;
    // Decode your application payload here.
  });

  client.connectionEvents.listen((event) {
    // Observe connection lifecycle diagnostics here.
  });

  await client.sendDatagram(Uint8List.fromList(<int>[1, 2, 3, 4]));
}

Future<void> stopClient() => client.close();

Listen as a server #

import 'dart:typed_data';

import 'package:low_latency_sync/low_latency_sync.dart';

final server = LowLatencyQuicServer();

Future<void> startServer() async {
  await server.listen(
    port: 4433,
    certificateIdentity: 'development',
    alpn: 'low-latency-sync/1',
  );

  server.datagrams.listen((datagram) async {
    final Uint8List bytes = datagram.bytes;
    final String connectionId = datagram.connectionId;

    // Decode your application payload here.

    await server.sendDatagram(
      bytes,
      connectionId: connectionId,
    );
  });
}

Future<void> stopServer() => server.close();

On the server side, connectionId is required when sending a datagram. It selects the established client connection that should receive the payload.

Shared endpoint shape #

Both LowLatencyQuicClient and LowLatencyQuicServer implement QuicEndpoint:

abstract interface class QuicEndpoint {
  Stream<QuicDatagram> get datagrams;

  Stream<QuicConnectionEvent> get connectionEvents;
  Stream<QuicIncomingStream> get incomingStreams;

  Future<QuicStream> openBidirectionalStream({String? connectionId});
  Future<QuicStream> openUnidirectionalStream({String? connectionId});
  Future<void> sendDatagram(Uint8List bytes, {String? connectionId});

  Future<void> close();
}

connectionEvents is diagnostic lifecycle metadata. Protocol correctness must not depend on synchronized wall clocks or event timestamps.

Native endpoint events are delivered through one EventChannel subscription per endpoint and routed in Dart. Existing DATAGRAM consumers continue to use datagrams as before.

Reliable QUIC byte streams are available concurrently with DATAGRAMs:

final stream = await client.openBidirectionalStream();
stream.data.listen(handleBytes);
await stream.send(bytes);
await stream.finish();

Streams are ordered and reliable, but do not preserve application message boundaries. A send() completes when all supplied bytes have been accepted by the local QUIC library, not when the peer has received them. Pending sends are bounded to 1 MiB per stream and fail with QUIC_BACKPRESSURE above that limit.

Reliable stream lifecycle #

Subscribe to incomingStreams before the peer starts sending. Each incoming stream exposes single-subscription data and events streams. Early bytes and terminal events are buffered until their listeners attach. Consume data concurrently with events: FIN and subsequent terminal events wait behind buffered data, including while the data subscription is paused. Cancel the data subscription if you intentionally discard the payload.

server.incomingStreams.listen((incoming) {
  final received = BytesBuilder(copy: false); // application-owned accumulation
  incoming.stream.data.listen(received.add);
  incoming.stream.events.listen((event) {
    if (event is QuicStreamRemoteFinReceived) {
      handleCompletePayload(received.takeBytes());
    } else if (event is QuicStreamResetReceived) {
      handleReset(event.errorCode);
    } else if (event is QuicStreamFailed) {
      handleTransportError(event.error);
    }
  });
});

final stream = await client.openUnidirectionalStream();
await stream.send(payload);
await stream.finish();

A successful finish() means the native library accepted FIN after all queued bytes. It does not acknowledge peer receipt. Repeated finish() calls share the same operation; subsequent send() calls fail. Partial writes retry through internal writable notifications without subscribing to your public events. A failed write prevents FIN from reporting success.

Remote FIN or reset ends data; use the typed event to distinguish successful completion from reset. Closing an endpoint interrupts pending stream opens, sends and FIN operations. Connection termination fails its remaining active streams. Peer process termination is detected by QUIC timeout, not immediately. The native idle timeout is currently 30 seconds.

The native registry holds up to 64 active streams per endpoint and recycles completed entries. QUIC also enforces the peer's negotiated stream limits. Opening beyond a limit reports QUIC_STREAM_LIMIT_REACHED.

Received datagrams expose:

final class QuicDatagram {
  final String connectionId;
  final Uint8List bytes;
  final String remoteHost;
  final int remotePort;
}

Packet header helper #

The package includes a tiny binary header helper for applications that need a fixed packet prefix:

final payload = Uint8List(PacketHeader.size + 4);
final data = ByteData.sublistView(payload);

PacketHeader.write(
  data,
  0x01, // application packet type
  42, // sequence
  DateTime.now().microsecondsSinceEpoch,
);

final type = PacketHeader.type(payload);
final sequence = PacketHeader.sequence(payload);
final timestampMicros = PacketHeader.timestampMicros(payload);

Header layout:

Offset Size Field Endian
0 1 byte packet type n/a
1 4 bytes sequence little-endian
5 4 bytes timestamp in microseconds little-endian

Error handling #

Platform errors are surfaced as QuicException:

try {
  await client.connect(
    host: '192.168.1.10',
    port: 4433,
    serverName: 'localhost',
  );
} on QuicException catch (error) {
  if (error.code == QuicErrorCode.notImplemented) {
    // Native backend is not available yet.
  }
}

Known error codes:

  • QUIC_NOT_IMPLEMENTED
  • QUIC_INVALID_ARGUMENT
  • QUIC_HANDSHAKE_FAILED
  • QUIC_CONNECTION_CLOSED
  • QUIC_DATAGRAM_UNSUPPORTED
  • QUIC_NATIVE_FAILURE

Package boundary #

  • lib/ stays generic and only exposes QUIC transport primitives over Uint8List.
  • example/ owns the particle simulation, multitouch handling, and application-specific binary packet types.
  • native/ contains the package-owned C ABI and backend documentation.
  • native/quiche/ contains the expected quiche headers, libraries, and artifact manifest.

Raw UDP is intentionally not used because it does not provide QUIC-level encryption, connection semantics, congestion behavior, or resilience.

Development #

Run static analysis:

flutter analyze

Run tests:

flutter test

Run the two-device Android stream regression (installs the example test app):

.\tool\test_android_streams.ps1 -ServerDevice <adb-id> -ClientDevice <adb-id> -ServerHost <LAN-IP>
# Also verify abrupt peer process termination:
.\tool\test_android_streams.ps1 -ServerDevice <adb-id> -ClientDevice <adb-id> -ServerHost <LAN-IP> -KillPeer

The script uses only the public demonstration credentials from example/certs. It checks 80 sequential streams in each direction at 1, 2, 8 and 64 KiB, exact byte equality before FIN, reset code propagation, and an active stream's failure when its peer closes or is force-stopped. Logs are saved in build/stream-validation/. This transport regression does not validate any consumer application's move reconciliation or checksums. See the 0.1.7 validation report for executed checks and remaining platform validation.

Run the example app:

cd example
flutter run

Limitations #

  • Android packaging supports arm64-v8a and x86_64; 32-bit ABIs are not packaged.
  • iOS binds the same C ABI as Android for QUIC datagrams and raw streams. The generated ios/Frameworks/Quiche.xcframework is not versioned, so source consumers must generate it with the macOS toolchain. A local-network handshake and DATAGRAM exchange were validated on iPadOS 18.7.9; two-device reliable-stream, background-transition and performance testing remain. See the macOS/iPad handoff for build and device checks.
  • DATAGRAMs remain unreliable; streams carry ordered reliable bytes without application framing. Serialization and stale-state policies belong in the app.
  • The Android event channel currently subscribes to one endpoint at a time per Flutter engine. Use separate app instances for simultaneous client/server tests.
  • Platform channels still allocate and copy payloads. The transport does not promise a zero-allocation hot path; no throughput or latency gain is claimed without a benchmark.
  • embedded credential mode compiles the private key into the native binary and should only be used for autonomous/offline cases where that tradeoff is acceptable.
1
likes
140
points
214
downloads

Documentation

API reference

Publisher

verified publisherkavacode.com

Weekly Downloads

QUIC datagram and reliable stream transport for low-latency realtime multiplayer Flutter apps on Android and iOS.

Repository (GitHub)
View/report issues

Topics

#quic #networking #realtime #multiplayer #low-latency

License

MIT (license)

Dependencies

flutter

More

Packages that depend on low_latency_sync

Packages that implement low_latency_sync