How to Use Browser DevTools with Flutter WebViews

Chrome DevTools on Android, Safari Web Inspector on iOS — full console, network, and debugger access for the web content inside your Flutter app.

13 min read

If your Flutter app uses WebViews, you’ve probably hit a wall where something goes wrong inside the web content and you have no idea why. A network request silently fails, JavaScript throws an error you can’t see, or the layout looks broken and you’re left guessing.

The good news: you can use the same browser developer tools you already know — Chrome DevTools on Android and Safari Web Inspector on iOS — to debug WebView content inside your Flutter app. Full console, network inspection, DOM editing, breakpoints, and all.

This post walks through the complete setup, the common gotchas that trip people up (especially on iOS), and a demo app you can use to try every debugging technique hands-on.

The Demo App

We built a Flutter app with five interactive scenarios, each designed to exercise a specific DevTools tab:

ScenarioDevTools TabWhat It Demonstrates
Console LoggingConsoleconsole.log, warn, error, table, group, trace
Network RequestsNetworkfetch, XMLHttpRequest, headers, timing, error states
DOM InspectionElementsElement selection, live CSS editing, CSS variables, animations
JS DebuggingSourcesBreakpoints, step-through, call stack, closures, debugger;
Error DebuggingConsoleError types, stack traces, caught vs uncaught, promise rejections

The full source code is available in this repository. For the rest of this post, we’ll reference specific code from the app as we walk through each setup step and debugging technique.

Prerequisites

  • Flutter SDK 3.18+
  • A physical Android device or emulator (for Chrome DevTools)
  • A physical iOS device or simulator + a Mac with Safari (for Safari Web Inspector)
  • The webview_flutter package (official, maintained by the Flutter team)

Step 1: Project Setup

Add the dependencies

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  webview_flutter: ^4.10.0
  webview_flutter_android: ^4.3.0
  webview_flutter_wkwebview: ^3.16.0

Run flutter pub get to install.

The main webview_flutter package provides the cross-platform API. The two platform packages (webview_flutter_android and webview_flutter_wkwebview) are needed to access platform-specific features like enabling debugging and setting WebViews as inspectable. The platform packages are already transitive dependencies of webview_flutter, but adding them as direct dependencies gives you access to their platform-specific classes.

Step 2: Platform Configuration

Android: Internet permission

WebViews that make network requests need the INTERNET permission. Add it to android/app/src/main/AndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET"/>
    <application
        ...

This goes before the <application> tag.

iOS: App Transport Security

By default, iOS blocks HTTP (non-HTTPS) requests from WebViews. If your web content needs to reach HTTP endpoints, add this to ios/Runner/Info.plist:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
    <key>NSAllowsArbitraryLoadsInWebContent</key>
    <true/>
</dict>

Note: For production apps, you should use more specific ATS exceptions rather than allowing all arbitrary loads. This blanket setting is appropriate for development and demo purposes.

Step 3: Enable WebView Debugging in Code

This is the critical step that most tutorials gloss over. There are two separate mechanisms, one for each platform, and both are accessed through the platform-specific packages.

Android: AndroidWebViewController.enableDebugging

On Android, WebView debugging is a global, static setting. You call it once, typically in main(), and it applies to every WebView in your app:

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (WebViewPlatform.instance is AndroidWebViewPlatform) {
    AndroidWebViewController.enableDebugging(true);
  }

  runApp(const MainApp());
}

Key points:

  • This maps to Android’s native WebView.setWebContentsDebuggingEnabled(true)
  • The platform check (is AndroidWebViewPlatform) ensures it only runs on Android
  • You must call this before any WebView is created
  • It affects all WebViews in the app, not just one

iOS: setInspectable per WebView

iOS works differently. Starting with iOS 16.4, Apple changed the default behavior: WebViews are no longer inspectable unless you explicitly opt in. This is a per-WebView setting, not a global one.

After creating your WebViewController, cast the platform controller and call setInspectable:

import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';

// Create the controller with platform-specific params
late final PlatformWebViewControllerCreationParams params;
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
  params = WebKitWebViewControllerCreationParams(
    allowsInlineMediaPlayback: true,
  );
} else {
  params = const PlatformWebViewControllerCreationParams();
}

final controller = WebViewController.fromPlatformCreationParams(params);

// Enable inspection on iOS 16.4+
if (controller.platform is WebKitWebViewController) {
  (controller.platform as WebKitWebViewController).setInspectable(true);
}

