Handling Slow Networks in Paystack Checkout Flows
To handle slow networks in Paystack checkout flows, set generous timeouts on your API calls (at least 30 seconds for initialize, longer for verify), show meaningful loading states that tell the customer something is happening, implement retry logic with exponential backoff for server-to-Paystack calls, and never assume a timeout means failure because the payment may still complete.
The Network Reality in African Markets
If you are building payment flows for customers in Nigeria, Kenya, Ghana, or South Africa, you are building for network conditions that most payment tutorials do not account for.
A typical scenario: a customer on Safaricom 3G in Nairobi clicks "Pay." The request from their phone to your server takes 2 seconds. Your server calls Paystack's API, which takes another 1.5 seconds. Paystack talks to the bank, which takes 3 seconds. The response travels back through the same chain. Total time: 8-12 seconds. During those 12 seconds, the customer sees nothing unless you planned for it.
Things that happen regularly on African mobile networks:
- Latency spikes from 200ms to 3000ms mid-request
- Complete connection drops lasting 5-30 seconds when moving between cell towers
- Throttled connections during peak hours (evenings, end of month)
- DNS resolution failures that look like server errors
- TCP connections that succeed but then stall during data transfer
Your checkout flow needs to handle all of these gracefully. Not with retry-until-crash logic, but with deliberate timeout configuration, meaningful user feedback, and safety checks that prevent duplicate charges.
Timeout Configuration
Set your timeouts based on the slowest networks your customers use, not the fastest ones you develop on.
Server-side timeouts (your server calling Paystack)
// Initialize transaction with a generous timeout
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 30000); // 30 seconds
try {
var response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
amount: amount,
reference: reference,
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
var data = await response.json();
// Process response
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
// Timeout: the request may or may not have reached Paystack
// Do NOT assume it failed. Check if the reference exists.
console.error('Initialize timed out for reference: ' + reference);
} else {
// Network error
console.error('Network error:', err.message);
}
}
Why 30 seconds? The Paystack API itself responds quickly (usually under 2 seconds), but the network path between your server and Paystack can add significant latency. If your server is in Europe and Paystack is in Lagos, the round trip already takes 200-400ms. Add network congestion and you can easily hit 5-10 seconds for a single request. A 30-second timeout gives you headroom for the worst cases without waiting forever.
Client-side timeouts (customer's browser to your server)
The customer's device to your server is the slowest link in the chain. Set a generous client-side timeout and show a loading state:
async function initializePayment(email, amount) {
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 45000); // 45 seconds
try {
var response = await fetch('/api/payment/initialize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, amount: amount }),
signal: controller.signal,
});
clearTimeout(timeoutId);
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
return { error: 'The connection is slow. Please check your network and try again.' };
}
return { error: 'Could not connect. Please check your internet connection.' };
}
}
Verify endpoint timeout: Set verify timeouts slightly longer than initialize timeouts. If the verify call times out, you must not tell the customer their payment failed. The payment may have succeeded. Check the Transaction Timeline API or wait for the webhook.
Loading States That Prevent Abandonment
A spinner with no context is anxiety-inducing when you are about to spend money. After 5 seconds of a generic spinner, most customers assume something went wrong. After 10 seconds, they close the tab.
Progressive status messages:
Instead of a single spinner, show messages that progress as time passes. This tells the customer that things are happening, even if the network is slow:
function showProgressiveLoading(containerId) {
var messages = [
{ delay: 0, text: 'Connecting to payment gateway...' },
{ delay: 3000, text: 'Setting up your checkout...' },
{ delay: 8000, text: 'Almost ready. Slow network detected...' },
{ delay: 15000, text: 'Still working. Please do not close this page...' },
{ delay: 25000, text: 'This is taking longer than usual. Please wait...' },
];
var container = document.getElementById(containerId);
var timeouts = [];
for (var i = 0; i < messages.length; i++) {
(function(msg) {
var tid = setTimeout(function() {
container.textContent = msg.text;
}, msg.delay);
timeouts.push(tid);
})(messages[i]);
}
// Return a cleanup function
return function cleanup() {
for (var j = 0; j < timeouts.length; j++) {
clearTimeout(timeouts[j]);
}
};
}
// Usage
var cleanup = showProgressiveLoading('loading-message');
var result = await initializePayment(email, amount);
cleanup();
The "please do not close this page" message is important. On slow networks, the natural instinct is to close and try again. But if the initialization request is in flight, closing the page and trying again can create a duplicate transaction (if you generate a new reference) or the customer returns to a stale state.
What not to show:
- Technical details ("HTTP request in progress" or "Waiting for API response"). Customers do not know what an API is and should not need to.
- Percentage progress bars. You do not actually know what percentage of the process is done. A progress bar stuck at 45% for 20 seconds is worse than no progress bar at all.
- Nothing at all. A blank page or a frozen screen is the worst option. Always show something.
Retry Logic for Server-Side Calls
When your server calls Paystack and gets a network error (not an API error), retrying makes sense. But retrying carelessly creates duplicate transactions, so the rules are strict.
Safe to retry:
- Initialize Transaction calls (Paystack uses the reference for idempotency, so the same reference produces the same transaction)
- Verify Transaction calls (read-only, no side effects)
- List/fetch calls (read-only)
Dangerous to retry:
- Charge API calls without a reference (could create duplicate charges)
- Refund calls (could issue duplicate refunds)
- Any write operation without idempotency
async function fetchWithRetry(url, options, maxRetries) {
var attempt = 0;
var lastError = null;
while (attempt < maxRetries) {
try {
var controller = new AbortController();
var timeoutId = setTimeout(function() { controller.abort(); }, 30000);
var fetchOptions = Object.assign({}, options, { signal: controller.signal });
var response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
// If Paystack returned a response (even an error), do not retry
// API errors (400, 401, etc.) are not network issues
return response;
} catch (err) {
clearTimeout(timeoutId);
lastError = err;
attempt++;
if (attempt < maxRetries) {
// Exponential backoff: 1s, 2s, 4s
var delay = Math.pow(2, attempt - 1) * 1000;
await new Promise(function(resolve) { setTimeout(resolve, delay); });
}
}
}
throw lastError;
}
// Usage: retry initialize up to 3 times
var response = await fetchWithRetry(
'https://api.paystack.co/transaction/initialize',
{
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: email, amount: amount, reference: reference }),
},
3
);
Notice the retry only fires on network errors (the catch block). If Paystack returns a 400 or 500 response, that is an API response and retrying will not help. Handle it as a business error.
Preventing Double-Clicks and Double-Submits
On a slow connection, the customer clicks "Pay," nothing visible happens for 3 seconds, and they click again. If each click fires a new request with a new reference, you might initialize two separate transactions. If they complete both (unlikely but possible), you have a duplicate charge.
Client-side: disable the button
var payButton = document.getElementById('pay-button');
payButton.addEventListener('click', async function() {
// Disable immediately
payButton.disabled = true;
payButton.textContent = 'Processing...';
try {
var result = await initializePayment(email, amount);
if (result.authorization_url) {
window.location.href = result.authorization_url;
} else {
// Re-enable on error so they can try again
payButton.disabled = false;
payButton.textContent = 'Pay ' + formatCurrency(amount, currency);
showError(result.error);
}
} catch (err) {
payButton.disabled = false;
payButton.textContent = 'Pay ' + formatCurrency(amount, currency);
showError('Something went wrong. Please try again.');
}
});
Server-side: use deterministic references
Even if the client double-submits, a deterministic reference means both requests create the same Paystack transaction:
// Same order always gets the same reference
var reference = 'order_' + orderId;
// If the client sends this twice, Paystack returns the same transaction both times
The combination of client-side button disabling and server-side deterministic references gives you two layers of protection. The button prevents most double-clicks. The reference prevents the rest.
A Timeout Does Not Mean Failure
This is the most important concept in this entire guide. When a network request to Paystack times out, you do not know what happened. Three scenarios are all possible:
- The request never reached Paystack. No transaction exists. Safe to retry.
- The request reached Paystack, but the response was lost. The transaction was created. Retrying with the same reference is safe (Paystack returns the existing transaction). Retrying with a new reference creates a duplicate.
- The request reached Paystack and the charge is in progress. The customer's bank is processing the charge. The payment might succeed in a few seconds.
What to do after a timeout:
async function handleInitializeTimeout(reference) {
// Wait a moment, then check if the transaction exists
await new Promise(function(resolve) { setTimeout(resolve, 3000); });
try {
var response = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var data = await response.json();
if (data.status && data.data) {
// Transaction exists on Paystack
if (data.data.status === 'success') {
return { status: 'paid', data: data.data };
}
if (data.data.status === 'abandoned') {
return { status: 'retry', message: 'Previous attempt was not completed. Please try again.' };
}
// Transaction exists but is pending or failed
return { status: 'pending', data: data.data };
}
// Transaction does not exist on Paystack. Safe to retry.
return { status: 'retry', message: 'Connection issue. Please try again.' };
} catch (err) {
// Even the verify call timed out. Network is very bad.
return { status: 'unknown', message: 'Network issue. Please check your connection and try again.' };
}
}
Never show "Payment failed" after a timeout. Show "We could not confirm your payment. Checking..." and then verify. If you tell the customer it failed when it actually succeeded, they will either pay again (double charge) or leave angry (lost sale).
Optimizing Payload Size
On a 150kbps connection, every kilobyte matters. A 50KB JavaScript bundle takes 2.7 seconds to download. A 200KB bundle takes 10.7 seconds. Optimize what you send to the client.
Checkout page weight targets:
- HTML: under 10KB
- CSS: under 15KB (only checkout-specific styles, not your entire site CSS)
- JavaScript: under 30KB (excluding Paystack's inline script, which you cannot control)
- Images: zero. The checkout page does not need product images. Show the product name and amount as text.
API response size:
Keep your API responses lean. Your /api/payment/initialize endpoint should return only what the frontend needs (authorization URL, access code, reference), not the entire Paystack response object.
// Lean response from your server
res.json({
url: data.data.authorization_url,
code: data.data.access_code,
ref: data.data.reference,
});
// ~120 bytes instead of the full Paystack response (~2KB)
These optimizations sound small, but they compound. A checkout page that loads in 3 seconds instead of 8 seconds on a slow connection can make a meaningful difference in how many customers complete payment.
Key Takeaways
- ✓A timeout on your initialize or verify call does not mean the payment failed. The request may have reached Paystack successfully. Always check the transaction status before showing an error.
- ✓Set API call timeouts to at least 30 seconds for Paystack endpoints. African network latency can spike to several seconds even on 4G, and the Paystack API itself needs time to process.
- ✓Show progressive loading states, not spinners. "Connecting to payment gateway..." then "Waiting for bank response..." tells the customer something is happening. A generic spinner for 15 seconds feels like something is broken.
- ✓Implement exponential backoff for retries on server-side Paystack API calls. Retry immediately on network errors, but wait longer between each attempt.
- ✓Disable the pay button after the first click and do not re-enable it until the flow completes or fails conclusively. Double-clicks on slow connections cause duplicate transactions.
- ✓Design your checkout to work on 150kbps connections. If it only works on broadband, you are excluding a large portion of African mobile users.
Frequently Asked Questions
- What timeout should I set for Paystack API calls?
- Set at least 30 seconds for initialize and verify calls from your server. For client-side requests (customer browser to your server), set 45 seconds. These seem long, but African mobile networks can have multi-second latency spikes. A timeout that is too short causes failures on slow but functional connections.
- Should I retry Paystack API calls when they timeout?
- Retry only safe operations. Initialize Transaction is safe to retry because Paystack uses the reference for idempotency. Verify is safe because it is read-only. Do not retry charge or refund calls without verifying the state first, as you could create duplicate charges or refunds.
- How do I prevent duplicate charges on slow networks?
- Use two layers. On the client, disable the pay button after the first click and do not re-enable it until you get a definitive result. On the server, use deterministic transaction references tied to the order ID so that duplicate requests create the same Paystack transaction, not separate ones.
- What should I show the customer while the payment is processing on a slow network?
- Show progressive text messages that change over time: "Connecting to payment gateway" at 0 seconds, "Setting up your checkout" at 3 seconds, "Slow network detected, please wait" at 8 seconds, "Still working, please do not close this page" at 15 seconds. Avoid generic spinners with no context.
- How do I test my checkout on slow networks?
- Use Chrome DevTools network throttling set to "Slow 3G" for quick tests, but also test on a real Android phone with a real SIM card. Emulated throttling does not fully replicate real network behavior like connection drops, DNS failures, and TCP stalls. Buy a mid-range phone that matches what your customers use.
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