Why Save Kept Spinning: Request Timeouts and Offline States
You tap Save on the checkout address form, the button starts spinning, and nothing happens for twenty seconds. The customer is trapped staring at a loader with no explanation, no error message, and no way to proceed.
Every best-effort request gets its own timeout. A required action must never wait on an optional one.
This is Day 9 of Ship Native, a standalone production story from our mobile apps. Yesterday in Day 8: Why My Form Forgot the Input and Sent It Twice, we covered field-level state and button guards. Today we investigate what happens when third-party network requests stall in the background, why Axios defaults to waiting indefinitely, and how to scope timeouts so optional lookups never block essential customer checkout flows.
The companion video features schematic diagrams, synthetic narration, and timed bilingual subtitles. The code examples below represent real production fixes tested across high-traffic mobile releases.
The symptom: a frozen Save button
In our e-commerce application Podium, customers saving a delivery address experienced intermittent twenty-second freezes on the checkout screen. The button would enter its loading state and remain disabled indefinitely.
Mobile users on fluctuating LTE or 3G connections were hit hardest. When customers tapped Save in a basement or while traveling, the app appeared completely frozen. Many customers force-closed the application or abandoned their cart entirely.
The mechanism: sequential await chaining and default timeouts
Inspecting the checkout codebase revealed two sequential asynchronous steps executed whenever the user pressed Save:
- Step 1: Verify the national address code against an external postal provider (Saudi Post / SPL).
- Step 2: Post the complete address record to our own database and return the newly created address ID.
The original implementation chained both calls with await:
const handleSave = async () => {
setIsSubmitting(true);
try {
// Step 1: External third-party lookup
const lookup = await verifyNationalAddress(nationalAddressCode);
// Step 2: Primary internal mutation
await saveCustomerAddress({ ...values, verifiedDetails: lookup });
navigation.navigate('Payment');
} catch (error) {
showToast('Failed to save address');
} finally {
setIsSubmitting(false);
}
};
This implementation contained two severe architectural flaws:
First, Axios instances default to timeout: 0. A timeout value of zero instructs the HTTP client to wait forever until the underlying operating system socket closes. On mobile operating systems, TCP keep-alive and network interface timeouts can keep an unacknowledged socket alive for thirty to sixty seconds before failing.
Second, the verification call was an optional enrichment step, but it was chained sequentially before the required save operation. The app could safely deliver the order with an unverified address, yet an outage at the third-party postal authority completely blocked the user from completing their purchase.
The production fix: scoping a 7-second timeout (Podium commit c74f4f4)
In Podium commit c74f4f4, we resolved the freeze by separating required mutations from best-effort lookups and capping the external request with a strict 7-second timeout.
Here is the exact API configuration pattern from features/checkout/api/checkoutApi.ts:
import axios from 'axios';
import type { AxiosInstance } from 'axios';
// Evidence from Podium commit c74f4f4:
// "fix(checkout): cap national-address verify at 7s so a hung SPL lookup can't freeze Save"
//
// In features/checkout/api/checkoutApi.ts:
// A format-invalid address is rejected fast (~460ms).
// A format-valid one triggers the external Saudi-Post (SPL) lookup,
// which can hang to the 20s axios default and freeze Save.
// The check is best-effort (checkout continues on failure), so scope a 7s timeout to it.
export interface NationalAddressPayload {
national_address: string;
}
export interface NationalAddressResponse {
valid: boolean;
street?: string;
district?: string;
city?: string;
}
export const createCheckoutApi = (client: AxiosInstance) => ({
verifyNationalAddress: (nationalAddress: string) =>
client.request<NationalAddressResponse>({
method: 'post',
url: '/api/v1/checkout/verify-national-address',
data: { national_address: nationalAddress },
// Best-effort check (outage -> we continue anyway), so it must fail fast:
// a format reject returns in <1s, but the external SPL lookup can
// hang to the 20s default and freeze the Save button. Cap it short.
timeout: 7000,
}),
saveAddress: (addressData: Record<string, unknown>) =>
client.request({
method: 'post',
url: '/api/v1/checkout/addresses',
data: addressData,
// Required mutation keeps normal network timeout + idempotency
timeout: 15000,
}),
});
Why 7 seconds?
When the address format is syntactically invalid (for example, missing digits), the postal service rejects the request immediately in under 500 milliseconds. When the postal service is healthy, normal responses return within 1 to 2 seconds even on high-latency mobile data.
When the third-party gateway stalls or experiences a backend database outage, waiting 20 or 30 seconds only punishes your customer. Capping the lookup at 7,000 milliseconds gives a healthy connection sufficient time to resolve while ensuring a stalled external dependency is cleanly aborted before the customer abandons the purchase.
Building graceful fallbacks and honest copy
Aborting the hung request after 7 seconds is only half the solution. You must define what happens to the user experience next.
When a network request fails or times out, mobile applications must clearly differentiate between two failure modes:
- Validation failure: If the postal service responds with an explicit rejection indicating that the building number does not exist, block the save immediately and show an inline error beside the national address field.
- Gateway timeout or outage: If the request aborts with an Axios
ECONNABORTEDerror or returns a 503 Service Unavailable, log the failure internally, notify the user with a non-blocking toast, and proceed with saving the primary address.
Here is the complete React Native hook and button implementation handling both paths:
import React, { useState, useCallback } from 'react';
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
export type NationalAddressVerdict = 'ok' | 'invalid' | 'unavailable';
export interface AddressFormValues {
name: string;
phone: string;
nationalAddress: string;
street: string;
cityId: number;
}
interface UseAddressSaveOptions {
verifyAddress: (code: string) => Promise<NationalAddressVerdict>;
persistAddress: (data: AddressFormValues) => Promise<{ id: number }>;
onSuccess: (id: number) => void;
showToast: (msg: { type: 'info' | 'error' | 'success'; text: string }) => void;
}
export const useAddressSave = ({
verifyAddress,
persistAddress,
onSuccess,
showToast,
}: UseAddressSaveOptions) => {
const [saving, setSaving] = useState(false);
const [nationalError, setNationalError] = useState<string>();
const submitAddress = useCallback(
async (values: AddressFormValues) => {
if (saving) return;
setSaving(true);
setNationalError(undefined);
try {
const national = values.nationalAddress.trim();
// 1. Best-effort verification: only if code is provided
if (national) {
const verdict = await verifyAddress(national);
if (verdict === 'invalid') {
// Local / schema rejection: block the save, tell user
setNationalError('National address format is not valid');
setSaving(false);
return;
}
if (verdict === 'unavailable') {
// 7s timeout or remote 5xx: warn and proceed anyway!
showToast({
type: 'info',
text: 'Address service unavailable. Saving without verification.',
});
}
}
// 2. Required mutation: proceed to save
const result = await persistAddress(values);
showToast({ type: 'success', text: 'Address saved successfully.' });
onSuccess(result.id);
} catch (err) {
showToast({ type: 'error', text: 'Failed to save address. Please retry.' });
} finally {
setSaving(false);
}
},
[saving, verifyAddress, persistAddress, onSuccess, showToast],
);
return { submitAddress, saving, nationalError };
};
export const SaveAddressButton: React.FC<{
loading: boolean;
disabled?: boolean;
onPress: () => void;
}> = ({ loading, disabled, onPress }) => (
<TouchableOpacity
style={[styles.button, (disabled || loading) && styles.buttonDisabled]}
disabled={disabled || loading}
onPress={onPress}
>
{loading ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={styles.buttonText}>Save Address</Text>
)}
</TouchableOpacity>
);
const styles = StyleSheet.create({
button: {
backgroundColor: '#22c55e',
paddingVertical: 14,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
buttonDisabled: {
backgroundColor: '#3f4b5b',
},
buttonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
});
Notice the user copy: "National address verification timed out. We saved your address anyway."
This provides honest transparency. The user understands that their address was saved successfully, knows why the verification badge is absent, and is not left wondering whether their payment will proceed.
What a client timeout does not prove
There is a critical systems limitation every mobile developer must remember:
A client-side timeout only proves that the mobile app stopped waiting. It does not prove that the remote server stopped executing the mutation.
When Axios triggers an abort or timeout on the mobile device, it tears down the local HTTP connection. However, the initial HTTP packet may have already reached your backend server. If the request was a database mutation (such as creating an order, charging a card, or inserting an address record), the server might continue writing that row to disk while the client has already given up.
If your application automatically retries the request following a timeout, you risk creating duplicate database rows or charging the customer twice unless your backend implements idempotent operations.
Here is how client timeouts interact with server idempotency:
// Production Case Study: Why Save Kept Spinning
// Source: Podium Checkout flow (Saudi Post SPL verification)
// ==========================================
// BEFORE (Buggy Pattern):
// ==========================================
// The Save button triggers verifyAddress.
// verifyAddress uses Axios default timeout (0 / none).
// When third-party SPL drops the connection or hangs,
// the promise remains pending for 20-60+ seconds.
// Because the UI disabled the button while awaiting this promise,
// the customer is completely locked out of completing checkout.
export const buggyAddressSave = async (
values: { nationalCode: string; street: string },
api: { post: (url: string, data: unknown) => Promise<unknown> },
) => {
// Freezes for 20+ seconds if SPL hangs!
await api.post('/verify-spl', { code: values.nationalCode });
await api.post('/save-address', values);
};
// ==========================================
// AFTER (Resilient Pattern):
// ==========================================
// 1. Differentiate required vs best-effort calls.
// 2. Cap best-effort call with a 7-second timeout.
// 3. Catch timeout or outage, notify the user, and proceed with save.
export const resilientAddressSave = async (
values: { nationalCode: string; street: string },
api: { post: (url: string, data: unknown, config?: { timeout?: number }) => Promise<unknown> },
notify: (msg: string) => void,
) => {
try {
// 7s timeout specifically for the third-party lookup
await api.post('/verify-spl', { code: values.nationalCode }, { timeout: 7000 });
} catch (err: unknown) {
// Check if format error vs timeout/outage
const isFormatError = (err as { response?: { status?: number } })?.response?.status === 422;
if (isFormatError) {
throw new Error('Please check your national address code.');
}
// Remote outage or timeout: notify and continue!
notify('Address service uncontactable. Proceeding with unverified address.');
}
// Required action proceeds without waiting indefinitely on optional service
await api.post('/save-address', values, { timeout: 15000 });
};
Always pair client-side timeout handling with backend idempotency keys on every mutating request.
Production checklist
Before releasing any mobile flow that relies on network requests, run through this verification checklist:
- Categorize every request: Determine whether the call is required (the screen cannot finish without it) or best-effort (provides optional enrichment).
- Never let required actions await optional dependencies: Run best-effort lookups in parallel or allow the user to bypass them during external downtime.
- Replace the Axios default timeout: Never leave
timeout: 0in production. Scope tight timeouts (3 to 7 seconds) to lightweight third-party APIs. - Provide non-blocking fallback states: Show clear, polite messaging that explains what was skipped and allows the customer to move forward.
- Protect retries with idempotency keys: Ensure duplicate requests triggered by timeouts or retries are deduplicated safely on your backend.
Summary
Next up is Day 10: Why Photo Uploads Crash Low-Memory Devices, where we explore camera image sizes, out-of-memory crashes on Android, and how to downsample images before upload.
Comments