This is the single most common reason people report “Safari Web Inspector can’t see my WebView.” Before iOS 16.4, inspection was always enabled in debug builds. After iOS 16.4, you must call setInspectable(true) on each individual WebView instance.

On iOS simulators, WebViews are always inspectable regardless of this flag. So you might think everything is working in the simulator, then get confused when it breaks on a real device. Always test inspection on a physical device.

Step 4: Connect DevTools

Android: Chrome DevTools

  1. Enable USB debugging on your Android device:

    • Go to Settings > About phone
    • Tap Build number 7 times to enable Developer Options
    • Go to Settings > Developer options
    • Enable USB debugging
  2. Connect your device via USB to your development machine.

  3. When prompted on the device, tap Allow to authorize USB debugging from your computer.

  4. Run your Flutter app:

    flutter run
  5. Open Chrome on your development machine and navigate to:

    chrome://inspect/#devices
  6. Ensure “Discover USB devices” is checked.

  7. Your device should appear in the list, with each WebView shown under it. Click “inspect” next to the WebView you want to debug.

A full Chrome DevTools window opens, connected to your WebView. You now have access to Console, Network, Elements, Sources, and everything else.

Emulator note: chrome://inspect works with emulators too, but the connection can be flaky. For reliable demos, use a physical device.

iOS: Safari Web Inspector

Safari Web Inspector requires a Mac — there is no Windows or Linux equivalent.

  1. Enable Web Inspector on your iOS device:

    • Go to Settings > Safari > Advanced
    • Toggle Web Inspector ON

    This setting is easy to miss. It’s not under the main Safari settings — you need to scroll to the bottom and tap Advanced.

  2. Enable the Develop menu in Safari on your Mac:

    • Open Safari > Settings (or press Cmd + ,)
    • Go to the Advanced tab
    • Check “Show features for web developers”

    This adds a Develop menu to Safari’s menu bar.

  3. Connect your iOS device via USB to your Mac (or use the same Wi-Fi network for wireless debugging).

  4. Run your Flutter app on the iOS device:

    flutter run
  5. In Safari on your Mac, go to Develop > [Your Device Name]. You should see your app and its WebView listed. Click on it to open Web Inspector.

If your device doesn’t appear in the Develop menu:

  • Make sure the device is unlocked
  • Try disconnecting and reconnecting USB
  • Restart Safari
  • Confirm setInspectable(true) was called on your WebViewController (iOS 16.4+)
  • Confirm Web Inspector is enabled in device Settings

Step 5: Debugging in Practice

Now that DevTools is connected, let’s walk through what you can actually do. Each section corresponds to a scenario in our demo app.

Console logging

DevTools tab: Console

The Console tab captures all JavaScript console.* calls from your WebView. This is your first stop for understanding what’s happening inside the web content.

Our demo provides buttons that exercise every console method:

// Basic log levels -- each gets a different icon/color in DevTools
console.log('Standard message');
console.warn('This will show a yellow warning icon');
console.error('This shows red with a stack trace');
console.info('Informational message');

// Structured data
console.table([
    { name: 'Alice', role: 'Engineer', level: 'Senior' },
    { name: 'Bob', role: 'Designer', level: 'Mid' },
]);

// Grouped output for readability
console.group('API Request Lifecycle');
console.log('1. Request initiated');
console.log('2. Headers set');
console.groupEnd();

// Performance measurement
console.time('Heavy Operation');
// ... do work ...
console.timeEnd('Heavy Operation');

// Stack traces
console.trace('How did we get here?');

What to try in DevTools:

  • Filter by log level (click the icons next to “Default levels”)
  • Click on logged objects to expand and inspect them
  • Use console.table output — it renders as an actual sortable table
  • Right-click a log entry and select “Store as global variable” to interact with it in the Console

Network requests

DevTools tab: Network

The Network tab shows every HTTP request the WebView makes, with full request/response details.

Our demo fires various types of requests:

// Simple GET -- inspect response body and headers
await fetch('https://jsonplaceholder.typicode.com/posts/1');

// POST with payload -- inspect the request body
await fetch('https://jsonplaceholder.typicode.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        title: 'Debug Demo Post',
        body: 'Created from WebView debug demo.',
        userId: 1
    })
});

// Slow request -- watch the timing bar in the waterfall
await fetch('https://httpbin.org/delay/3');

