nitro_http 0.0.3 copy "nitro_http: ^0.0.3" to clipboard
nitro_http: ^0.0.3 copied to clipboard

A fast HTTP client for Flutter backed by a native libcurl C++ engine over Nitro FFI, with HTTP/1.1, HTTP/2, HTTP/3, streaming and WebSockets.

nitro_http

A fast HTTP client for Flutter. One C++ engine, five platforms, and the same behaviour on all of them.

Built with Nitro for Flutter — the FFI bridge that makes the C++ call cheap enough to be worth making.

pub version pub points ci license: MIT

import 'package:nitro_http/nitro_http.dart';

final res = await fetch('https://api.example.com/users/42');

print(res.bodyToJson());   // parsed JSON, decoded with the response's charset
print(res.version.label);  // HTTP/3 — negotiated for you, not configured

Every request goes to a libcurl engine written in C++, called straight over FFI — no platform channel, and no second implementation to disagree with the first.

Protocols HTTP/1.1 · HTTP/2 · HTTP/3 (QUIC) · WebSockets
Transfers streaming both ways · upload & download progress · cancellation · per-request timings
Security TLS 1.2/1.3 · SPKI pinning, per client or per request · mTLS · custom roots · DNS-over-HTTPS
Caching RFC 9111 subset — Cache-Control, ETag, Last-Modified, 304 revalidation · prefetch
Plumbing interceptors · retry with backoff · cookie jar · HTTP and SOCKS5 proxies · connection pool
Platforms iOS · Android · macOS · Windows · Linux

One engine means proxies, pinning, redirects, timeouts and cookies behave identically on all five. There is no "works differently on Android".

Why you might want it #

  • Fast in the shape a real screen loads. Small and large requests interleaved — fastest in 9 runs out of 9 on every platform measured, and 1.75x the requests per second of the next-best client on Android. See the charts.
  • Features dart:io cannot reach. HTTP/3, TLS pinning, mTLS, DNS-over-HTTPS, a disk cache and transfer timings. package:http, dio and retrofit all sit on dart:io's socket layer, which has no knobs for these — this package owns its transport, so that is where they live.
  • One engine, one behaviour. Nothing to rediscover per platform once you are in production.
  • Drop-in adapters. Already on package:http or dio? Change one line and keep every call site.
  • Honest about limits. No web, HTTP/1.1-only WebSockets, ~1.5–3 MB per ABI. All of it in Limitations, not buried.

How fast is it #

Release builds on real hardware, against dart:io, package:http, dio and rhttp — all five hitting the same in-process server in the same run.

Android on a OnePlus 11 (Snapdragon 8 Gen 2, 16 GB, Android 16): nitro_http is fastest in all five scenarios — small GET 0.5 ms, 64 concurrent GETs 19 ms, 32 MiB download 209 ms, 8 MiB upload 227 ms, mixed workload 2.87 ms — winning 9 of 9 runs on concurrency, download and mixed.

iOS on an iPhone 12 (A14 Bionic, 4 GB, iOS 26.6): nitro_http wins 64 concurrent GETs at 4.22 ms and the mixed workload at 1.17 ms in 9 of 9 runs, ties rhttp on a 32 MiB download at 135 ms, and trails package:http by 2.7 % on an 8 MiB upload.

macOS on an Apple M1 Pro (16 GB, macOS 26.4): nitro_http wins the mixed workload in 9 of 9 runs at 0.81 ms, ties rhttp on concurrency, and trails dart:io by 0.01 ms on a single small GET.

Each panel's "wins N/9" note is what separates a result from a coin flip — bar length cannot show it. Where it loses, why, and how to reproduce any of this: the full benchmark record.

Install #

flutter pub add nitro_http

Requires Dart ^3.12.2 and Flutter >=3.3.0. No extra setup: the native engine is downloaded as a checksum-pinned prebuilt on first build, for every platform below.

Platform support

Platform Status
Android arm64-v8a, armeabi-v7a, x86_64 (minSdk 24)
iOS 13.0+, device and simulator
macOS 10.15+
Windows x64
Linux x64, arm64
Web Not supported, and never will be — see below

