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 targets QUIC datagrams, not raw UDP.
- Android and iOS plugin entry points are available.
- Client and server endpoints expose a shared
QuicEndpointAPI. - Incoming payloads are exposed as
Stream<QuicDatagram>. - Connection lifecycle changes are exposed as
Stream<QuicConnectionEvent>. - Outgoing payloads are sent as raw
Uint8Listdatagrams. - Server datagram sends require a QUIC
connectionIdso replies target an established client connection.
The Android arm64-v8a and x86_64 native paths are wired to the
quiche_native backend.
iOS classes and podspec integration are prepared, but iOS native artifacts must
be built and validated on macOS before runtime use. 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.5
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=embeddedLLS_QUIC_CERT_PEM_FILEpointing to a local PEM certificate file.LLS_QUIC_KEY_PEM_FILEpointing 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
libclangavailable throughLIBCLANG_PATHor beside the hostclangexecutable. - Rust toolchain:
rustupandcargo. - Android Rust targets:
aarch64-linux-androidx86_64-linux-android
ANDROID_NDK_HOMEorANDROID_NDK_ROOTpointing 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:
rustupandcargo. - iOS Rust targets:
aarch64-apple-iosaarch64-apple-ios-simx86_64-apple-ios
Prepare the iOS quiche checkout and Rust targets:
./tool/build_quiche_ios.sh
During CocoaPods preparation, the podspec reads LLS_QUIC_CREDENTIAL_MODE. In
embedded mode it reads the PEM files and generates
native/generated/lls_quic_build_credentials.h. In external mode it generates
an empty credential header.
Native artifacts
The expected native artifact layout is defined in
native/quiche/artifacts.json.
Check that native quiche artifacts are present:
.\tool\check_quiche_artifacts.ps1
The quiche preparation scripts intentionally stop before producing final release artifacts until the exact native build commands are pinned and reviewed.
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;
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.
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_IMPLEMENTEDQUIC_INVALID_ARGUMENTQUIC_HANDSHAKE_FAILEDQUIC_CONNECTION_CLOSEDQUIC_DATAGRAM_UNSUPPORTEDQUIC_NATIVE_FAILURE
Package boundary
lib/stays generic and only exposes QUIC transport primitives overUint8List.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 example app:
cd example
flutter run
Limitations
- Android packaging supports
arm64-v8aandx86_64; 32-bit ABIs are not packaged. - iOS native runtime validation must be completed on macOS with Xcode and the expected quiche xcframework.
- The package transports raw binary datagrams only; application-level serialization belongs in the host app.
- Delivery is designed for latency-sensitive datagrams where stale state may be discarded by the application protocol.
embeddedcredential mode compiles the private key into the native binary and should only be used for autonomous/offline cases where that tradeoff is acceptable.