// Parallel requests -- see the waterfall visualization
await Promise.all([
    fetch('https://jsonplaceholder.typicode.com/posts/1'),
    fetch('https://jsonplaceholder.typicode.com/posts/2'),
    fetch('https://jsonplaceholder.typicode.com/users/1'),
    fetch('https://jsonplaceholder.typicode.com/comments/1'),
    fetch('https://jsonplaceholder.typicode.com/todos/1'),
]);

What to try in DevTools:

  • Click any request to see its Headers, Payload, Response, and Timing tabs
  • The Waterfall column shows how long each request took and whether they overlapped
  • Filter requests by type (Fetch, XHR, JS, CSS, etc.)
  • Right-click a request and select “Copy as cURL” to replay it in your terminal
  • Throttle the connection (Network tab > throttle dropdown) to simulate slow networks

DOM inspection

DevTools tab: Elements

The Elements tab lets you inspect and live-edit the DOM and CSS of your WebView content. This is especially useful when debugging layout issues in hybrid apps.

Our demo includes a rich layout with multiple CSS techniques:

<!-- CSS custom properties on :root for easy live editing -->
<style>
    :root {
        --primary: #2196F3;
        --accent: #9C27B0;
        --radius: 12px;
    }
    .profile-card {
        background: var(--card-bg);
        border-radius: var(--radius);
        box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    }
    .pulse-dot {
        animation: pulse 2s ease-in-out infinite;
    }
</style>

<!-- Rich DOM structure for inspection practice -->
<div class="profile-card" id="profile">
    <div class="avatar">JD</div>
    <div class="profile-info">
        <h3>Jane Developer</h3>
        <span class="role">Senior Engineer</span>
    </div>
</div>

What to try in DevTools:

  • Click the inspect cursor (top-left icon) and tap any element on the WebView to jump to it in the DOM tree
  • Edit CSS properties in the Styles panel — changes apply instantly
  • Change CSS variables on :root to restyle the entire page (try changing --primary to #E91E63)
  • Check the Box Model diagram to understand padding, margin, and border
  • Toggle the CSS animation on .pulse-dot by unchecking the animation property
  • Double-click text content in the Elements panel to edit it inline
  • Check Computed tab to see the final resolved values for any property

JavaScript debugging

DevTools tab: Sources

The Sources tab is your full JavaScript debugger — breakpoints, step-through execution, variable inspection, and call stack analysis.

Our demo includes a data processing pipeline with clearly-marked breakpoint targets:

function fetchData() {
    // BREAKPOINT HERE: inspect the raw data
    const rawData = [
        { id: 1, name: 'Widget A', price: 29.99, category: 'electronics', inStock: true },
        // ...
    ];
    return rawData;
}

function transformData(data) {
    // BREAKPOINT HERE: watch the transformation
    const transformed = data.map(item => ({
        ...item,
        displayName: item.name.toUpperCase(),
        priceFormatted: '$' + item.price.toFixed(2),
    }));
    return transformed;
}

function filterData(data) {
    // BREAKPOINT HERE: see what gets filtered
    const filtered = data.filter(item => {
        const isInStock = item.inStock === true;
        const isAffordable = item.price < 50;
        return isInStock && isAffordable;
    });
    return filtered;
}

What to try in DevTools:

  1. In the Sources tab, find the page’s script (it may appear under demo.local).
  2. Click a line number to set a breakpoint (a blue marker appears).
  3. Press “Run Data Pipeline” in the WebView.
  4. Execution pauses at your breakpoint. Now you can:
    • Step Over (F10) to execute the current line and move to the next
    • Step Into (F11) to enter a function call
    • Step Out (Shift+F11) to return from the current function
    • Hover over variables to see their current values
    • Check the Scope panel on the right to see all local and closure variables
    • Check the Call Stack panel to see the function call chain

The demo also includes a debugger; statement behind a button:

function triggerDebugger() {
    const secret = 'You found the hidden value!';
    const counter = 42;
    const data = { message: secret, count: counter };
    debugger;  // Execution pauses here when DevTools is open
}

When you click the button with DevTools open, execution pauses immediately and you can inspect all variables in scope.

Bonus — Fibonacci for call stack depth: Set a breakpoint inside the fibonacci() function and run it. The recursive calls create a deep call stack visible in the Call Stack panel, which is great for understanding how recursive functions execute.

Error debugging

DevTools tab: Console (with Sources “Pause on exceptions”)

The Console tab shows JavaScript errors with their full stack traces. Combined with the “Pause on exceptions” feature in Sources, you can freeze execution at the exact moment an error occurs.

Our demo triggers various error types:

// Each of these creates a different error in DevTools
function throwReferenceError() {
    console.log(undefinedVariable);  // ReferenceError
}

function throwTypeError() {
    const obj = null;
    obj.toString();  // TypeError
}

function throwSyntaxError() {
    eval('function {');  // SyntaxError
}

// Async errors
function throwPromiseRejection() {
    Promise.reject(new Error('Async operation failed: timeout after 30s'));
}

// Caught vs uncaught -- compare behavior in DevTools
function caughtError() {
    try {
        const data = JSON.parse('{ invalid json }');
    } catch (error) {
        console.error('Caught error:', error);
    }
}

What to try in DevTools:

  • Enable “Pause on exceptions” (the pause icon in the Sources tab) — execution will freeze at the exact line where an error is thrown
  • Toggle “Pause on caught exceptions” as well to catch errors inside try/catch blocks
  • Click on stack trace entries to jump to the source location
  • Compare how caught and uncaught errors appear differently in the Console
  • Watch for unhandled promise rejections — these show up with a distinct icon

The WebView Screen: Bringing It Together

Here’s how the demo app’s WebView screen is structured. Each scenario uses the same screen with different HTML content:

import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';

class DemoWebViewScreen extends StatefulWidget {
  final DemoScenario scenario;
  const DemoWebViewScreen({super.key, required this.scenario});

  @override
  State<DemoWebViewScreen> createState() => _DemoWebViewScreenState();
}

class _DemoWebViewScreenState extends State<DemoWebViewScreen> {
  late final WebViewController _controller;

  @override
  void initState() {
    super.initState();

    // Use platform-specific params for iOS
    late final PlatformWebViewControllerCreationParams params;
    if (WebViewPlatform.instance is WebKitWebViewPlatform) {
      params = WebKitWebViewControllerCreationParams(
        allowsInlineMediaPlayback: true,
      );
    } else {
      params = const PlatformWebViewControllerCreationParams();
    }

    _controller = WebViewController.fromPlatformCreationParams(params)
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..loadHtmlString(
        widget.scenario.htmlContent,
        baseUrl: 'https://demo.local/',
      );

    // Enable inspection on iOS 16.4+
    if (_controller.platform is WebKitWebViewController) {
      (_controller.platform as WebKitWebViewController).setInspectable(true);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.scenario.title)),
      body: Column(
        children: [
          // Instruction panel for presenters
          ExpansionTile(
            initiallyExpanded: true,
            title: const Text('What to look for'),
            children: [
              Padding(
                padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
                child: Text(widget.scenario.description),
              ),
            ],
          ),
          const Divider(height: 1),
          // The WebView
          Expanded(
            child: WebViewWidget(controller: _controller),
          ),
        ],
      ),
    );
  }
}

A few things worth noting:

  • WebViewController.fromPlatformCreationParams — On iOS, we pass WebKitWebViewControllerCreationParams to configure WebKit-specific behavior. On Android, we use the default params. This pattern lets you write platform-adaptive code cleanly.
  • setInspectable(true) — This is what makes the WebView visible to Safari Web Inspector on iOS 16.4+. Without it, the WebView simply won’t appear in the Develop menu. You access this through the platform-specific WebKitWebViewController.
  • setJavaScriptMode(JavaScriptMode.unrestricted) — Enables JavaScript execution in the WebView. Without this, none of the demo interactions would work.
  • loadHtmlString with baseUrl — When loading HTML inline, the page’s origin is about:blank by default. Setting a baseUrl gives the page a proper origin so network requests from JavaScript don’t hit CORS issues.
  • WebViewWidget(controller: _controller) — The widget is a thin wrapper that renders whatever the controller has loaded. The controller holds all the configuration and state.

Common Gotchas and Troubleshooting

”My WebView doesn’t appear in chrome://inspect”

  1. Verify AndroidWebViewController.enableDebugging(true) is called in main() before the WebView loads.
  2. Check USB debugging is enabled on the device.
  3. Try a different USB cable — charging-only cables don’t support debugging.
  4. Change USB mode from “Charging” to “File Transfer” on the device.
  5. Refresh chrome://inspect and make sure “Discover USB devices” is checked.

”Safari Develop menu shows my device but no WebView”