There is no web build because the whole client is native code. Use package:http's BrowserClient behind kIsWeb.

macOS only: an app in the App Sandbox needs one entitlement, or every request fails with "connection refused":

<key>com.apple.security.network.client</key>
<true/>

Add it to Runner/DebugProfile.entitlements and Runner/Release.entitlements.

Quick start #

import 'package:nitro_http/nitro_http.dart';

final client = NitroHttpClient(
  settings: const ClientSettings(
    baseUrl: 'https://api.example.com',
    userAgent: 'my_app/1.0',
  ),
);

// GET some JSON
final user = await client.get('/users/42');
print((user.bodyToJson() as Map)['name']);

// POST some JSON
final created = await client.post('/users', body: HttpBody.json({'name': 'Ada'}));
print(created.statusCode); // 201

client.dispose(); // closes pooled connections

One client per API is the intended shape: it owns the connection pool, the cookie jar and the cache, so reusing it is what makes the second request fast.

Features #

Grouped by what you are trying to do. Every snippet below that calls this library is compiled and run by test/readme_examples_test.dart, so an API change breaks that test before it can break a reader.


The default client #

fetch() and every NitroHttp.* verb share one lazily created client. Configure it once at startup and they all pick it up:

NitroHttp.init(
  const ClientSettings(
    baseUrl: 'https://api.example.com',
    userAgent: 'my_app/1.0',
  ),
  interceptors: [RetryInterceptor(maxRetries: 3)],
);

final user = await NitroHttp.get('/users/42');    // baseUrl applies
final posted = await NitroHttp.post('/users', body: HttpBody.json({'name': 'Ada'}));

Calling init again replaces the client and disposes the previous one, which cancels anything still in flight on it. Call it during startup, not per screen.

Large bodies have a one-liner too, so you do not need a client just to stream a download:

final report = await NitroHttp.getStream('/reports/2026.csv');
await for (final chunk in report.body) {
  // chunk is a List<int>, delivered as it arrives
}

NitroHttp also carries the process-wide pieces — configureCache, prefetch, cacheStats, and the capability getters in Engine capabilities.

One caveat worth stating: the default client is global. For anything beyond a single API, construct a NitroHttpClient per API instead — see Quick start — because a client owns the connection pool, the cookie jar and the cache, and sharing one across unrelated hosts shares all three.

Making requests #

Every verb has a method, and options_ is spelled with a trailing underscore because options is a Dart keyword in that position:

await client.get('/users');
await client.head('/users');
await client.post('/users', body: HttpBody.json({'name': 'Ada'}));
await client.put('/users/1', body: HttpBody.json({'name': 'Ada'}));
await client.patch('/users/1', body: HttpBody.json({'name': 'Grace'}));
await client.delete('/users/1');
await client.options_('/users');
await client.trace('/users');

Anything else — including verbs nobody has standardised — goes through request:

await client.requestText(HttpMethod.custom, '/graph', customMethod: 'PURGE');

Query parameters and headers are per call. HttpHeaders is case-insensitive, and a header you set replaces the client default of the same name rather than appending to it:

final res = await client.get(
  '/search',
  query: {'q': 'flutter', 'limit': '20'},
  headers: HttpHeaders.fromMap({'Authorization': 'Bearer $token'}),
);

Sending a body #

Seven shapes, and the last three never load the payload into the Dart heap:

// UTF-8 text.
HttpBody.text('hello', contentType: 'text/plain; charset=utf-8');

// jsonEncode-ed, with application/json; charset=utf-8.
HttpBody.json({'name': 'Ada', 'roles': ['admin']});

// Raw bytes.
HttpBody.bytes(Uint8List.fromList([0xDE, 0xAD, 0xBE, 0xEF]));

// application/x-www-form-urlencoded.
HttpBody.form({'grant_type': 'refresh_token', 'token': token});

// multipart/form-data; file parts stream off disk, never through the Dart heap.
HttpBody.multipart([
  MultipartItem.text('caption', 'Sunset'),
  MultipartItem.file('photo', '/tmp/sunset.jpg', contentType: 'image/jpeg'),
]);

// A Dart stream, chunked when the length is unknown.
HttpBody.stream(source, contentLength: 4096);

