The 3am Pager That Taught Me WebSockets Don't Work Offline

It was 3am when the pager went off. Our food delivery app was losing thousands of orders because users kept going into tunnels and elevators. Everyone told me WebSockets were the answer for real-time tracking. They were wrong.

The Million-Dollar Tunnel Problem

Picture this: Your CEO just tweeted about the new real-time delivery tracking feature. Users are excited. But then the complaints start rolling in. "I was in the subway and my order disappeared!" "The app showed 'delivered' but I'm still waiting!" We had a classic case of the happy path syndrome. Our WebSocket connection worked perfectly... as long as you had perfect internet. But real life isn't perfect. Real life has tunnels, elevators, dead zones, and that one spot in the kitchen where WiFi goes to die. The stakes? We were losing 12% of orders in urban areas. That's not just a bug - that's revenue bleeding out while we slept. 💡 Insight : The problem isn't keeping users connected. It's gracefully handling when they inevitably disconnect.

My WebSocket Wakeup Call

I used to think WebSockets were magic. Just open a connection and boom - real-time everything. Then I learned the hard way that WebSockets are fragile as glass. Here's what happens when a user's train enters a tunnel: // The naive approach that broke our app const ws = new WebSocket('wss://api.deliveryapp.com/orders'); ws.onmessage = (event) => { const order = JSON.parse(event.data); updateOrderStatus(order.id, order.status); }; ws.onclose = () => { // 😱 User is now in a tunnel with no updates! console.log('Connection lost. Good luck!'); }; The result? Users seeing stale data, phantom deliveries, and rage-quitting our app. We had built a Ferrari that couldn't handle a speed bump. ⚠️ Watch Out : WebSockets don't automatically reconnect. They don't cache messages. They don't care about your user's experience when the network disappears.

The Exponential Backoff Revelation

My first attempt at a fix was simple: just reconnect when the connection drops. What could go wrong? Everything. // The DDoS attack on our own servers ws.onclose = () => { setTimeout(() => { ws = new WebSocket('wss://api.deliveryapp.com/orders'); }, 1000); // Reconnect every second! }; When our servers had a brief hiccup, thousands of clients started hammering them with reconnection attempts every second. We accidentally DDoS'd ourselves. Then I discovered exponential backoff - the hero we needed: let reconnectAttempts = 0; const MAX_RECONNECT_DELAY = 30000; // 30 seconds max const BASE_RECONNECT_DELAY = 1000; // Start with 1 second function connectWithBackoff() { const ws = new WebSocket('wss://api.deliveryapp.com/orders'); ws.onclose = () => { const delay = Math.min( BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY ); setTimeout(() => { reconnectAttempts++; connectWithBackoff(); }, delay); }; ws.onopen = () => { reconnectAttempts = 0; // Reset on successful connection }; } 🔥 Hot Take : Exponential backoff isn't just for retries - it's a fundamental pattern for building resilient distributed systems.

The Offline-First Plot Twist

Here's where everything changed. I was reading the Uber engineering blog (more on that later) when I had my "aha" moment: What if we stopped trying to keep users online and instead embraced offline? The plot twist: The best real-time app works great when it's NOT real-time. Enter the service worker + IndexedDB power couple: // Service worker for offline magic self.addEventListener('fetch', (event) => { if (event.request.url.includes('/orders/')) { event.respondWith( caches.match(event.request) .then(response => { // Return cached version if available if (response) return response; // Otherwise fetch and cache return fetch(event.request).then(fetchResponse => { const responseClone = fetchResponse.clone(); caches.open('orders-v1').then(cache => { cache.put(event.request, responseClone); }); return fetchResponse; }); }) ); } }); And for the local state persistence: // IndexedDB for when WiFi abandons you class OrderStore { constructor() { this.db = null; this.init(); } async init() { this.db = await idb.openDB('OrderDB', 1, { upgrade(db) { db.createObjectStore('orders', { keyPath: 'id' }); db.createObjectStore('updates', { keyPath: 'id', autoIncrement: true }); } }); } async saveOrder(order) { await this.db.put('orders', order); } async queueUpdate(update) { await this.db.add('updates', { ...update, timestamp: Date.now(), synced: false }); } } 🎯 Key Point : Offline-first isn't about being offline. It's about being so good at handling offline that users never notice the difference.

The Background Sync Miracle

But here's the thing - storing data locally is only half the battle. What happens when the user comes back online? How do we sync everything without creating chaos? Background sync is the unsung hero here: // Register for background sync navigator.serviceWorker.ready.then(registration => { registration.sync.register('order-updates'); }); // In the service worker self.addEventListener('sync', (event) => { if (event.tag === 'order-updates') { event.waitUntil(syncOrderUpdates()); } }); async function syncOrderUpdates() { const updates = await getAllQueuedUpdates(); for (const update of updates) { try { await fetch('/api/orders/update', { method: 'POST', body: JSON.stringify(update) }); // Mark as synced await markUpdateSynced(update.id); } catch (error) { // Will retry on next sync event console.log('Sync failed, will retry later'); break; } } } The beauty? This works even if the user closed the tab. The browser handles it in the background. I used to think optimistic UI updates were risky. Now I realize they're essential - just pair them with proper conflict resolution. ⚠️ Watch Out : Always implement conflict resolution. What if the user marked an order as delivered while offline, but the server already marked it as cancelled? Real-World Case Study Uber In 2016, Uber faced a massive problem with their rider app losing trip updates in areas with poor connectivity. Their initial WebSocket-based approach failed spectacularly in urban canyons and during high-demand events. Users would see frozen trip status or lose their ride entirely when going through tunnels. Key Takeaway: Uber's engineering team discovered that the solution wasn't better WebSockets - it was embracing offline-first architecture. They implemented a sophisticated sync system using service workers and local storage, reducing trip update failures by 94% and improving rider retention in connectivity-challenged markets by 23%.