This is almost always the setInspectable issue on iOS 16.4+:

  1. Make sure you’re calling setInspectable(true) on the WebKitWebViewController:

    if (_controller.platform is WebKitWebViewController) {
      (_controller.platform as WebKitWebViewController).setInspectable(true);
    }
  2. Make sure Web Inspector is enabled on the device: Settings > Safari > Advanced > Web Inspector.

  3. Rebuild and re-run the app — hot reload doesn’t apply setting changes.

  4. Remember: simulators always work without this flag, so test on a real device.

”Network requests from my WebView fail with CORS errors”

When HTML is loaded via loadHtmlString, the page origin is about:blank, which can trigger CORS restrictions. Pass a baseUrl:

controller.loadHtmlString(
  htmlContent,
  baseUrl: 'https://demo.local/',
);

“The debugger; statement doesn’t pause execution”

debugger; only pauses if DevTools is open and attached before the statement is hit. If DevTools isn’t connected, the statement is silently ignored. Connect DevTools first, then trigger the action.

”I can’t see my WebView’s source in the Sources tab”

The page loaded via loadHtmlString may appear under the baseUrl domain you set (e.g., demo.local). Look in the page tree under that domain. If you don’t set a baseUrl, it appears under about:blank, which can be harder to find.

Quick Reference: Setup Checklist

Android

  • INTERNET permission in AndroidManifest.xml
  • AndroidWebViewController.enableDebugging(true) called in main()
  • USB debugging enabled on device
  • Device connected via USB (data cable, not charging-only)
  • chrome://inspect/#devices open in Chrome with “Discover USB devices” checked

iOS

  • NSAppTransportSecurity configured in Info.plist (if hitting HTTP endpoints)
  • setInspectable(true) called on WebKitWebViewController (required for iOS 16.4+)
  • Web Inspector enabled on device: Settings > Safari > Advanced > Web Inspector
  • “Show features for web developers” enabled in Safari > Settings > Advanced on your Mac
  • Device connected via USB to Mac
  • Safari > Develop > [Device Name] > select the WebView

Wrapping Up

Debugging WebView content in Flutter doesn’t require special tools or complex setups — it’s the same Chrome DevTools and Safari Web Inspector you use for regular web development. The key steps are:

  1. Use webview_flutter with the platform-specific packages for debugging control.
  2. Enable debugging in code: AndroidWebViewController.enableDebugging(true) on Android, setInspectable(true) on iOS.
  3. Configure the device settings: USB debugging on Android, Web Inspector on iOS.
  4. Connect via chrome://inspect or Safari’s Develop menu.

The demo app for this post gives you a hands-on playground for every major DevTools feature. Clone it, run it on a device, connect DevTools, and start inspecting.

If you’ve been treating WebViews as black boxes in your Flutter apps, this is your way in.

Frequently Asked Questions

Why can't Safari Web Inspector see my Flutter WebView on iOS?

Starting with iOS 16.4, WebViews are no longer inspectable by default. You must call setInspectable(true) on each WebKitWebViewController instance after creating the controller. This is a per-WebView setting, not global. iOS simulators remain inspectable regardless of the flag, so always verify on a physical device.

How do I enable Chrome DevTools debugging for WebViews on Android?

Call AndroidWebViewController.enableDebugging(true) once in main() before any WebView is created. It's a global, static setting that applies to every WebView in your app. Then enable USB debugging on the device, connect it, and open chrome://inspect/#devices in Chrome on your development machine.

Do I need a physical device to debug Flutter WebViews?

It depends on the platform. iOS simulators are always inspectable, so they work without setInspectable(true), which can mask issues that appear on real devices. Android emulators can also work with chrome://inspect but the connection is often flaky. For reliable debugging, use a physical device on both platforms.

Why am I getting CORS errors when fetching from a Flutter WebView?

When you load HTML with loadHtmlString, the page origin defaults to about:blank, which triggers CORS restrictions on cross-origin requests. Pass a baseUrl to give the page a proper origin, for example controller.loadHtmlString(html, baseUrl: 'https://demo.local/').

What's the difference between Android and iOS WebView debugging in Flutter?

Android uses a global static setting (AndroidWebViewController.enableDebugging(true)) that affects every WebView in the app. iOS requires per-WebView opt-in via setInspectable(true) on each WebKitWebViewController. Android connects through Chrome DevTools on any OS; iOS requires Safari Web Inspector on a Mac.