// A path handed straight to the engine.
HttpBody.file('/tmp/backup.zip');

Reading a response #

res.statusCode;              // 200
res.body;                    // String, decoded using the response charset
res.bodyBytes;               // Uint8List
res.bodyToJson();            // parsed JSON
res.headers['etag'];         // case-insensitive lookup
res.isSuccess;               // 2xx

body is decoded lazily and cached, so reading only statusCode never pays to decode a megabyte of text. bodyToJson() is jsonDecode with one difference that matters: malformed JSON throws NitroHttpDecodingException, not a bare FormatException, so a single on NitroHttpException clause still catches everything this package can fail with.

Responses are a sealed family, so a switch over them is checked for completeness — add a response kind and the compiler finds every place that needs updating:

String describe(HttpResponse res) => switch (res) {
  HttpTextResponse(:final body) => 'text: ${body.length} chars',
  HttpBytesResponse(:final bodyBytes) => 'bytes: ${bodyBytes.length}',
  HttpStreamResponse(:final contentLength) => 'stream: ${contentLength ?? -1}',
};

There is more on the response than the body. fromCache and revalidated tell you whether the network was touched at all:

final res = await client.get('/users');
print(res.version.label);        // HTTP/2
print(res.reasonPhrase);         // OK — '' over HTTP/2 and HTTP/3, which dropped it
print(res.finalUrl);
print(res.redirectCount);
print(res.primaryIp);
print(res.fromCache);
print(res.headers.getAll('set-cookie'));

Errors #

Every failure is a NitroHttpException subtype, and the family is sealed — so you can switch exhaustively instead of matching on message strings:

try {
  await client.get('/users');
} on NitroHttpException catch (e) {
  final detail = switch (e) {
    NitroHttpTimeoutException(:final stage) => 'timeout at ${stage.name}',
    NitroHttpCancelException(:final reason) => 'cancelled: ${reason ?? ''}',
    NitroHttpStatusCodeException(:final statusCode) => 'status $statusCode',
    NitroHttpCertificateException(:final isPinMismatch) =>
      isPinMismatch ? 'pin mismatch' : 'bad certificate',
    NitroHttpConnectionException(:final failure) => 'connection ${failure.name}',
    NitroHttpRedirectException(:final redirectCount) => '$redirectCount hops',
    NitroHttpProtocolException() => 'protocol error',
    NitroHttpDecodingException() => 'undecodable body',
    NitroHttpCacheMissException() => 'nothing cached',
    NitroHttpDisposedException() => 'client disposed',
    NitroHttpUnknownException(:final engineErrorCode) => 'CURLcode $engineErrorCode',
  };
  print(detail);
}

A 4xx or 5xx is returned, not thrown, so check res.statusCode. Set throwOnStatusCode: true in ClientSettings if you would rather have it thrown.

Streaming #

Downloads stream with real backpressure: the engine stops reading the socket when you stop consuming, so a slow consumer slows the network instead of filling memory.

final res = await client.requestStream(HttpMethod.get, '/dataset.ndjson');

await for (final chunk in res.body) {
  sink.add(chunk);              // a slow consumer stalls the socket, not the heap
}

Uploads stream the same way, from any Dart stream:

await client.post(
  '/ingest',
  body: HttpBody.stream(source, contentLength: totalBytes),
);

And a file goes straight from disk to the socket, with the engine doing the reading — a 2 GB upload costs a few KB of Dart memory:

await client.put('/backups/nightly.zip', body: HttpBody.file('/var/tmp/nightly.zip'));

Progress and timings #

Both directions, on any request:

await client.post(
  '/upload',
  body: HttpBody.file('/tmp/video.mp4'),
  onSendProgress: (sent, total) => print('$sent / ${total ?? -1}'),
  onReceiveProgress: (received, total) => print('down $received'),
);

Phase timings are on by default and cost nothing measurable. No other Dart client reports these, because they come from inside the engine:

final res = await client.get('/ping');
print(res.timings.dns);
print(res.timings.tls);
print(res.timings.firstByte);
print(res.timings.total);

Cancelling #

Make a CancelToken, pass it to the requests it should control, and cancel it. The reason you give comes back on the exception:

