Browser Timeout & Retry Logic for File Uploads
Implementing robust timeout and retry mechanisms is critical for reliable file transfers in modern web applications. Network instability and backend processing limits frequently interrupt large payload transmissions. This guide covers production-ready strategies for handling interruptions and configuring Upload Fundamentals & Browser APIs correctly.
You will learn how to align client-side resilience with server processing limits. The following patterns ensure deterministic behavior across varying network conditions.
- Configure explicit timeout thresholds for Fetch and legacy XHR requests
- Implement exponential backoff with jitter to prevent server overload
- Persist upload state to IndexedDB for cross-session resumability
- Align client retry budgets with server-side gateway and proxy timeouts
Configuring Timeout Thresholds & Abort Signals
Deterministic timeout boundaries prevent hanging connections and memory leaks. Modern browsers require explicit cancellation signals to release network sockets. You must distinguish between connection establishment, request writing, and response reading phases.
Mobile networks frequently experience latency spikes. A static 30-second threshold works for standard payloads. Larger transfers require dynamic adjustment based on payload size and network type.
/**
* Wraps fetch() with a configurable timeout using AbortController.
* Explicitly handles connection vs. read timeouts and cleans up resources.
*/
async function fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
const controller = new AbortController();
const { signal } = controller;
// Timeout timer
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { ...options, signal });
// Check HTTP status before consuming body
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Optional: Add a read timeout for large responses
const readTimeoutId = setTimeout(() => controller.abort(), timeoutMs);
const data = await response.json();
clearTimeout(readTimeoutId);
return data;
} catch (error) {
if (error.name === 'AbortError') {
console.warn('Request timed out or was aborted:', url);
throw new Error('Upload timeout exceeded', { cause: error });
}
throw error;
} finally {
clearTimeout(timeoutId);
// Ensure controller is cleaned up
if (!signal.aborted) controller.abort();
}
}
Implementing Retry Logic & Exponential Backoff
Blind retries trigger server rate limits and duplicate payloads. Algorithmic queues must respect infrastructure boundaries. You should filter retriable status codes strictly.
Transient 5xx, 408, and 429 responses warrant automatic recovery. Permanent 4xx errors require immediate failure and user notification. Idempotency keys guarantee safe server-side reconciliation.
const RETRIABLE_CODES = new Set([408, 429, 500, 502, 503, 504]);
const MAX_RETRIES = 4;
const BASE_DELAY_MS = 1000;
function calculateBackoff(attempt) {
const exponential = BASE_DELAY_MS * Math.pow(2, attempt);
const jitter = Math.random() * 500; // Prevents thundering herd
return Math.min(exponential + jitter, 15000); // Cap at 15s
}
async function uploadWithRetry(file, uploadUrl, idempotencyKey) {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const formData = new FormData();
formData.append('file', file);
const headers = {
'X-Idempotency-Key': idempotencyKey,
'X-Retry-Count': attempt.toString()
};
return await fetchWithTimeout(uploadUrl, {
method: 'POST',
body: formData,
headers
}, 30000);
} catch (error) {
const isRetriable = error.cause?.name === 'AbortError' ||
RETRIABLE_CODES.has(error.status);
if (!isRetriable || attempt === MAX_RETRIES) {
throw new Error(`Upload failed after ${attempt} attempts: ${error.message}`);
}
const delay = calculateBackoff(attempt);
console.log(`Retrying in ${Math.round(delay)}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Chunked Uploads & State Persistence
Breaking large payloads into segments bypasses gateway limits and isolates failures. You can slice File or Blob objects efficiently using native .slice() methods. This avoids the overhead of Base64 vs Binary Encoding during chunk serialization.
Track completed segments in IndexedDB or localStorage. Send chunk metadata via custom headers to enable precise resume points. The server must validate checksums before finalizing the assembly.
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks
async function uploadChunks(file, uploadUrl, uploadId) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const completedChunks = await getCompletedChunksFromDB(uploadId);
for (let i = 0; i < totalChunks; i++) {
if (completedChunks.includes(i)) continue;
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
const headers = {
'X-Upload-ID': uploadId,
'X-Chunk-Index': i.toString(),
'X-Total-Chunks': totalChunks.toString(),
'Content-Type': 'application/octet-stream'
};
try {
await fetchWithTimeout(uploadUrl, { method: 'POST', body: formData, headers }, 60000);
await saveChunkToDB(uploadId, i);
} catch (error) {
console.error(`Chunk ${i} failed:`, error);
// Pause and resume later, or trigger full retry logic
throw error;
}
}
}
// IndexedDB helper stub
async function getCompletedChunksFromDB(uploadId) {
// Implementation omitted for brevity.
// Returns array of completed chunk indices.
return [];
}
Security Defaults & Error Handling Boundaries
Strict validation prevents malicious payloads from consuming retry budgets. Always verify MIME types and file signatures before initiating transfers. Strip sensitive metadata from error responses before client-side logging.
Circuit breakers halt retries during sustained backend degradation. This protects your infrastructure from cascading failures. Align your client strategy with legacy patterns like Fixing XMLHttpRequest timeout errors for large files when migrating older codebases.
class UploadCircuitBreaker {
constructor(failureThreshold = 5, resetTimeoutMs = 30000) {
this.failureCount = 0;
this.threshold = failureThreshold;
this.resetTimeout = resetTimeoutMs;
this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
this.lastFailureTime = null;
}
async execute(uploadFn) {
if (this.state === 'OPEN') {
const elapsed = Date.now() - this.lastFailureTime;
if (elapsed > this.resetTimeout) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker OPEN: Uploads temporarily disabled');
}
}
try {
const result = await uploadFn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
onSuccess() {
this.failureCount = 0;
this.state = 'CLOSED';
}
onFailure() {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
console.warn('Circuit breaker tripped. Halting uploads.');
}
}
}
Common Pitfalls
Unbounded retry loops on permanent 4xx errors
Retrying client-side validation failures wastes bandwidth and triggers rate limits. Implement strict status code filtering. Only retry 5xx, 408, and 429 responses. Fail fast on 4xx.
Ignoring CORS preflight timeout limits
Browsers apply separate timeout rules to OPTIONS requests. Long preflight delays can abort the main upload before it begins. Keep preflight responses cached via Access-Control-Max-Age. Minimize custom headers to avoid repeated OPTIONS calls.
Memory leaks from uncanceled Blob references
Failed uploads that are not properly aborted retain large File/Blob objects in memory. This causes tab crashes on low-end devices. Always invoke AbortController.abort() in finally blocks. Nullify references to processed chunks immediately after transmission.
FAQ
How do I handle browser network timeouts during large uploads?
Use AbortController with a configurable timeout. Implement chunked transfers to isolate failures. Apply exponential backoff with idempotency keys for safe retries.
Should I retry failed uploads automatically or prompt the user?
Automatically retry transient network errors (5xx/429) with backoff. Prompt the user for permanent failures (4xx) or after exhausting the retry budget. This maintains UX control.
What’s the optimal chunk size to avoid timeout errors?
Start with 5–10MB chunks for standard broadband. Reduce to 1–2MB for mobile networks. Align chunk size with your server’s maximum request size and timeout thresholds.