The Reality of Web Requests on Unstable Connections
In modern web development, developers often assume network requests are binary: they either succeed immediately or fail with a clear HTTP status. However, building web applications for the Syrian market—or any region with developing infrastructure—requires design patterns that accommodate high latency, frequent connection drops, ADSL resets, and transitions between weak mobile data (3G/4G) and local Wi-Fi networks.
If a web application relies purely on direct, online-only fetch() requests, users will inevitably experience:
- Data Loss: Incomplete form submissions, drafts, or transaction entries disappear if the connection drops mid-request.
- Poor UX: Frustrating loading spinners that end in generic timeout errors, forcing the user to repeat their actions.
- Server Inconsistencies: Duplicate requests caused by aggressive client-side retries when a response is lost but the write succeeded on the server.
To solve this, developers can implement a client-side Offline Sync Queue. Instead of sending user actions directly to the server, the application writes the request to a local persistent database first. A background synchronization manager then drains this queue whenever a healthy connection is detected.
Here is a step-by-step technical guide to building a robust offline sync queue in vanilla JavaScript.
---
Architecture of the Sync Queue
The sync engine operates on a local-first buffer model:
+-------------------------+
| User Saves Data |
+-------------------------+
|
v
+-------------------------+
| Is Network Online? |
+-------------------------+
/ \
YES / \ NO / Fetch Failed
v v
+-----------------------+ +-------------------------------+
| Send immediately via | | Save to IndexedDB Sync Queue |
| standard HTTP fetch() | | (HTTP Method, URL, Body, etc.)|
+-----------------------+ +-------------------------------+
| |
v (Success) v
[Done] +-------------------------------+
| Listen for online events / |
| Trigger Background Sync loop |
+-------------------------------+
|
v
+-------------------------------+
| Process Queue Items with |
| Exponential Backoff Retry |
+-------------------------------+
|
v (Success)
+-------------------------------+
| Clear Item from IndexedDB |
+-------------------------------+
We will use IndexedDB for local storage because it is supported in all modern browsers, offers asynchronous non-blocking queries, and can store megabytes of structured data without the storage limits of localStorage.
---
Step 1: Setting Up the IndexedDB Store
First, we need a lightweight, promise-based wrapper to manage our IndexedDB database. This database will store our pending synchronization requests.
class SyncStore {
constructor(dbName = 'OfflineSyncDB', storeName = 'requestQueue') {
this.dbName = dbName;
this.storeName = storeName;
this.db = null;
}
async init() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
// Use auto-incrementing key to preserve insertion order
db.createObjectStore(this.storeName, { keyPath: 'id', autoIncrement: true });
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
async addRequest(method, url, body, headers = {}) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const requestItem = {
method,
url,
body: body instanceof Object ? JSON.stringify(body) : body,
headers,
timestamp: Date.now(),
attempts: 0
};
const addOp = store.add(requestItem);
addOp.onsuccess = () => resolve(addOp.result);
addOp.onerror = (event) => reject(event.target.error);
});
}
async getAllRequests() {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
const getOp = store.getAll();
getOp.onsuccess = () => resolve(getOp.result);
getOp.onerror = (event) => reject(event.target.error);
});
}
async deleteRequest(id) {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
const deleteOp = store.delete(id);
deleteOp.onsuccess = () => resolve();
deleteOp.onerror = (event) => reject(event.target.error);
});
}
}
const syncStore = new SyncStore();
---
Step 2: Enqueuing Failed Requests
Instead of calling fetch() directly throughout your application code, route all state-modifying requests (such as POST, PUT, DELETE) through a custom fetch wrapper. This wrapper checks the current connection status and handles direct execution failures.
async function executeRequest(url, options = {}) {
const method = options.method || 'GET';
const headers = options.headers || {};
const body = options.body || null;
// 1. If explicitly offline, queue immediately
if (!navigator.onLine) {
if (method !== 'GET') {
await syncStore.addRequest(method, url, body, headers);
triggerBackgroundSync();
return { offline: true, queued: true, message: 'Saved offline. Will sync later.' };
}
throw new Error('You are currently offline.');
}
// 2. If online, attempt the call
try {
const response = await fetch(url, options);
// Check for server-side availability issues
if (!response.ok && response.status >= 500) {
throw new Error(`Server error: ${response.status}`);
}
return response;
} catch (error) {
// 3. Catch network errors/timeouts and queue write requests
if (method !== 'GET') {
console.warn('Network request failed. Queueing for offline sync...', error);
await syncStore.addRequest(method, url, body, headers);
triggerBackgroundSync();
return { offline: true, queued: true, message: 'Network error. Saved offline.' };
}
throw error;
}
}
---
Step 3: Background Synchronization with Exponential Backoff
When network connections are weak or unstable, retrying immediately and repeatedly (busy-waiting) will drain client device batteries and overwhelm congested backup connections. We must implement Exponential Backoff with a random jitter factor to space out retry attempts.
let isSyncing = false;
let syncTimeoutId = null;
async function triggerBackgroundSync() {
if (isSyncing) return;
if (syncTimeoutId) {
clearTimeout(syncTimeoutId);
}
// Schedule a sync check
syncTimeoutId = setTimeout(() => {
runSyncLoop();
}, 1000);
}
async function runSyncLoop() {
if (isSyncing || !navigator.onLine) return;
isSyncing = true;
try {
const requests = await syncStore.getAllRequests();
if (requests.length === 0) {
isSyncing = false;
return;
}
console.log(`Processing offline sync queue: ${requests.length} items pending.`);
for (const req of requests) {
// Check again, connection might have dropped during loop execution
if (!navigator.onLine) break;
let success = false;
try {
const response = await fetch(req.url, {
method: req.method,
headers: {
...req.headers,
'Content-Type': 'application/json',
'X-Offline-Sync-Id': req.id // Trace on server
},
body: req.body
});
if (response.ok) {
await syncStore.deleteRequest(req.id);
success = true;
console.log(`Successfully synchronized request #${req.id}`);
} else if (response.status < 500) {
// If it's a 4xx error (e.g. 400 Bad Request, 403 Forbidden),
// retrying won't fix it. Remove from queue or flag for manual review.
await syncStore.deleteRequest(req.id);
console.error(`Rejected request #${req.id} due to client error: ${response.status}`);
}
} catch (err) {
console.warn(`Sync attempt failed for request #${req.id}`, err);
}
if (!success) {
// Stop processing the rest of the queue to maintain request ordering.
// Wait and reschedule sync with exponential backoff.
const backoffDelay = calculateBackoff(req.attempts || 0);
req.attempts = (req.attempts || 0) + 1;
console.log(`Retrying queue in ${backoffDelay / 1000} seconds...`);
isSyncing = false;
syncTimeoutId = setTimeout(runSyncLoop, backoffDelay);
return;
}
}
} catch (error) {
console.error('Error during sync execution:', error);
} finally {
isSyncing = false;
}
}
function calculateBackoff(attempts) {
const base = 1000; // 1 second
const maxDelay = 60000; // 1 minute maximum
const factor = 2;
// Calculate exponential delay: 1s, 2s, 4s, 8s, 16s...
const delay = Math.min(base * Math.pow(factor, attempts), maxDelay);
// Add 0-30% random jitter to prevent synchronized retry thundering herds
const jitter = Math.random() * 0.3 * delay;
return delay + jitter;
}
// Automatically listen for browser connectivity restoration events
window.addEventListener('online', () => {
console.log('Network connectivity restored. Triggering sync...');
triggerBackgroundSync();
});
---
Step 4: Data Conflict Resolution
When working offline, data conflict is inevitable. For example, if an offline field agent updates a customer profile, and an office administrator edits the same customer record online, a conflict occurs when the offline queue attempts to synchronize.
The cleanest client-side approach is to implement a Last-Write-Wins (LWW) strategy using strict client timestamps:
- Every write operation payload should include a
lastUpdatedproperty set to the local device'sDate.now(). - The server compares the incoming
lastUpdatedtimestamp with thelastUpdatedfield currently stored in the database. - If the incoming request has a newer timestamp, the server overwrites the record. If the incoming request has an older timestamp, the server rejects the request but responds with a
200 OK(to satisfy the sync queue) and sends back the latest server-side state to update the client.
Client-Side Payload Structure Example:
async function saveCustomerData(customerId, data) {
const payload = {
...data,
id: customerId,
lastUpdated: Date.now() // Record local modification time
};
const response = await executeRequest(`/api/customers/${customerId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: payload
});
return response;
}
---
Syria-Specific Implementation Guidelines
When optimizing this codebase for operations in Syria, engineers should account for these three infrastructural realities:
1. Unexpected Device Shutdowns (Power Cuts)
In regions experiencing scheduled or sudden power cuts, desktops and routers can drop offline instantly.
- Guideline: Do not store the sync queue in standard React/Vue state or memory. Every queue insertion must be committed to IndexedDB synchronously before you update the user interface. When the application loads, always run an initial
triggerBackgroundSync()in the main layout file to catch and process items left over from a previous session that was cut short.
2. Payloads & Compression
Mobile data plans are billed strictly by volume, and 3G connections are easily congested.
- Guideline: Minify your queued requests. Strip out unnecessary nested properties, metadata, or large image blobs before saving the item to the queue. For text-heavy data fields, consider compressing JSON strings using lightweight libraries like
lz-stringbefore queueing, then decompressing them at your API gateway.
3. Clear UI Indicators
Users need to know when they are viewing stale local data and when their edits are pending.
- Guideline: Build a global sync status widget in your layout. Provide clear visual cues:
- Offline Mode (Working locally) - Syncing: 3 tasks pending... (with a spinning loader) - All changes saved to cloud
---
Leveraging Resilient Infrastructure
Creating offline-first software ensures your business operations remain uninterrupted by external network conditions. Combined with upcoming national infrastructure improvements like the Saudi-funded SilkLink project and local fiber expansions, businesses can achieve the digital resilience required to remain fully competitive.
Need to build a resilient, offline-first application for your company? At Dragonfly Soft, we design high-performance web systems and databases tailored to operate reliably in all local market conditions. Contact Dragonfly Soft today to discuss your next project with our engineering team.