final token = CancelToken();
Timer(const Duration(seconds: 2), () => token.cancel('user navigated away'));

try {
  await client.get('/slow', cancelToken: token);
} on NitroHttpCancelException catch (e) {
  print(e.reason);              // user navigated away
}

One token, any number of requests. Give the same token to everything a screen loads and one cancel() stops all of it — useful in dispose():

final screen = CancelToken();

final results = await Future.wait([
  client.get('/profile', cancelToken: screen),
  client.get('/feed', cancelToken: screen),
  client.get('/notifications', cancelToken: screen),
]);
@override
void dispose() {
  screen.cancel('screen closed');
  super.dispose();
}

Cancelling early keeps the request off the network entirely. The token lives in the engine, not in Dart, so a request bound to a token that is already cancelled is refused before a socket is opened — it never reaches your server:

final token = CancelToken()..cancel('never mind');

try {
  await client.get('/expensive', cancelToken: token);
} on NitroHttpCancelException {
  // Fails straight away: no socket was opened and the server saw nothing.
}

Cancelling is safe to do at any point: twice, after the request already finished, or on a token nothing is using. The first cancel() wins and the rest are no-ops.

Timeouts #

Three separate deadlines, because "it timed out" is three different problems:

const ClientSettings(
  connectTimeout: Duration(seconds: 10),   // DNS + TCP + TLS
  timeout: Duration(seconds: 30),          // the whole request
  idleTimeout: Duration(seconds: 90),      // aborts a transfer that goes quiet
);

Retries #

final client = NitroHttpClient(
  interceptors: [
    RetryInterceptor(
      maxRetries: 4,
      baseDelay: const Duration(milliseconds: 250),
      maxDelay: const Duration(seconds: 10),
      respectRetryAfter: true,
    ),
  ],
);

It retries only what is safe to retry — connection failures, timeouts, 429 and 5xx — with exponential backoff and jitter, and it honours Retry-After.

Interceptors #

class AuthInterceptor extends Interceptor {
  AuthInterceptor(this.tokens);

  final TokenStore tokens;

  @override
  Future<InterceptorResult<HttpRequest>> beforeRequest(HttpRequest request) async {
    request.headers.set('authorization', 'Bearer ${await tokens.access()}');
    return Interceptor.next();
  }

  @override
  Future<InterceptorResult<HttpResponse>> onError(NitroHttpException exception) async {
    if (exception is NitroHttpStatusCodeException && exception.statusCode == 401) {
      await tokens.refresh();
    }
    return Interceptor.next();
  }
}

They run in order on the way out and in reverse on the way back. One can short-circuit a request and answer it itself, which is how you fake a response in a test:

final logger = DelegatingInterceptor(
  onResponse: (res) async {
    print('${res.statusCode} ${res.finalUrl} in ${res.timings.total}');
    return Interceptor.next();
  },
);

Cookies #

On by default, one jar per client, and persistable as a Netscape file:

final client = NitroHttpClient(
  settings: ClientSettings(
    cookieSettings: CookieSettings(
      storeCookies: true,
      persistPath: '$appSupportDir/cookies.txt',   // Netscape jar
    ),
  ),
);

// After some traffic:
for (final c in client.cookiesFor(Uri.parse('https://api.example.com/'))) {
  print('${c.name}=${c.value} (${c.domain}${c.path})');
}

client.setCookie(const Cookie(
  name: 'consent',
  value: 'granted',
  domain: 'api.example.com',
));
client.flushCookies();      // also happens automatically on dispose()

Caching and prefetch #

An RFC 9111 subset: Cache-Control, ETag, Last-Modified, and 304 revalidation that refreshes metadata without re-downloading the body.

NitroHttp.configureCache(HttpCacheConfig(
  directory: cacheDir,            // e.g. path_provider's getApplicationCacheDirectory()
  maxSizeBytes: 128 * 1024 * 1024,
  maxEntryBytes: 8 * 1024 * 1024,
));

final client = NitroHttpClient(
  settings: const ClientSettings(cacheSettings: CacheSettings(enabled: true)),
);

Per request you can override the policy — refresh to force revalidation, onlyIfCached for an offline screen:

