Skip to content

[web] Development server proxy buffers SSE and NDJSON responses #194354

Closed

Description

@onism0106

Steps to reproduce

  1. Create a project with flutter create --platforms=web proxy_stream_repro.
  2. Replace/add the four files below, then run flutter pub get.
  3. In terminal A, run dart backend.dart.
  4. In terminal B, run flutter run -d web-server from the project directory.
  5. Open http://127.0.0.1:51881 in a browser. Both requests run automatically.

The backend writes and flushes a small JSON line immediately, then holds the
response open for 30 seconds. The client waits up to 3 seconds for the first line.
response.bufferOutput = false applies to the backend; Flutter's proxy has its
own downstream response buffering.

Expected results

Both the direct request and the request through Flutter's proxy receive
{"type":"connected"} promptly, without waiting for the response to close.

Actual results

GET http://127.0.0.1:51880/api/events
HTTP 200 at 33 ms
First line at 33 ms: {"type":"connected"}
GET http://127.0.0.1:51881/api/events
HTTP 200 at 0 ms
After 3000 ms: TimeoutException after 0:00:03.000000: Future not completed

Related:
dart-lang/shelf#503

Code sample

name: proxy_stream_repro
publish_to: none
environment:
  sdk: ^3.10.0
dependencies:
  flutter:
    sdk: flutter
  http: 1.6.0
flutter:
  uses-material-design: true

backend.dart

import 'dart:async';
import 'dart:io';

Future<void> main() async {
  final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 51880);
  print('Backend: http://127.0.0.1:51880/api/events');
  await for (final request in server) {
    unawaited(handle(request));
  }
}

Future<void> handle(HttpRequest request) async {
  final response = request.response;
  response.headers.set('Access-Control-Allow-Origin', '*');
  if (request.uri.path != '/api/events') {
    response.statusCode = HttpStatus.notFound;
    await response.close();
    return;
  }
  response.bufferOutput = false;
  response.headers.set('Content-Type', 'application/x-ndjson');
  response.headers.set('Cache-Control', 'no-store');
  response.headers.set('X-Accel-Buffering', 'no');
  try {
    response.write('{"type":"connected"}\n');
    await response.flush();
    // Keep the response open without filling any downstream proxy buffer.
    await Future<void>.delayed(const Duration(seconds: 30));
    await response.close();
  } catch (_) {
    // The browser aborts after receiving its first line or timing out.
  }
}

web_dev_config.yaml

server:
  host: "127.0.0.1"
  port: 51881
  proxy:
    - prefix: "/api/"
      target: "http://127.0.0.1:51880"

lib/main.dart

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/browser_client.dart';
import 'package:http/http.dart' as http;

void main() => runApp(const MaterialApp(home: StreamDemo()));

class StreamDemo extends StatefulWidget {
  const StreamDemo({super.key});

  @override
  State<StreamDemo> createState() => _StreamDemoState();
}

class _StreamDemoState extends State<StreamDemo> {
  final _messages = <String>[];

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((_) async {
      await _probe('http://127.0.0.1:51880/api/events');
      await _probe('/api/events');
    });
  }

  void _log(String message) {
    if (mounted) setState(() => _messages.add(message));
  }

  Future<void> _probe(String path) async {
    final client = BrowserClient();
    final abort = Completer<void>();
    final uri = Uri.base.resolve(path);
    final watch = Stopwatch()..start();
    _log('GET $uri');
    try {
      final firstLine = await (() async {
        final response = await client.send(
          http.AbortableRequest('GET', uri, abortTrigger: abort.future),
        );
        _log('HTTP ${response.statusCode} at ${watch.elapsedMilliseconds} ms');
        return response.stream
            .transform(utf8.decoder)
            .transform(const LineSplitter())
            .first;
      })().timeout(const Duration(seconds: 3));
      _log('First line at ${watch.elapsedMilliseconds} ms: $firstLine');
    } catch (error) {
      _log('After ${watch.elapsedMilliseconds} ms: $error');
    } finally {
      abort.complete();
      client.close();
    }
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(title: const Text('Flutter proxy streaming reproduction')),
    body: Padding(
      padding: const EdgeInsets.all(16),
      child: SingleChildScrollView(
        child: SelectableText(_messages.join('\n')),
      ),
    ),
  );
}

Screenshots or Video

No response

Logs

No response

Flutter Doctor output

Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.47.7, on Debian GNU/Linux 13 (trixie)
    6.12.111+deb13-amd64, locale zh_CN.UTF-8)
[!] Android toolchain - develop for Android devices (Android SDK version 36.0.0)
    ✗ cmdline-tools component is missing.
      Try installing or updating Android Studio.
      Alternatively, download the tools from
      https://developer.android.com/studio#command-line-tools-only and make sure
      to set the ANDROID_HOME environment variable.
      See https://developer.android.com/studio/command-line for more details.
    ✗ Android license status unknown.
      Run `flutter doctor --android-licenses` to accept the SDK licenses.
      See https://flutter.dev/to/linux-android-setup for more details.
[✓] Chrome - develop for the web
[✓] Linux toolchain - develop for Linux desktop
[✓] Connected device (2 available)

Activity

  1. saurabh-mirajkar commented on Oct 14, 2026

    @saurabh-mirajkar
    Contributor

    @onism0106 Thanks for the report. I am able to reproduce the issue on the latest stable release following the details provided in the report. Labelling for team-web to investigate further.

  2. added
    toolAffects the "flutter" command-line tool. See also t: labels.
    platform-webWeb applications specifically
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    team-webOwned by Web platform team
    and removed
    in triagePresently being triaged by the triage team
    on Oct 14, 2026
  3. Scout-commits-dot commented on Oct 16, 2026

    @Scout-commits-dot
  4. added a commit that references this issue on Nov 4, 2026
    060795f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

assigned for triageissue is assigned to a domain expert for further triagehas reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-webWeb applications specificallyteam-webOwned by Web platform teamtoolAffects the "flutter" command-line tool. See also t: labels.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions