nitro_http 0.0.2
nitro_http: ^0.0.2 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.
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.
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:iocannot reach. HTTP/3, TLS pinning, mTLS, DNS-over-HTTPS, a disk cache and transfer timings.package:http,dioandretrofitall sit ondart: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:httpordio? 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.
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
- Making requests
- Sending a body
- Reading a response
- Errors
- Streaming
- Progress and timings
- Cancelling
- Timeouts
- Retries
- Interceptors
- Cookies
- Caching and prefetch
- TLS and certificates
- Proxies and DNS
- HTTP versions and the connection pool
- WebSockets
- Using it with package:http or dio
- Engine capabilities and hot restart
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
BrowserClientbehindkIsWeb. - HTTP/3 depends on the build you link. A system libcurl usually has no QUIC
backend. Check
NitroHttp.supportsHttp3at 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.