await NitroHttp.prefetchOnAppStart([
  'https://api.example.com/v1/feed',
  'https://api.example.com/v1/me',
]);

Warm it before the user asks for anything:

await client.get('/feed', options: const RequestOptions(cacheMode: CacheMode.onlyIfCached));
await client.get('/feed', options: const RequestOptions(cacheMode: CacheMode.refresh));

final stats = NitroHttp.cacheStats();
print('${stats.entryCount} entries, hit rate ${stats.hitRate}');
NitroHttp.clearCache();

TLS and certificates #

Versions, root sources, SPKI pinning and mutual TLS, all per client:

final client = NitroHttpClient(
  settings: ClientSettings(
    tlsSettings: TlsSettings(
      minVersion: TlsVersion.tls13,
      rootCaSource: RootCaSource.platform,
      pinnedSpkiSha256: const ['YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg='],
      clientCertificate: ClientCertificate(
        certificatePem: certPem,
        privateKeyPem: keyPem,
      ),
    ),
  ),
);

Pinning is per request too, which is what you want for one sensitive endpoint in an otherwise ordinary app:

await client.post(
  '/payments',
  options: const RequestOptions(pinnedSpkiSha256: 'YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg='),
);

Proxies and DNS #

const ClientSettings(proxySettings: ProxySettings.system());     // default
const ClientSettings(proxySettings: ProxySettings.noProxy());

ClientSettings(
  proxySettings: const ProxySettings.http(
    'proxy.corp.example:3128',
    username: 'svc',
    password: 'hunter2',
    noProxy: 'localhost,127.0.0.1,*.internal',
  ),
);

const ProxySettings.socks5('127.0.0.1:1080');           // resolve locally
const ProxySettings.socks5Hostname('127.0.0.1:1080');   // let the proxy resolve

Static DNS overrides and DNS-over-HTTPS, without touching the device's resolver:

ClientSettings(
  dnsSettings: DnsSettings.static({
    'api.example.com': ['203.0.113.10', '2001:db8::10'],
  }, port: 443),
);

const ClientSettings(
  dnsSettings: DnsSettings.doh('https://cloudflare-dns.com/dns-query'),
);

HTTP versions and the connection pool #

const ClientSettings(
  // auto, http11Only, http2, http2Only, http3, http3Only
  httpVersionPref: HttpVersionPref.http2,
  poolSettings: PoolSettings(
    maxConnections: 64,        // across all hosts
    maxConnectionsPerHost: 6,
    idleTimeout: Duration(seconds: 90),
    maxLifetime: Duration(minutes: 10),
  ),
);

The *Only variants fail the request rather than silently downgrading, which is what you want when a downgrade would be worse than an error. Check NitroHttp.supportsHttp3 first — a system libcurl usually has no QUIC backend.

WebSockets #

final ws = await NitroWebSocket.connect(
  Uri.parse('wss://echo.example.com/socket'),
  protocols: ['chat'],
  pingInterval: const Duration(seconds: 30),
);

ws.events.listen((event) {
  switch (event) {
    case TextDataReceived(:final text):
      print('text $text');
    case BinaryDataReceived(:final data):
      print('binary ${data.length}');
    case CloseReceived(:final code, :final reason):
      print('closed $code $reason');
  }
});

ws.sendText('hello');
await ws.close(1000, 'done');

NitroWebSocket implements package:web_socket's WebSocket interface, so it drops into code written against that.

Using it with package:http or dio #

final client = NitroHttpCompatClient();
final res = await client.get(Uri.parse('https://example.com/'));
print(res.statusCode);
client.close();
final dio = Dio()..useNitroHttp();

// or, with settings or a shared client:
final dio = Dio()
  ..httpClientAdapter = NitroHttpDioAdapter(
    settings: const ClientSettings(timeout: Duration(seconds: 30)),
  );

The package:http adapter is checked against the official package:http_client_conformance_tests suite. The dio adapter is the separate nitro_http_dio package.

Engine capabilities and hot restart #

Ask the engine what it can actually do, rather than assuming:

