Building for Kenyan Network Conditions: Retries and Timeouts
Kenyan networks are good in cities and unreliable everywhere else. Payment systems must handle dropped connections, high latency, STK push delays, and customers on 2G. Set server-side timeouts to at least 30 seconds for M-Pesa operations. Make every payment request idempotent using unique references. Build your UI to survive connection drops mid-payment. Send SMS confirmations as a fallback channel. Keep payloads small to reduce data costs and loading times.
The Reality of Kenyan Networks
Kenya has good mobile internet coverage by African standards. 4G covers most urban areas. Safaricom, Airtel, and Telkom have invested heavily in infrastructure. Nairobi, Mombasa, Kisumu, and other major towns have reliable connectivity most of the time.
But "most of the time" is not "all of the time," and "major towns" is not "everywhere." Here is what your payment system actually faces:
- 2G/3G fallback. Outside urban centers, connections drop to 3G or even 2G. A page that loads in 1 second on 4G takes 8 to 15 seconds on 2G. Payment API calls that complete in 500ms on 4G can take 5 to 10 seconds on 3G.
- Intermittent connectivity. Even in Nairobi, buildings, matatus, and underground areas cause connection drops. A customer walking through a busy area might lose signal for 10 to 30 seconds at a time.
- High latency. Connections through congested towers can have latency of 500ms to 2 seconds per request. Your payment flow that involves three sequential API calls (initialize, charge, verify) could take 6 seconds just in network time.
- Dropped connections mid-payment. The customer taps "Pay," the request starts, and the connection drops. Did the payment go through? Did it fail? The customer does not know. Your system might not know either.
- Data costs. Many Kenyans buy data in small bundles (10MB, 50MB, 100MB). A checkout page that loads 2MB of JavaScript, images, and fonts costs them real money. Uncompressed API responses waste their data.
- STK push delays. M-Pesa STK push is delivered through the GSM network, not the internet. On congested networks, the push can take 5 to 15 seconds to arrive on the customer's phone. If your system times out at 10 seconds, the push might arrive after you have already shown an error.
These are not hypothetical problems. They are the normal operating conditions for a large segment of your user base. Every design decision in your payment flow should account for them.
Timeout Configuration
The default HTTP timeout in most libraries is 30 seconds. For payment operations in Kenya, you need to think about timeouts at multiple levels.
Server-side timeouts (your backend calling Paystack):
// Configure axios with appropriate timeouts for Kenyan conditions
const paystackClient = axios.create({
baseURL: 'https://api.paystack.co',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
timeout: 45000, // 45 seconds for payment operations
});
// For M-Pesa charges specifically, use a longer timeout
async function initiateMpesaCharge(phone, amount, email, reference) {
try {
const response = await paystackClient.post('/charge', {
email,
amount,
currency: 'KES',
reference,
mobile_money: { phone, provider: 'mpesa' },
}, {
timeout: 60000, // 60 seconds for M-Pesa specifically
});
return { success: true, data: response.data.data };
} catch (error) {
if (error.code === 'ECONNABORTED') {
// Timeout: the charge might still be processing
return {
success: false,
timeout: true,
reference, // Important: keep the reference to check later
message: 'Request timed out. The payment may still be processing.',
};
}
throw error;
}
}
Client-side timeouts (the customer's browser or app):
- Set fetch/axios timeouts on the client to at least 30 seconds for payment requests.
- Show a loading indicator immediately after the customer taps "Pay." Do not wait for the response to show feedback.
- After 10 seconds of loading, show a "Still processing..." message. After 20 seconds, show "This is taking longer than usual. Please wait." The customer needs to know the app has not frozen.
- Never show a timeout error and assume the payment failed. If the client times out, the server may still be processing. Check the payment status before telling the customer it failed.
STK push timeout:
After you call the Paystack Charge API with M-Pesa, the customer receives an STK push on their phone. They have to read the prompt, confirm the amount, and enter their PIN. This takes 10 to 30 seconds for a typical customer. On a slow network, the push itself may take 5 to 15 seconds to arrive.
Your UI should tell the customer: "Check your phone for the M-Pesa prompt" and show a waiting state for at least 60 seconds before suggesting they try again. Some implementations poll the Paystack Verify endpoint every 5 seconds during this wait to detect successful payment.
Idempotent Retry Strategies
When a network request fails, the natural reaction is to retry. In payment systems, retrying without idempotency guarantees means you might charge the customer twice. This is the single most important rule for payment integrations on unreliable networks.
The rule: every payment request must include a unique, client-generated reference. If you retry the request, use the same reference.
// Generate a unique reference before the first attempt
function generatePaymentReference(orderId) {
return `order-${orderId}-${Date.now()}`;
}
// Retry logic with the same reference
async function chargeWithRetry(paymentDetails, maxRetries = 3) {
const reference = generatePaymentReference(paymentDetails.orderId);
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await paystackClient.post('/charge', {
...paymentDetails,
reference, // Same reference on every attempt
});
return { success: true, data: result.data.data };
} catch (error) {
lastError = error;
// Do NOT retry if Paystack explicitly rejected the charge
if (error.response?.status === 400) {
return {
success: false,
retryable: false,
message: error.response.data.message,
};
}
// Retry on network errors and timeouts
if (error.code === 'ECONNABORTED' || error.code === 'ECONNRESET' || !error.response) {
console.log(`Attempt ${attempt} failed (network error). Retrying...`);
// Exponential backoff: 2s, 4s, 8s
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)));
}
continue;
}
// Do not retry server errors from Paystack (500s)
// The charge may have been created. Verify instead of retrying.
if (error.response?.status >= 500) {
return {
success: false,
shouldVerify: true,
reference,
message: 'Paystack server error. Verify the transaction status.',
};
}
throw error;
}
}
// All retries exhausted
return {
success: false,
shouldVerify: true,
reference,
message: 'All retries failed. Verify the transaction status before retrying.',
};
}
Key principles:
- Same reference on every retry. Paystack will reject a duplicate reference if the first charge was already created. This prevents double charges.
- Only retry on network errors. If Paystack returns a 400 (bad request), the request itself is wrong. Retrying will not help. If Paystack returns a 500 (server error), the charge may have been created on their side. Verify before retrying.
- Exponential backoff. Wait 2 seconds, then 4, then 8 between retries. This gives the network time to recover and avoids hammering Paystack's API during an outage.
- Always verify after failure. If all retries fail, do not assume the payment did not go through. Call the Paystack Verify Transaction endpoint with the reference to check.
Handling Dropped Connections Mid-Payment
The worst scenario: the customer taps "Pay," the connection drops, and neither the customer nor your frontend knows what happened. Here is how to handle this.
On the client side:
// Client-side payment with connection-drop recovery
async function processPayment(orderId, amount) {
const reference = `order-${orderId}-${Date.now()}`;
// Save the reference locally before making the request
localStorage.setItem('pending_payment_ref', reference);
localStorage.setItem('pending_payment_order', orderId);
showLoadingState('Processing payment...');
try {
const response = await fetch('/api/charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId, amount, reference }),
signal: AbortSignal.timeout(45000),
});
const data = await response.json();
if (data.success) {
showMpesaPrompt('Check your phone for the M-Pesa prompt');
startPollingForConfirmation(reference);
} else {
showError(data.message);
}
} catch (error) {
if (error.name === 'AbortError' || error.name === 'TypeError') {
// Network error or timeout
showRecoveryUI(reference);
} else {
showError('Something went wrong. Please try again.');
}
}
}
// When the app loads, check for pending payments
function checkForPendingPayments() {
const pendingRef = localStorage.getItem('pending_payment_ref');
const pendingOrder = localStorage.getItem('pending_payment_order');
if (pendingRef) {
// Ask the server to check the status
verifyPaymentStatus(pendingRef, pendingOrder);
}
}
async function verifyPaymentStatus(reference, orderId) {
try {
const response = await fetch(`/api/verify-payment/${reference}`);
const data = await response.json();
if (data.status === 'success') {
showSuccess('Your payment was successful!');
localStorage.removeItem('pending_payment_ref');
localStorage.removeItem('pending_payment_order');
} else if (data.status === 'pending') {
showPendingUI('Your payment is still being processed. We will notify you.');
} else {
showRetryUI('Your previous payment did not go through. Would you like to try again?');
localStorage.removeItem('pending_payment_ref');
localStorage.removeItem('pending_payment_order');
}
} catch {
// Still offline. Will check again next time the app loads.
}
}
// Check on app load
checkForPendingPayments();
The pattern is simple: before making the payment request, save the reference locally. When the app loads (or comes back online), check if there is a pending reference and verify its status. This handles the case where the customer's connection drops, they close the browser, and come back hours later.
On the server side:
// Verification endpoint
app.get('/api/verify-payment/:reference', async (req, res) => {
const { reference } = req.params;
// Check your database first
const order = await getOrderByReference(reference);
if (order?.status === 'paid') {
return res.json({ status: 'success' });
}
// If not yet marked as paid, check with Paystack
try {
const paystackRes = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await paystackRes.json();
if (data.data?.status === 'success') {
// Payment went through but webhook might not have arrived yet
await updateOrderStatus(order.id, 'paid', {
paystackRef: reference,
verifiedManually: true,
});
return res.json({ status: 'success' });
}
if (data.data?.status === 'pending' || data.data?.status === 'ongoing') {
return res.json({ status: 'pending' });
}
return res.json({ status: 'failed' });
} catch {
return res.json({ status: 'unknown' });
}
});
This verification endpoint closes the loop. Even if the webhook was delayed or the client lost connection, the customer can always check whether their payment went through.
Offline-Tolerant Payment Flows
True offline payment is not possible with Paystack (you need a network connection to initiate a charge), but you can design flows that tolerate intermittent connectivity.
Pattern 1: Queue orders offline, pay when online.
Let the customer browse products and build a cart while offline (service workers and cached product data). When they go to checkout, queue the order locally. When connectivity returns, submit the order and initiate the payment. This is useful for mobile apps used by sales agents in rural areas.
Pattern 2: Pre-generated payment links.
If you know what the customer will pay (a fixed monthly subscription, a known invoice), generate the Paystack payment link while you have connectivity. Store the link. Share it with the customer (via SMS, WhatsApp, or in-app) even if connectivity is intermittent. The customer only needs connectivity at the moment they tap the link and complete payment.
Pattern 3: USSD fallback.
USSD works on 2G without a data connection. If your customer is in an area with no data but has GSM signal, they can initiate payment through a USSD flow that triggers the Paystack charge on your server. See USSD Plus Paystack: Reaching Feature Phone Users.
Pattern 4: STK push as the offline-friendly option.
M-Pesa STK push is delivered over the GSM network, not over the internet. The customer does not need a data connection to receive and respond to the push. They only need GSM signal. Your server needs connectivity to call the Paystack API, but the customer does not. This is one reason M-Pesa works where card payments do not: the customer's side of the transaction is GSM-based.
SMS Fallback for Payment Confirmation
Push notifications, email, and in-app messages all require a data connection to reach the customer. SMS does not. It works on 2G, on feature phones, and in areas with minimal coverage.
M-Pesa already sends the customer an SMS when they complete a payment. But that SMS confirms the M-Pesa transaction, not your order. The customer knows they paid, but they do not know if your system received and processed the payment.
Sending your own SMS confirmation closes that gap:
// Send SMS confirmation after successful payment
// Using Africa's Talking SMS API as an example
async function sendSmsConfirmation(phone, orderId, amount) {
const message = `Payment of KES ${amount.toLocaleString()} received for order ${orderId}. Thank you for your purchase.`;
try {
const response = await fetch('https://api.africastalking.com/version1/messaging', {
method: 'POST',
headers: {
apiKey: process.env.AT_API_KEY,
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
username: process.env.AT_USERNAME,
to: phone,
message,
from: process.env.AT_SENDER_ID || '',
}),
});
const data = await response.json();
return { success: true, data };
} catch (error) {
console.error('SMS sending failed:', error);
// SMS failure should not block the payment flow
return { success: false, error };
}
}
Important details about SMS in the Kenyan context:
- SMS costs money. Africa's Talking, Twilio, and other providers charge per SMS. Factor this into your unit economics. For a KES 100 transaction, a KES 1 SMS cost matters.
- Sender ID registration. To send SMS with a branded sender ID (your company name instead of a random number), you need to register with the Communications Authority of Kenya and your SMS provider. This takes time. Plan ahead.
- SMS is not instant. On congested networks, SMS delivery can take 5 to 30 seconds. Occasionally longer. Do not promise "instant SMS confirmation."
- Do not put sensitive data in SMS. Transaction amount and order ID are fine. Full card numbers, account balances, or detailed purchase histories are not.
- SMS failure should not block your payment flow. If the SMS fails to send, the payment is still valid. Log the failure and move on. You can retry the SMS later or let the customer check their order status in the app.
Loading States and Progress Indicators
On a fast connection, the user taps "Pay" and sees a result in 2 seconds. On a slow connection, 15 to 30 seconds is normal. Without proper feedback, the customer assumes the app is broken and taps "Pay" again, potentially triggering a double charge.
Design your loading states for the slowest connection your users will have:
- Immediately disable the Pay button. The moment the customer taps it, disable it and change the label to "Processing..." This prevents double-taps.
- Show a spinner or progress indicator within 100ms. The customer needs visual feedback that something is happening.
- After 5 seconds, add text. "Connecting to payment service..." gives the customer confidence that the app is working, not frozen.
- After 15 seconds, update the text. "Waiting for M-Pesa prompt on your phone..." (if using M-Pesa) or "Still processing, please wait..." This tells the customer the delay is expected.
- After 30 seconds, offer options. "Taking longer than expected. You can wait, or check your M-Pesa messages to see if you already received the prompt." Give the customer agency without abandoning the flow.
- After 60 seconds, suggest verification. "If you already entered your M-Pesa PIN, your payment may have gone through. Tap here to check." Link to the verification flow.
// Progressive loading states
function showPaymentProgress(stage) {
const messages = {
initiated: 'Processing your payment...',
connecting: 'Connecting to payment service...',
waiting_stk: 'Check your phone for the M-Pesa prompt. Enter your PIN to complete.',
slow: 'Still waiting. This sometimes takes a moment on slower networks.',
very_slow: 'Taking longer than usual. If you entered your PIN, your payment may be processing.',
check: 'You can check your payment status or try again.',
};
updateUI(messages[stage]);
}
// Timer-based progression
function startPaymentTimer() {
showPaymentProgress('initiated');
setTimeout(() => showPaymentProgress('connecting'), 3000);
setTimeout(() => showPaymentProgress('waiting_stk'), 8000);
setTimeout(() => showPaymentProgress('slow'), 20000);
setTimeout(() => showPaymentProgress('very_slow'), 35000);
setTimeout(() => showPaymentProgress('check'), 60000);
}
These timers should be cancelled when the payment succeeds. If the webhook or polling detects a successful payment at second 12, immediately show the success state. Do not keep showing "Still waiting..." after the payment has already completed.
Lean Payloads and Mobile Data Costs
Kenyan customers on prepaid data plans pay for every byte. A checkout page that loads 3MB of JavaScript, fonts, and images costs them money before they even start paying you. Respect their data.
Practical optimizations:
- Compress everything. Enable gzip or Brotli compression on your server. A 200KB JavaScript bundle compresses to 40KB. That is 160KB saved per page load.
- Lazy load non-essential assets. The checkout page needs the payment form. It does not need the hero image, the footer, or the live chat widget. Load those after the critical content is rendered, or skip them entirely on slow connections.
- Minimize API response payloads. If your API returns the full order object with 20 fields when the checkout only needs 5, you are wasting bandwidth. Create lean response shapes for mobile clients.
- Use system fonts. Loading a custom web font costs 50 to 200KB. On a checkout page, system fonts are fine. The customer is not there to admire your typography. They are there to pay.
- Optimize images. If you show product images on the checkout, use WebP format and appropriate sizes. A 1MB product image scaled down to 100x100px in CSS is a waste. Serve a 100x100px image instead.
- Consider a lightweight checkout. Paystack's hosted checkout page is already optimized. If you are building a custom checkout, benchmark its weight against Paystack's hosted page. If yours is heavier, you are hurting your conversion rate on slow networks.
You can detect slow connections on the client and adjust accordingly:
// Detect connection quality and adapt
function getConnectionQuality() {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (!connection) return 'unknown';
// effectiveType: 'slow-2g', '2g', '3g', '4g'
const type = connection.effectiveType;
if (type === 'slow-2g' || type === '2g') return 'low';
if (type === '3g') return 'medium';
return 'high';
}
// Adapt the checkout based on connection quality
function renderCheckout() {
const quality = getConnectionQuality();
if (quality === 'low') {
// Minimal checkout: no images, no animations, system fonts
renderMinimalCheckout();
} else if (quality === 'medium') {
// Standard checkout with compressed images
renderStandardCheckout();
} else {
// Full experience
renderFullCheckout();
}
}
CDN and Edge Considerations
Where your servers are located matters for latency. If your backend is hosted in US-East and your customer is in Nairobi, every API call travels across the Atlantic and back. That is 200 to 400ms of latency per request, on top of whatever processing time the request takes.
Options for reducing latency:
- Host in a region close to Kenya. AWS, GCP, and Azure all have data centers in Africa or the Middle East. AWS has an Africa (Cape Town) region. Google Cloud has a Johannesburg region. Hosting there cuts latency to East African users significantly compared to US or European hosting.
- Use a CDN for static assets. Cloudflare, Vercel Edge, and AWS CloudFront have Points of Presence in Nairobi or nearby. Static assets (JavaScript, CSS, images) served from a Nairobi PoP load faster than the same assets served from Europe.
- Edge functions for API routes. If your checkout page calls your own API before calling Paystack, running that API on Vercel Edge Functions, Cloudflare Workers, or AWS Lambda@Edge puts your code closer to the customer. The call to Paystack still goes to Paystack's servers, but the overhead of your own processing happens closer to the user.
- Paystack's own infrastructure. Paystack's API servers are optimized for African traffic. You do not control their latency, but you can minimize the latency of everything around the Paystack calls.
A practical improvement: if your checkout flow makes three sequential calls (load cart from your API, initialize transaction on Paystack, redirect to checkout), and each call takes 300ms on a good connection, that is 900ms minimum. On a 3G connection with 500ms latency per hop, it becomes 2.5 seconds. Reduce the number of sequential calls by combining them or pre-computing values.
Testing for Bad Network Conditions
You cannot ship reliable software for bad networks if you only test on your office WiFi. Here is how to test properly.
- Chrome DevTools Network Throttling. Open DevTools, go to the Network tab, and select "Slow 3G" or "Fast 3G" from the throttling dropdown. This simulates slow connections. Test your entire checkout flow at this speed.
- Custom throttling profiles. Chrome lets you create custom profiles. Set download to 100 Kbps, upload to 50 Kbps, and latency to 1000ms. This simulates a congested 2G connection. If your checkout works at this speed, it works anywhere.
- Real device testing. Emulation is not perfect. Test on an actual Android phone (a budget model like the ones many Kenyans use) on a real mobile network. Go outside your office. Try the checkout in a matatu. Try it in a basement. These are real usage scenarios.
- Connection interruption testing. Turn airplane mode on and off during the payment flow. What happens? Does the app recover? Does it show the right error? Does it let the customer check their payment status?
- Automated testing with network conditions. Playwright and Cypress support network throttling in tests. Add slow-network tests to your CI pipeline.
Make slow-network testing a regular part of your QA process, not a one-time exercise. Every feature that touches the payment flow should be tested on slow networks before it ships.
Further Reading
For related topics on building reliable payment systems in Kenya:
- Building an M-Pesa Fallback When Paystack Is Unavailable covers what to do when Paystack itself has an outage.
- M-Pesa STK Push Timeout Handling Compared Across Providers compares how different gateways handle the STK push timeout problem.
- USSD Plus Paystack: Reaching Feature Phone Users covers reaching customers who cannot use a web checkout at all.
- Paystack in Kenya: M-Pesa, Pesalink, and the Daraja Question is the hub article for all Kenya-specific topics.
Building for African network conditions is a core skill. The McTaba 26-week bootcamp teaches you to build real products that work for real users on real networks in Kenya and across Africa.
Key Takeaways
- ✓Kenyan network conditions are not an edge case. A significant portion of your users are on 2G/3G, have intermittent connectivity, or experience high latency. Build for this from day one.
- ✓Set M-Pesa STK push timeouts to at least 30 seconds on the server side. The customer needs time to receive the push, read it, and enter their PIN. Timing out too early means failed payments that were about to succeed.
- ✓Every payment request must be idempotent. Use unique transaction references so that retrying a failed request never results in a double charge.
- ✓Design your UI to survive connection drops mid-payment. The customer taps "Pay," loses connection, and comes back later. Your system should be able to tell them whether the payment went through.
- ✓SMS confirmation is a reliable fallback when push notifications, email, and in-app messages cannot reach the customer. M-Pesa itself sends an SMS, so consider SMS for your own order confirmation too.
- ✓Lean payloads and lazy loading reduce data consumption. For customers on limited data bundles, every kilobyte you save is money you save them.
Frequently Asked Questions
- What timeout should I set for M-Pesa STK push through Paystack?
- Set your server-side timeout for the Paystack Charge API call to at least 45 to 60 seconds. The STK push is delivered over the GSM network, and the customer needs time to receive it, read it, and enter their PIN. On slow networks, the push can take 10 to 15 seconds just to arrive. Timing out too early results in payments that fail on your end but succeed on M-Pesa.
- How do I prevent double charges on slow networks?
- Use a unique transaction reference for every payment attempt. Disable the Pay button immediately after the first tap. If you need to retry a failed request, use the same reference. Paystack will reject the duplicate, preventing a double charge. On your server, check if an order is already paid before processing a new charge.
- Should I build my own offline mode for payments?
- You cannot process a Paystack payment without a network connection. But you can design your app to be useful offline: let customers browse products, build carts, and save orders locally. When connectivity returns, submit the order and initiate payment. For the payment step itself, a network connection is required.
- How much data does a Paystack checkout page use?
- Paystack's hosted checkout page is optimized and relatively lightweight. If you are building a custom checkout, aim for under 200KB total (compressed) for the checkout page. Test the actual data usage by checking the Network tab in Chrome DevTools. Compare your custom checkout against Paystack's hosted page.
- Is SMS confirmation necessary if M-Pesa already sends one?
- M-Pesa sends the customer a transaction confirmation SMS, but it confirms the M-Pesa payment, not your order. The customer knows they paid, but they do not know if your system processed the order. Sending your own SMS confirmation closes that gap. Whether the cost is justified depends on your product and transaction values.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon