A Flutter checkout's payment-success callback sometimes fires twice; how would you trace why with logging, breakpoints and DevTools?
answer
- two hits, two call stacks
- pause only on the second call
- named, sequenced log entries
- count requests in the network view
- listener registered more than once
basics
~20 sLog 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 sFirst 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 linesimport '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
Know the tools involved: a log line per call, a breakpoint to see the call stack, and the network view to check requests.
Explain why registering a listener in build or didChangeDependencies duplicates calls and how comparing two call stacks reveals the second registration.
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.
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.