System Flow

graph TD A[User App] --> B[Service Worker] A --> C[IndexedDB] B --> D[Cache Storage] B --> E[Background Sync] C --> F[Local Order State] C --> G[Queued Updates] E --> H[WebSocket Server] H --> I[Order Database] J[Network Available] --> K[Sync Queued Updates] L[Network Unavailable] --> M[Store Locally] style A fill:#e1f5fe style H fill:#fff3e0 style I fill:#f3e5f5 subgraph "Offline Mode" C D G end subgraph "Online Mode" H I E end Did you know? The first WebSocket implementation was created in 2010, but it took until 2016 for browsers to properly support service workers - the missing piece that made offline-first real-time apps actually viable in production. Key Takeaways WebSocket + Exponential Backoff for resilient connections Service Worker for request interception and caching IndexedDB for local state persistence Background Sync for automatic data synchronization Optimistic UI + Conflict Resolution for smooth UX References 1 Uber Engineering: Building Resilient Real-Time Features blog 2 MDN: Using Service Workers documentation 3 Google Web Dev: Offline-first Apps documentation 4 Netflix: The Evolution of Their Playback Architecture blog

System Flow

graph TD A[User App] --> B[Service Worker] A --> C[IndexedDB] B --> D[Cache Storage] B --> E[Background Sync] C --> F[Local Order State] C --> G[Queued Updates] E --> H[WebSocket Server] H --> I[Order Database] J[Network Available] --> K[Sync Queued Updates] L[Network Unavailable] --> M[Store Locally] style A fill:#e1f5fe style H fill:#fff3e0 style I fill:#f3e5f5 subgraph "Offline Mode" C D G end subgraph "Online Mode" H I E end

Did you know? The first WebSocket implementation was created in 2010, but it took until 2016 for browsers to properly support service workers - the missing piece that made offline-first real-time apps actually viable in production.

Wrapping Up

The moral of the story? Stop trying to build perfect connections and start building perfect disconnections. Your users will thank you, your servers will breathe easier, and your CEO won't get 3am pager alerts about tunnels. Tomorrow, audit your real-time features: what happens when the network disappears? If the answer is 'bad things,' you now have the roadmap to fix it.

Satishkumar Dhule
Satishkumar Dhule
Software Engineer

Ready to put this into practice?

Practice Questions
Start typing to search articles…
↑↓ navigate open Esc close
function openSearch() { document.getElementById('searchModal').classList.add('open'); document.getElementById('searchInput').focus(); document.body.style.overflow = 'hidden'; } function closeSearch() { document.getElementById('searchModal').classList.remove('open'); document.body.style.overflow = ''; document.getElementById('searchInput').value = ''; document.getElementById('searchResults').innerHTML = '
Start typing to search articles…
'; } document.addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openSearch(); } if (e.key === 'Escape') closeSearch(); }); document.getElementById('searchInput')?.addEventListener('input', e => { const q = e.target.value.toLowerCase().trim(); const results = document.getElementById('searchResults'); if (!q) { results.innerHTML = '
Start typing to search articles…
'; return; } const matches = searchData.filter(a => a.title.toLowerCase().includes(q) || (a.intro||'').toLowerCase().includes(q) || a.channel.toLowerCase().includes(q) || (a.tags||[]).some(t => t.toLowerCase().includes(q)) ).slice(0, 8); if (!matches.length) { results.innerHTML = '
No articles found
'; return; } results.innerHTML = matches.map(a => `
${a.title}
${a.channel.replace(/-/g,' ')}${a.difficulty}
`).join(''); }); function toggleTheme() { const html = document.documentElement; const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; html.setAttribute('data-theme', next); localStorage.setItem('theme', next); } // Reading progress window.addEventListener('scroll', () => { const bar = document.getElementById('reading-progress'); const btt = document.getElementById('back-to-top'); if (bar) { const doc = document.documentElement; const pct = (doc.scrollTop / (doc.scrollHeight - doc.clientHeight)) * 100; bar.style.width = Math.min(pct, 100) + '%'; } if (btt) btt.classList.toggle('visible', window.scrollY > 400); }); // TOC active state const tocLinks = document.querySelectorAll('.toc-list a'); if (tocLinks.length) { const observer = new IntersectionObserver(entries => { entries.forEach(e => { if (e.isIntersecting) { tocLinks.forEach(l => l.classList.remove('active')); const active = document.querySelector('.toc-list a[href="#' + e.target.id + '"]'); if (active) active.classList.add('active'); } }); }, { rootMargin: '-20% 0px -70% 0px' }); document.querySelectorAll('.article-content h2[id]').forEach(h => observer.observe(h)); } function filterArticles(difficulty, btn) { document.querySelectorAll('.diff-filter').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); document.querySelectorAll('.article-card').forEach(card => { card.style.display = (difficulty === 'all' || card.dataset.difficulty === difficulty) ? '' : 'none'; }); } function copySnippet(btn) { const snippet = document.getElementById('shareSnippet')?.innerText; if (!snippet) return; navigator.clipboard.writeText(snippet).then(() => { btn.innerHTML = ''; if (typeof lucide !== 'undefined') lucide.createIcons(); setTimeout(() => { btn.innerHTML = ''; if (typeof lucide !== 'undefined') lucide.createIcons(); }, 2000); }); } if (typeof lucide !== 'undefined') lucide.createIcons();