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:

  1. Every write operation payload should include a lastUpdated property set to the local device's Date.now().
  2. The server compares the incoming lastUpdated timestamp with the lastUpdated field currently stored in the database.
  3. 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-string before 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.