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.
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:
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.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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:
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:
Dangerous to retry:
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.
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.
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:
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).
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:
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course