#4339: How Text Survives a Tunnel: Offline Queueing Deep Dive

What happens when you tap send in a tunnel? The browser, service worker, and backend dance that makes tiny payloads survive offline.

Featuring
Listen
0:00
0:00
Episode Details
Episode ID
MWP-4518
Published
Duration
22:35
Audio
Direct link
Pipeline
V5
TTS Engine
chatterbox-regular
Script Writing Agent
deepseek-v4-pro

AI-Generated Content: This podcast is created using AI personas. Please verify any important information independently.

This episode tackles a deceptively narrow problem: what happens when a user submits a tiny text payload—a few kilobytes to a webhook—while offline, and how does it reliably arrive when connectivity returns? The answer spans four phases: detecting the offline state, queuing the request locally, retrying when connectivity returns, and deduplicating on the backend.

The first trap is navigator.onLine, which returns true whenever a device has any network interface active—even on captive portals or dead upstreams. The real signal is a fetch that fails with a TypeError: "Failed to fetch." That specific error indicates the request never left the device, distinguishing it from 400s (bad input, don't retry), 500s (maybe retry with backoff), and 429s (definitely retry with backoff).

Storage decisions determine survival. In-memory queues vanish on tab close. sessionStorage survives refresh but not tab close. localStorage survives tab close but blocks the main thread and is inaccessible to service workers. IndexedDB is the right choice—it survives device restarts and is reachable from service workers, which are essential for tab-independent retry. The canonical pattern: the page registers a service worker, which stores failed payloads in IndexedDB with an idempotency key, then registers a Background Sync event. The browser fires the sync event when connectivity returns, the worker retries the fetch, and deletes on success.

The critical gap is browser support—Background Sync works only in Chromium-based browsers as of mid-2026. The fallback listens for the window's online event and runs a periodic health-check ping. Each queued item needs an idempotency key (generated on form mount, not submit), the payload, a retry count (max 5), and a status. The UI must show pending state without clearing the form until the server confirms delivery.

On the backend, Stripe's idempotency pattern makes retries safe. The server checks a store (Redis or database) for the incoming idempotency key. If absent, it processes and caches the response with a 24-hour TTL. If present, it returns the cached response—no duplicate processing. The five failure modes that catch developers: browser says online but server is unreachable, user edits a pending item (queue must support mutation), auth expires while queued, multiple tabs competing (solved by single service worker queue), and OS suspending the app before retry (service worker pattern is the only reliable solution).

Downloads

Episode Audio

Download the full episode as an MP3 file

Download MP3
Transcript (TXT)

Plain text transcript file

Transcript (PDF)

Formatted PDF with styling

#4339: How Text Survives a Tunnel: Offline Queueing Deep Dive

Corn
Daniel sent us this one — he's asking about that moment when you tap "Send to pipeline" on a train entering a tunnel, the app doesn't error, it just waits, and when you emerge the message arrives as if nothing happened. He wants to know what actually happens across the frontend, browser, OS, and backend to make that reliable for tiny payloads — a few kilobytes of text to a webhook. And he's drawing a sharp distinction between this lightweight pattern and full offline-first architectures with gigabytes of media. Where does the boundary actually lie?
Herman
This is one of those problems that looks trivial until you've built it twice and discovered six different ways it breaks. The scope Daniel's setting is important — we're talking about a text prompt to an automation pipeline, maybe two kilobytes. Not video uploads, not offline maps, not a content store. Just "save this tiny thing and send it when you can.
Corn
Which makes it a deceptively narrow problem, because you don't need conflict resolution or storage quotas or resumable chunked transfers. But you still need every link in the chain to hold.
Herman
And the chain has four phases. Detect that you're offline, queue the request locally, retry when connectivity returns, and deduplicate on the backend so retries don't create doubles. Each phase has traps that aren't obvious on a first pass.
Corn
Let's start with the first trap, because it's the one that tricks every junior developer. How does the browser actually know you're offline?
Herman
Navigator dot onLine. And it lies.
Corn
That's a strong claim.
Herman
It's a well-documented weakness. navigator dot onLine returns true if the device has a network interface — WiFi is connected, cellular data is on. It does not mean your server is reachable. You can be on a captive portal, behind a misconfigured VPN, on a network that blocks your domain, or in one of those maddening situations where WiFi says connected but the upstream is dead. The browser has no idea whether your specific API endpoint is reachable. It just knows the network card has an IP address.
Corn
The real signal isn't a property you can poll — it's a failed fetch.
Herman
And you have to distinguish what kind of failure. A TypeError colon Failed to fetch — that's a network error. The request never left the device, or it left and nothing came back. That's the one you queue and retry. A four hundred response — bad request, validation error — that's a bug in your code or the user's input. Retrying it five times won't fix it. A five hundred or five oh three — server error — that might be retryable with backoff, because the server could recover. But you don't want to hammer a dying server. And a four twenty nine — rate limited — definitely retryable, but you'd better back off.
Corn
Step one is a fetch that fails with a network error, and you need to catch that specifically. What do you do with it?
Herman
You store it. And where you store it determines whether it survives a tab close, a refresh, a device restart. There's a hierarchy. In-memory queue — a JavaScript array — fastest, simplest, gone the moment the tab closes. sessionStorage — survives a page refresh, gone when the tab closes. localStorage — survives tab close, five to ten megabyte limit, but it's synchronous and blocks the main thread. And it's not accessible from a service worker. IndexedDB — asynchronous, survives everything including device restart, accessible from service workers, and the storage quota is generous, typically around fifty percent of available disk space capped at several gigabytes depending on the browser.
Corn
For a two-kilobyte text payload, that quota is irrelevant. The reason you'd pick IndexedDB isn't capacity — it's that the service worker can reach it.
Herman
That's the key architectural decision. If you want the retry to happen even when the user has closed the tab, you need a service worker. The service worker lives outside any individual page — it's a shared background process that can wake up and handle a sync event. The canonical pattern, documented in the web dot dev offline forms article, is this. Your page registers a service worker. When a fetch fails with a network error, the page sends a message to the service worker with the payload and an idempotency key. The service worker stores it in IndexedDB and registers a background sync event. The browser — not your code — decides when to fire that sync event, typically when it detects connectivity has returned. The service worker's sync handler pulls items from IndexedDB, retries the fetch, and on success deletes them.
Corn
This is where browser support gets interesting.
Herman
As of mid twenty twenty six, the Background Sync API is supported in Chrome, Edge, and Opera — anything Chromium-based. Firefox and Safari do not support it. That's a critical gap. If you're building for the whole web, you can't rely on Background Sync alone.
Corn
What's the fallback?
Herman
You listen for the online event on the window object, and you run a periodic health-check ping — a setInterval that hits a lightweight endpoint on your server. When the browser fires online and your ping succeeds, you drain the queue. It's less battery-friendly than Background Sync because you're polling, and it only works while the tab is open. But it works everywhere. The service worker pattern is progressive enhancement — if Background Sync is available, you get tab-independent retry. If not, you get retry as long as the tab stays open.
Corn
Let's talk about what's actually in the queue. Each item isn't just a blob of text.
Herman
Each queued item needs at minimum an idempotency key — a UUID version four, generated when the user first loads the form, not when they submit. The payload itself. A retry count starting at zero with a max of, say, five. And a status — pending, sending, or failed. If it hits max retries, it moves to a dead-letter queue that the user can see and manually retry or discard.
Corn
Why generate the idempotency key on form mount rather than on submit?
Herman
Because if you generate it on submit, and the submit fails, and the user resubmits, you've got two different keys for the same logical action. The backend can't deduplicate them. The key needs to be stable for that submission intent. Mount the form, generate the key, attach it to that attempt. If they refresh the page, new mount, new key, new intent — that's correct behavior.
Corn
The UI needs to reflect all of this without confusing the user.
Herman
The UI feedback loop is underappreciated. You submit, the request queues — show a clock icon or a subtle "pending" badge. Don't clear the form. The text stays right where the user typed it, because until the backend acknowledges receipt, that data isn't safely delivered. When the retry succeeds, animate the clock to a checkmark, then clear the form. If it fails permanently after max retries, show an error state with a retry button. Never optimistic-clear the form on a network error. That's how you lose user data.
Corn
The form is the source of truth until the server confirms otherwise.
Herman
That's exactly the mental model. Alright, we've covered the frontend storage and detection. But storing a request is useless if the backend can't safely handle retries. Let's flip to the server side.
Corn
This is the part where Stripe enters the conversation.
Herman
Stripe's idempotency pattern is the gold standard. They published a blog post on it that's essentially required reading. Here's how it works. The frontend sends the idempotency key in a header — Idempotency-Key colon that UUID. The backend receives the request. It checks a store — typically Redis or a database table — for that key. If the key isn't there, it processes the request, stores the key with the response and a TTL of twenty four hours, and returns the response. If the key is there, it returns the cached response without processing anything.
Corn
The dangerous scenario is the request reaches the server, the server processes it, the server sends a two hundred OK, and the network drops before the two hundred reaches the client. The client sees a network error and queues a retry. Without idempotency, the server processes it again and you get a duplicate. With idempotency, the server sees the key, returns the cached two hundred, and nothing is duplicated.
Herman
That's the single most important backend pattern for making retries safe. And the TTL matters — you don't want to store idempotency keys forever. Twenty four hours is standard. After that, a retry with the same key would be treated as new, but by then the user has either succeeded or given up.
Corn
What about errors that aren't network drops? The server might reject the request for reasons that won't change on retry.
Herman
The queue needs to distinguish retryable from non-retryable errors. Four twenty nine rate limit — retry with backoff. Five hundred or five oh three server error — retry with backoff. Four oh one authentication expired — do not retry, surface to the user immediately, because no amount of waiting will fix an expired token. Four hundred validation error — do not retry, surface to the user, because the payload itself is bad. If you blindly retry a four hundred, you're just spamming your own server with a malformed request.
Corn
The backoff strategy?
Herman
Exponential backoff with jitter. Start at one second, double each retry, cap at thirty seconds. Add plus or minus twenty percent randomness — jitter — so that if you have a thousand clients all retrying at the same time, they don't synchronize into a thundering herd that crushes your server. Background Sync handles this automatically in Chromium. If you're building the fallback with setInterval and the online event, you have to implement the backoff yourself.
Corn
We've built the happy path. Now let's break it. What are the failure modes that turn a simple queue into a debugging nightmare?
Herman
I count at least five that catch people. First, the browser says online but the server is unreachable — we covered that, solved by the health-check ping rather than trusting navigator dot onLine. Second, the user edits or deletes the pending item before it's delivered. Your queue needs to support mutation — delete by idempotency key. If the user changes their mind and edits the text, you update the queued payload and reset the retry count, because it's a new logical submission.
Corn
Third one — authentication expires while the item is queued.
Herman
This is a real problem, especially on mobile where the user might be offline for hours. Your options are store a refresh token and attempt a token refresh before retrying the queued request, or surface an auth error to the user and let them re-authenticate. Option one is smoother but more complex. Option two is simpler but interrupts the user. Pick based on your tolerance for complexity.
Corn
Fourth — multiple tabs.
Herman
If the user has your app open in two tabs and submits in both, you could get duplicate queues competing. This is solved by making the service worker the single queue owner. All tabs post messages to the same service worker, which maintains one queue in IndexedDB. The service worker is shared across all tabs for the same origin.
Corn
Fifth — the operating system suspends the app before a retry can occur.
Herman
On mobile this is common. The user switches apps, the OS freezes your tab or your app. A service worker gets a limited execution window when a sync event fires — the browser gives it a few seconds to complete. If it doesn't finish, the browser may retry the sync event later. But if you're relying on a main-thread JavaScript interval in a backgrounded tab, the OS will throttle or suspend it. This is why the service worker pattern matters — it's the only way to get guaranteed execution time after the user has moved on.
Corn
What about the OS clearing storage? iOS is notorious for this.
Herman
IndexedDB is classified as "best effort" storage on some platforms. If the device is low on space, the OS can clear it without warning. For a text prompt queue, the data loss is annoying but not catastrophic — the user can retype. For a full offline content store with gigabytes of media, this is a much bigger problem. Which brings us to the distinction Daniel asked about. Is this lightweight queue the same engineering problem as full offline-first?
Corn
My instinct is that they share a family resemblance but diverge on every implementation detail.
Herman
They share the abstraction of "save locally, sync later." But that's where the similarity ends. A lightweight queue for text prompts is a single atomic mutation — one create operation, no editing conflicts, no storage quotas to worry about, no resumable transfers, no eviction policies. Full offline-first is a completely different beast. You need conflict resolution — CRDTs or operational transforms — because the user might edit the same document on two devices while offline. You need storage quotas because IndexedDB can eat half the user's disk. You need eviction policies because you can't keep everything forever. You need resumable chunked uploads because you're syncing video files that are hundreds of megabytes. You need differential sync because you don't want to re-upload an entire document when one paragraph changed.
Corn
The boundary is at "single atomic mutation" versus "stateful local replica.
Herman
That's the cleanest line. If your offline action is "create one thing" — a text prompt, a form submission, a like, a comment — you're in lightweight queue territory. The moment the user can edit that thing offline, or multiple users can modify the same data offline, or the payloads exceed a few megabytes, you've crossed into offline-first territory. And the engineering complexity jumps by an order of magnitude.
Corn
What about the middle ground Daniel mentioned — a durable offline mutation queue? Where does that sit?
Herman
A durable mutation queue is the bridge. It's a lightweight queue that's been hardened — it survives tab close, device restart, app kill. It handles multiple pending actions with ordering guarantees. But it still deals in atomic mutations, not stateful replicas. Think of it as the lightweight pattern with production-grade durability. You'd use IndexedDB, a service worker, idempotency keys, and a dead-letter queue. But you wouldn't add CRDTs or storage eviction. It's the same pattern, just built to survive more failure pattern.
Corn
Let's make this concrete. Daniel asked about specific frameworks. What does this look like in React versus Vue versus Flutter versus plain HTML?
Herman
In React or Next dot js, you'd typically use a library like SWR or TanStack Query for data fetching, and their mutation retry mechanisms can handle some of this. But neither handles offline queueing natively — you'd still need a service worker and IndexedDB for the actual queue. The React layer manages the UI state — pending, sent, failed — and posts messages to the service worker. In Vue, you'd use Pinia for state management with an offline queue plugin that wraps IndexedDB. In Svelte, a custom store with IndexedDB persistence — Svelte's reactivity model makes this surprisingly clean. In Angular, an HTTP interceptor that catches network errors and routes them to a queue service. In React Native, you'd use NetInfo for connectivity detection and a library like react-native-queue for persistence. In Flutter, connectivity underscore plus for network detection, Hive for local storage, and a background isolate for processing the queue.
Corn
For a conventional server-rendered app — no JavaScript framework, just form actions?
Herman
The form works without JavaScript — it submits normally, and if the network is down, the browser shows its native error. Then you layer on JavaScript that intercepts the form submission, catches network errors, and queues to a service worker. If JavaScript fails to load or the browser doesn't support service workers, the form still works — it just doesn't have the offline queue. This is the most resilient pattern.
Corn
Are there established libraries that handle all of this, or is it custom logic?
Herman
Workbox, from the Chrome team, provides a background sync module that wraps the Background Sync API with IndexedDB storage. It handles a lot of the boilerplate — registering sync events, storing in IndexedDB, replaying the queue. But it doesn't handle the backend idempotency, the UI state management, or the framework integration. You're still writing custom logic to tie it all together. There's no single library that says "give me a form and I'll make it offline-reliable." It's a composition of patterns.
Corn
Let's assemble the simplest robust implementation. Daniel asked for the checklist.
Herman
One — generate a UUID v4 idempotency key when the form mounts. Two — on submit, attempt the fetch with that key in the Idempotency-Key header. Three — on network error specifically, not server error, store the payload and key in IndexedDB via a service worker. Four — register a Background Sync event if the API is available. Five — in the sync handler, attempt the fetch with the stored key. Six — on success, delete the item from IndexedDB. Seven — on failure, increment the retry count and re-register the sync. Eight — after five retries, surface the failure to the user. Nine — on the backend, check the idempotency key against Redis with a twenty four hour TTL, and return the cached response on duplicate.
Corn
That's the pattern. And when should someone escalate beyond it?
Herman
Add conflict resolution when users can edit the same item offline. Add storage quotas when payloads exceed about a hundred kilobytes. Add resumable transfers when payloads exceed ten megabytes. Add CRDTs when multiple users can modify the same data offline. Until you hit one of those thresholds, the lightweight queue is sufficient. Don't build a full offline-first architecture for a text prompt. That's the mistake I see teams make — they hear "offline" and reach for the heaviest solution.
Corn
The engineering equivalent of using a flamethrower to light a candle.
Herman
You burn down the sprint in the process. The idempotency key pattern is the highest-leverage change. If you do nothing else, add idempotency keys to your backend. That alone makes retries safe, even if your queue is just an in-memory array that dies on tab close. Then add the service worker queue when you need durability.
Corn
The lightweight queue and the full offline content store — they're not the same spectrum. They're different problems that happen to share a few mechanisms.
Herman
They share idempotency keys, retry queues, network detection, and local storage. They diverge on conflict resolution, storage quotas, eviction policies, resumable transfers, media transcoding, and differential sync. The shared components are the easy parts. The divergent components are where the engineering cost lives. If you're building a note-taking app with offline editing and multi-device sync, you're in the deep end. If you're building "send this text to a webhook when I have signal," you're in the shallow end. Both are valid. Just know which one you're swimming in.
Corn
There's an open question I keep coming back to. Background Sync is Chromium-only. If Safari and Firefox eventually adopt it — or if something like Web Push with service workers becomes the universal primitive — does this lightweight queue pattern become a browser default? Something the platform just handles?
Herman
I think it trends that way. The line between online and offline is already blurring. The most reliable apps treat connectivity as a spectrum, not a binary. The browser providing a built-in "retry this fetch when possible" primitive feels inevitable. Until then, we're assembling it from service workers and IndexedDB and idempotency keys.
Corn
The practical takeaway for anyone listening — audit your current form submissions. Do they survive a tab close? A device restart? A lost two hundred acknowledgement? If the answer is no to any of those, start with the idempotency key. That's the foundation everything else builds on.
Herman
Then add the service worker queue. Don't jump to offline-first. Solve the problem you actually have.
Corn
Now: Hilbert's daily fun fact.
Herman
Now: Hilbert's daily fun fact.

Hilbert: During the Cold War, a unique sign language dialect emerged among workers at a pigment factory in the Chatham Islands, where color names were signed by mimicking the grinding motion specific to each mineral — cobalt blue used a circular wrist grind, while red ochre was a sharp downward strike. The dialect died out by nineteen seventy two when the factory closed and no deaf children had learned it natively.
Corn
A pigment factory sign language on a remote island. That is aggressively specific.
Herman
The grinding motion for cobalt blue. I have questions about workplace safety in that factory.
Corn
This has been My Weird Prompts. Thanks to our producer Hilbert Flumingtop. If you enjoyed this episode, rate the show wherever you listen. Next time we're tackling how streaming databases actually work — which is going to be a whole different kind of fun. I'm Corn.
Herman
I'm Herman Poppleberry. See you then.

This episode was generated with AI assistance. Hosts Herman and Corn are AI personalities.