print(NitroHttp.engineVersion);      // libcurl/8.21.0 ... nghttp2/1.70.0 ...
print(NitroHttp.supportsHttp3);
print(NitroHttp.supportsWebSockets);
print(NitroHttp.supportsBrotli);
print(NitroHttp.supportsZstd);

Hot restart leaves the native engine threads running while the Dart isolate is replaced. You do not have to do anything about it: the first time the reloaded app touches the engine, it joins those threads, aborts the stragglers, flushes the cookie jars and clears cancellation state.

void main() {
  runApp(const MyApp());       // nothing to add
}

Configuration #

The settings you are most likely to touch:

Setting Default What it does
baseUrl none Prefix for relative paths
connectTimeout 10 s DNS + TCP + TLS only
timeout 30 s The whole request
idleTimeout 90 s Aborts a transfer that goes quiet
httpVersionPref auto Negotiate, prefer, or require a version
headers none Default headers a request can override
userAgent package default User-Agent
throwOnStatusCode false Throw on 4xx/5xx instead of returning
enableCompression true Advertise and decode gzip/deflate/br/zstd
redirectSettings follow, max 5 Whether and how far to follow 3xx
poolSettings 64 total, 6 per host Pool size and connection lifetimes
tlsSettings system Versions, pinning, roots, mTLS
proxySettings system HTTP or SOCKS5 proxy
dnsSettings system Static overrides or DNS-over-HTTPS
cookieSettings on Jar behaviour and persistence
cacheSettings off Disk cache, after NitroHttp.configureCache
streamChunks tuned How streamed chunks are batched

Interceptors are not a setting — they are a NitroHttpClient argument, because they are behaviour rather than configuration:

final client = NitroHttpClient(
  settings: const ClientSettings(baseUrl: 'https://api.example.com'),
  interceptors: [RetryInterceptor(maxRetries: 3)],
);

TLS, proxies and DNS-over-HTTPS have more to them than fits here — see doc/ADVANCED.md.

Limitations #

  • No web support, permanently. Use BrowserClient behind kIsWeb.
  • HTTP/3 depends on the build you link. A system libcurl usually has no QUIC backend. Check NitroHttp.supportsHttp3 at runtime.
  • WebSockets are HTTP/1.1 Upgrade only. No RFC 8441 over HTTP/2 — libcurl does not implement it, and neither does reqwest.
  • Cookies have no public-suffix list, so do not treat the jar as a security boundary against a hostile server.
  • Binary size is roughly 1.5–3 MB per ABI with the bundled native stack.
  • It is a 0.0.x release. The API is complete and tested, but it has not yet been through a wide range of real apps, so treat breaking changes in a minor version as possible until 1.0.

Try it #

The example app is a full HTTP console — pick a library, build any request, and compare all five clients on the same benchmark:

cd example
flutter run

Docs #

  • doc/ADVANCED.md — TLS, proxies, DNS, HTTP versions, the native build, testing, and the complete benchmark record
  • doc/ARCHITECTURE.md — how the engine works inside: the ack protocol, the credit loop, the threading contract. Read this before changing native code.
  • CHANGELOG.md

Contributing #

Contributions welcome. The Nitro spec in lib/src/nitro_http.native.dart is generated code's source of truth — change it and re-run nitrogen generate, never edit the generated files. Run dart analyze and the test suites before opening a pull request.

Bugs and feature requests: issue tracker.

License #

MIT © Shreeman Arjun Sahu.

The published builds statically link libcurl, BoringSSL, nghttp2, nghttp3, ngtcp2, brotli and zstd. Those carry their own permissive licences (curl, ISC, MIT, Apache-2.0, BSD) and shipping an app built with this package means distributing them — worth a line in your app's acknowledgements.

18
likes
140
points
188
downloads

Documentation

API reference

Publisher

verified publishershreeman.dev

Weekly Downloads

A fast HTTP client for Flutter backed by a native libcurl C++ engine over Nitro FFI, with HTTP/1.1, HTTP/2, HTTP/3, streaming and WebSockets.

Repository (GitHub)
View/report issues

Topics

#http #network #ffi #nitro #websocket

License

MIT (license)

Dependencies

flutter, http, nitro, web_socket

More

Packages that depend on nitro_http

Packages that implement nitro_http