skip to content

A Flutter checkout's payment-success callback sometimes fires twice; how would you trace why with logging, breakpoints and DevTools?

level: seniorimportance: should knowfreq 30%

answer

  1. two hits, two call stacks
  2. pause only on the second call
  3. named, sequenced log entries
  4. count requests in the network view
  5. listener registered more than once

basics

~20 s

Log each call with a name and sequence number, pause on the second call with a conditional breakpoint or debugger(when:) and compare its call stack with the first, and count requests in the network view. Usually a listener was registered twice.

solid answer

~40 s

First I make the duplication visible: a `developer.log` entry in the callback with `name: 'payments'`, the payment id and an incrementing `sequenceNumber`, read in the DevTools Logging view filtered to that name. Then I pause on the **second** call only, with a conditional breakpoint or `debugger(when: _calls > 1)`, and compare its **call stack** with the first: two different origins mean two registrations; the same origin twice means the event itself was emitted twice. The **network view** tells me whether the app actually sent two confirmation requests. The most common culprit is a subscription made in `build` or `didChangeDependencies`, both of which can run many times, instead of once in `initState` with `cancel()` in `dispose`. After the fix I keep a guard: ignore a payment id already handled.

code

dart · 54 lines
dart
import 'dart:async';

import 'package:flutter/material.dart';

class PaymentGateway {
  PaymentGateway._();
  static final PaymentGateway instance = PaymentGateway._();
  final StreamController<String> _results = StreamController<String>.broadcast();
  Stream<String> get results => _results.stream;
}

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

  @override
  State<CheckoutPage> createState() => _CheckoutPageState();
}

class _CheckoutPageState extends State<CheckoutPage> {
  StreamSubscription<String>? _results;
  final Set<String> _handled = <String>{};
  String? _paidId;

  // BUG (before): subscribing here ran again on every dependency change,
  // so each success event reached one more listener.
  // @override
  // void didChangeDependencies() {
  //   super.didChangeDependencies();
  //   PaymentGateway.instance.results.listen(_onSuccess);
  // }

  @override
  void initState() {
    super.initState();
    _results = PaymentGateway.instance.results.listen(_onSuccess); // once
  }

  void _onSuccess(String paymentId) {
    if (!_handled.add(paymentId)) return; // idempotent guard
    if (!mounted) return;
    setState(() => _paidId = paymentId);
  }

  @override
  void dispose() {
    _results?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(body: Center(child: Text(_paidId == null ? 'Paying...' : 'Paid: $_paidId')));
  }
}

go deeper

for a junior

Know the tools involved: a log line per call, a breakpoint to see the call stack, and the network view to check requests.

for a middle

Explain why registering a listener in build or didChangeDependencies duplicates calls and how comparing two call stacks reveals the second registration.

for a senior

Demonstrate the full trace: named, sequenced logs; a pause on the second call only; network confirmation; a fix at the registration site plus an idempotent handler and a regression test.

for a principal

Treat payment flows as needing idempotency by design, end to end, with the server deduplicating too, rather than relying on the UI never repeating an event.

## Why this bug matters A payment callback that runs twice can show two success screens, send two confirmation requests or, worst case, fulfil an order twice. The job is to find **who calls it the second time**, then fix the cause rather than hiding the symptom. ## Step 1: make each call visible Add a structured log entry at the top of the callback: - `developer.log('success ${result.paymentId}', name: 'payments', sequenceNumber: ++_calls)`; - in the **DevTools Logging view**, filter to `payments`. Two entries with the same payment id confirm the duplicate; two different ids mean two real payments and a different bug. Use `log()` rather than `print`, so the entries carry a name you can filter on and are not interleaved with other output. ## Step 2: pause on the second call A plain breakpoint stops on every call, which is noise here. Pause only on the duplicate: 1. Add a **conditional breakpoint** in the IDE, or write `debugger(when: _calls > 1)` from `dart:developer` temporarily. 2. When it hits, read the **Call stack** pane and the **Variables** of each frame. 3. Compare with the stack of the first call, taken from a breakpoint that fires once. ## Step 3: read what the stacks say | Observation | Likely cause | |---|---| | two different registration sites in the stacks | the handler is registered twice, for example two listeners | | identical stacks, triggered by the same stream | the source emitted the event twice | | the second call comes from a gesture handler | a double tap reached `onPressed` twice before the button was disabled | | the second call starts after a hot reload | a subscription made in `build()` was added again when the reload rebuilt the widget | ## Step 4: check the network Open the **network view** and look at the confirmation endpoint. If there are two requests, the duplicate reached the server; filter with the endpoint path and `m:post` to count them. If there is one, the duplicate is only in the UI layer. ## The usual root cause The classic bug is registering a listener in a method that runs more than once: - `build()` runs on every rebuild; - `didChangeDependencies()` runs again whenever an inherited widget the `State` depends on changes, such as the theme or `MediaQuery`. Each run adds another subscription to the payment stream, so each success event is delivered once per subscription. The fix is to subscribe **once**, in `initState`, keep the `StreamSubscription`, and cancel it in `dispose`. ## Step 5: harden it - Make the handler **idempotent**: remember handled payment ids and ignore repeats. - Disable the pay button while a payment is in flight. - Remove the temporary `debugger()` call; keep the `log()` entry if it is useful, guarded for release. - Add a widget test that emits one success event and expects the receipt state to be set once. ## Tools recap - **Logging view**: proves the duplicate and correlates ids. - **Breakpoint or `debugger(when:)`**: stops exactly on the duplicate. - **Call stack**: names the second caller. - **Network view**: shows whether the server saw it twice.

  • Why can subscribing to a stream in a Flutter State's didChangeDependencies deliver each event twice?
    `didChangeDependencies` runs after `initState` and again whenever an inherited widget the State depends on changes, such as the theme or `MediaQuery`. Each run calls `listen` again and adds another subscription, so one event reaches several handlers. Subscribe once in `initState` and cancel in `dispose`.
  • After fixing the double registration in the Flutter checkout, why keep an idempotency guard in the callback?
    Other paths can still repeat an event: a gateway retry, a double tap before the button disables, or a stream that re-emits after reconnecting. Ignoring a payment id you have already handled makes the handler safe regardless of the source, which is cheap insurance for a money path.

saying these in an interview costs you the question

  • A breakpoint on every call is the fastest way to find the second caller.
  • If the network view shows one request, the callback cannot have run twice.
  • Registering the listener in build() is fine because Flutter deduplicates listeners.
  • Adding a delay before navigation fixes a callback that fires twice.
  • print() with the payment id gives the same filtering as a named log() entry.