widget_chat is live on pub.dev — drop-in AI chat for Flutter, FlutterFlow, React & Web. Start free →

← Back to Blog
Fix getUserMedia on iOS Safari for Flutter Web Voice Chat

Fix getUserMedia on iOS Safari for Flutter Web Voice Chat

flutter webios safarigetusermediavoice chatflutterflowwebkit

Fix getUserMedia on iOS Safari for Flutter Web Voice Chat

You shipped a voice-enabled Flutter web build. Desktop Chrome: perfect. Android Chrome: perfect. Then someone opens it on an iPhone and the microphone light blinks on for a split second and dies. No error, no crash — the MediaStream just goes silent, or the audio track fires ended almost immediately. If you're searching "flutter web microphone not working iphone" right now, this post is the missing explanation.

And yes, it also fails in Chrome on iOS. Every browser on iOS is WebKit under the hood (Apple requires it for browsers distributed through the App Store in most regions), so this is one bug with one fix.

Why WebKit kills your mic stream

Three WebKit behaviors stack on top of each other, and Flutter web apps trip all three:

  1. AudioContext starts suspended unless it's created or resumed inside a user gesture. Safari's autoplay policy applies to the Web Audio API, not just <audio> tags. An AudioContext created in main() or in an initState sits in the suspended state forever on iOS.
  2. The getUserMedia permission dialog does not count as a user gesture. This one surprises everyone. On desktop you can call getUserMedia whenever you like, then resume audio after the user clicks "Allow". On iOS, the user tapping "Allow" in the system permission sheet does not grant transient activation — so any audioContext.resume() you chain after the permission resolves is rejected or silently ignored (Apple Developer Forums).
  3. WebKit aggressively suspends capture that isn't feeding an active audio pipeline. A mic track that isn't connected to a running AudioContext (or an active WebRTC connection) is a prime candidate for iOS to mute or end — which is exactly the "flutter web audio recording stops immediately" symptom. There are long threads of developers hitting the mic being disabled the moment they try to record (Apple Developer Forums, flutter-webrtc #1550).

The typical broken flow in a Flutter voice UI looks like this: user taps the mic button → your code awaits some setup (fetch a token, show a dialog, Future.delayed) → then calls getUserMedia → then tries audioContext.resume(). By the time resume() runs, the gesture's transient activation has expired, the context stays suspended, the track has nothing consuming it, and WebKit kills it.

The fix: gesture-chain everything

The rule for getUserMedia on iOS Safari in Flutter is: inside the synchronous part of the tap handler, create/resume the AudioContext first, then request the mic, then immediately wire the stream into that running context. No awaits before the AudioContext work, no dialogs in between.

Here's a working implementation using package:web and dart:js_interop (the modern replacement for dart:html):

import 'dart:js_interop';
import 'package:web/web.dart' as web;

web.AudioContext? _ctx;
web.MediaStream? _micStream;

/// Call this DIRECTLY from onPressed/onTap — do not await anything first.
Future<bool> startMicFromTap() async {
  try {
    // 1. Create or resume the AudioContext synchronously inside the tap.
    //    On iOS this only succeeds while the gesture's transient
    //    activation is still alive.
    _ctx ??= web.AudioContext();
    if (_ctx!.state != 'running') {
      await _ctx!.resume().toDart;
    }

    // 2. Now request the mic, still on the same gesture chain.
    _micStream = await web.window.navigator.mediaDevices
        .getUserMedia(web.MediaStreamConstraints(audio: true.toJS))
        .toDart;

    // 3. Immediately feed the stream into the RUNNING context so
    //    WebKit sees an active consumer and keeps the track alive.
    final source = _ctx!.createMediaStreamSource(_micStream!);
    final analyser = _ctx!.createAnalyser();
    source.connect(analyser);
    // ...connect your real pipeline here (AudioWorklet, encoder, WebRTC).

    // 4. Instrument the track so failures are visible, not silent.
    final track = _micStream!.getAudioTracks().toDart.first;
    track.onended = ((web.Event _) {
      // If this fires within ~1s on iPhone, something above ran
      // outside the gesture chain.
    }).toJS;

    return true;
  } catch (_) {
    return false;
  }
}

Wire it straight into your widget:

IconButton(
  icon: const Icon(Icons.mic),
  onPressed: () => startMicFromTap(), // no async gap before this
)

Flutter dispatches onPressed from the browser's pointer event, so transient activation is intact when your Dart runs — as long as you don't insert an await before the AudioContext calls. Fetch your auth token before the user taps, or after the stream is live. Never between.

Three more things that silently break it

  • HTTPS is mandatory. navigator.mediaDevices is undefined outside a secure context. Testing on your iPhone against http://192.168.x.x:8080 fails before your code even runs — use a tunneled HTTPS URL or a local cert.
  • Iframes need explicit permission. If your Flutter web app is embedded, the parent must set allow="microphone" on the <iframe>, or iOS denies the mic with a permission error your users will read as "flutter web mic permission safari is broken".
  • One capture stream at a time. iOS Safari ends the previous audio track when a new getUserMedia call starts. Cache and reuse _micStream; don't re-request per utterance (webrtcHacks Safari guide).

The FlutterFlow variant

In FlutterFlow, put the same code in a custom action and bind it to the mic button's On Tap — first action in the chain, before any navigate/wait/backend call. That ordering is the entire fix for a "flutterflow web app microphone permission ios" failure: any action FlutterFlow runs before yours can burn the gesture.

// FlutterFlow custom action: startIosSafeMic
// Return type: bool. Exclude from compilation on mobile if you
// guard with kIsWeb, since package:web is web-only.
import 'dart:js_interop';
import 'package:web/web.dart' as web;

Future<bool> startIosSafeMic() async {
  try {
    final ctx = web.AudioContext();
    if (ctx.state != 'running') await ctx.resume().toDart;
    final stream = await web.window.navigator.mediaDevices
        .getUserMedia(web.MediaStreamConstraints(audio: true.toJS))
        .toDart;
    ctx.createMediaStreamSource(stream).connect(ctx.createAnalyser());
    return true;
  } catch (_) {
    return false;
  }
}

Branch on the returned bool: false means the user denied the mic or you're on an insecure origin, so show a friendly retry prompt instead of a dead mic icon.

Why this matters for AI voice support in your app

If you're reading this, you're probably not building a toy recorder — you're building voice into a real product, most likely a support or assistant flow. This gesture-chained pattern is exactly what WidgetChat does under the hood so its live voice chat works in web Flutter builds, not just native iOS and Android.

WidgetChat is an AI customer-support chatbot you embed in Flutter and FlutterFlow apps — it answers users from your own content over streaming Server-Sent Events (POST https://api.widgetchat.app/v1/chat/stream), integrated through a plain HTTP client or a FlutterFlow custom action, no proprietary SDK required. With voice enabled, your users tap the mic in the same embedded widget and get a real-time voice call with the assistant: speech-to-speech replies in a natural voice, barge-in so they can interrupt mid-sentence, live captions, and rich product cards on screen while it talks. Same conversation history and dashboard as text chat, across iOS, Android, and web — and your provider API keys stay server-side. Voice minutes are plan-gated and configurable per project in the dashboard's Voice section (enable/disable, voice name, max session length, captions default).

So you have two paths: wire up the gesture-chained getUserMedia pipeline above and build your own capture, transport, and playback stack — or embed a widget where iPhone Safari's quirks are already handled.

Try WidgetChat free — embed the chat widget in your Flutter or FlutterFlow app, flip on voice in the dashboard, and your users can talk to your AI assistant on the web build too.

MDN's getUserMedia reference documents the secure-context and permission requirements that trip up iOS web builds.

WidgetChat embeds an AI support chatbot — with live voice calls — into Flutter and FlutterFlow apps, including web builds.

Author

About the author

Widget Chat is a team of developers and designers passionate about creating the best AI chatbot experience for Flutter, web, and mobile apps.

Comments

Comments are coming soon. We'd love to hear your thoughts!