DEV Community

Ronny Nyabuto
Ronny Nyabuto

Posted on

I measured M-Pesa STK Push polling lag on a real device. The variance will ruin your UX.

Same code. Same device. Same network. Same shortcode.

Test 1: 39 seconds from PIN entry to UI update.
Test 2: 3 seconds.

13x variance. Not a bug. Not a fluke. Just the math of a fixed polling schedule colliding with a non-deterministic callback.
When you fire an STK Push, Safaricom returns a CheckoutRequestID and ResponseCode: 0 almost immediately. Most developers celebrate this. It means nothing. It means Safaricom received your request. The customer hasn't seen a prompt yet.

The actual payment outcome arrives later — via a POST to your CallBackURL. That callback takes 5 seconds or it takes 45. Safaricom doesn't tell you when it's coming. And if your server isn't reachable when it arrives, Safaricom does not retry. The delivery attempt is fire-and-forget.

So the typical Flutter developer does what makes sense: they poll. Every 10 or 30 seconds, ask the server if anything happened. This works until it doesn't.


My polling schedule fired at T+10s, T+30s, and T+70s. In Test 1, the callback landed at T+45s — squarely between the T+30 and T+70 windows. The next poll was 25 seconds away. Safaricom completed the payment in 14 seconds. The user waited 39.

Test 1:
  PIN entered:        11:10:48
  Callback processed: 11:11:02  (14s — Safaricom's side)
  UI updated:         11:11:27  (39s — polling lag)

  Polls: T+10 → PENDING, T+30 → PENDING, T+70 → SUCCESS

Test 2:
  PIN entered:        11:31:55
  Callback processed: 11:31:59
  UI updated:         11:31:58  (3s)

  T+10 poll and callback arrived within 1 second of each other.
  Lucky timing. Not better code.
Enter fullscreen mode Exit fullscreen mode

The same polling schedule. The only variable was when Safaricom's callback landed relative to the poll windows.


There is one optimisation that actually moves the number.

The real-world flow for most users: tap "Pay," get the USSD prompt, press home, open M-Pesa to confirm the request or check their balance, enter PIN, return to your app. The app was backgrounded the entire time. The callback arrived and was processed server-side while the user was in a different app. Without WidgetsBindingObserver, they come back to a spinner and wait for the next scheduled poll.

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (state == AppLifecycleState.resumed) {
    ref.read(paymentProvider.notifier).checkStatusOnResume();
  }
}
Enter fullscreen mode Exit fullscreen mode

The moment they return to your app, you poll immediately. My Test 7 result: 1–2 seconds from return to PaymentSuccess.

That is not a polling win. That is knowing when to trigger the poll. Most Flutter M-Pesa implementations do not have this. The USSD flow almost guarantees the user will background the app. The one scenario you should optimize for is the one most developers leave unhandled.


The failure mode nobody documents is worse.

Test 3: I killed the ngrok tunnel after the STK Push was sent but before the customer entered their PIN. Customer paid. Balance reduced. Server never received the callback. Safaricom made one delivery attempt, got no response, and moved on.

DB state after 90 seconds:

status:               PENDING
result_code:          null
failure_reason:       null
mpesa_receipt_number: null
Enter fullscreen mode Exit fullscreen mode

The app timed out and displayed: "Status unknown. We did not receive a confirmation within the expected window."

That copy is deliberate. Telling a user their payment failed when money has already left their account is not a UX problem. It is a trust problem. The distinction matters more than most developers realize until a customer calls.

This is not a contrived scenario. It happens when your server restarts, when your laptop sleeps during a demo, when a deployment takes thirty seconds at the wrong moment. Safaricom does not retry. The only recovery is reconciliation — query the STK Push Query endpoint on a schedule and resolve orphaned PENDING records.

One caveat: Safaricom's sandbox STK Query API returned FAILED for confirmed SUCCESS payments during testing. That is a known sandbox limitation. Production behaves correctly.


The baseline from this session:

Polling lag: 3–39 seconds, non-deterministic.
Callback delivery: 100% when the server is reachable. 0% when it isn't.
Lifecycle optimisation: 1–2 seconds on resume, which covers the most common real-world flow.

Every Flutter developer building on M-Pesa either lives with these numbers, reinvents the solution from scratch, or doesn't know the problem exists until a production incident surfaces it.

No maintained Flutter package handles the full lifecycle — callback receipt, persistence, polling fallback, lifecycle recovery — without requiring a separately managed backend. That is the gap.

The next post will show what happens when you replace the polling cascade with Appwrite Realtime. The numbers are not subtle.


Tested on Google Pixel 9, Android 15. Daraja sandbox, Flutter 3.41. All timings are from real device logs. Test harness: flutter-daraja-raw.

Top comments (0)