Detecting Captive Portals Before Sharing

This guide is part of Connectivity-Aware Share UX, which itself is part of Offline-First PWA Patterns for Web Share API.

Hotel, airport, café, and conference Wi-Fi almost always sit behind a captive portal — the gateway hands your device an IP address but intercepts every request until you accept terms or log in. To the browser this looks like a working connection: navigator.onLine is true, DNS resolves, and a socket opens. Yet nothing your app sends actually reaches your server. If your share flow trusts navigator.onLine, it will try to send, appear to succeed, and quietly drop the user’s content. The fix is an active probe that a portal cannot fake.

The trap is specifically that a portal is worse than plain offline. When the device is genuinely offline, navigator.onLine is false and any half-decent flow queues the share. A portal produces the opposite: navigator.onLine reads true, so the app confidently attempts a real network share, the portal returns its login HTML, and — depending on where the request lands — the share either throws a confusing error or appears to complete against a page that was never your server. Detecting this state before you attempt navigator.share() is the only way to route the payload into the same durable queue you already use for the offline case.

Feature Detection Gate

Probe only where the primitives exist. AbortSignal.timeout is the newest dependency (Safari 16.4+, Firefox 100+), so guard on it explicitly before attempting a request.

// portal-support.js
export function canProbeForPortal() {
  return (
    window.isSecureContext === true &&
    typeof fetch === 'function' &&
    typeof AbortSignal !== 'undefined' &&
    typeof AbortSignal.timeout === 'function'
  );
}

If canProbeForPortal() returns false, fall back to navigator.onLine alone and lean on the connectivity-aware share UX state machine to recover on the next online event.

Solution Walkthrough

Step 1 — Host and fetch a known 204 endpoint

Serve a tiny same-origin endpoint that returns HTTP 204 No Content with an empty body — the same technique platform connectivity checks use. Request it with cache: 'no-store' so neither the HTTP cache nor your service worker can answer it, and bound it with a timeout so a portal that black-holes the socket cannot hang the UI.

// portal-probe.js
import { canProbeForPortal } from './portal-support.js';

export async function reachesRealServer(probeUrl = '/connectivity-check', timeoutMs = 2500) {
  if (!canProbeForPortal()) return navigator.onLine;
  if (navigator.onLine === false) return false;

  try {
    const res = await fetch(probeUrl, {
      method: 'GET',
      cache: 'no-store',
      redirect: 'manual',          // a portal redirect must not be followed silently
      signal: AbortSignal.timeout(timeoutMs)
    });
    return await isGenuine204(res);
  } catch {
    // Timeout, DNS interception, or network error — assume portal/offline.
    return false;
  }
}

Step 2 — Verify the status and the body

A portal frequently answers with 200 OK and an HTML login page, or issues a redirect (302, or an opaque 0-status response when redirect: 'manual' catches it). Require an exact 204 and confirm the body is empty. Anything else is interception.

// portal-verify.js
export async function isGenuine204(res) {
  // redirect:'manual' surfaces a portal 302 as an opaqueredirect (status 0).
  if (res.type === 'opaqueredirect') return false;
  if (res.status !== 204) return false;

  // A real 204 carries no body; a portal masquerading as 204 may still send bytes.
  const text = await res.text();
  return text.length === 0;
}

Step 3 — Queue on any ambiguous result

Wire the verified probe into the share entry point. Only a definitive true may attempt navigator.share(); every other outcome enqueues the payload and lets the reconnect logic drain it later.

// guarded-share.js
import { reachesRealServer } from './portal-probe.js';
import { enqueueShare } from './share-queue.js';

export async function shareOrQueue(payload) {
  if (!window.isSecureContext || typeof navigator.share !== 'function') {
    await enqueueShare(payload);
    return { outcome: 'queued', reason: 'unsupported' };
  }

  const online = await reachesRealServer();
  if (!online) {
    await enqueueShare(payload);      // portal or offline — capture intent
    return { outcome: 'queued', reason: 'captive-or-offline' };
  }

  try {
    await navigator.share(payload);
    return { outcome: 'shared' };
  } catch (err) {
    if (err.name === 'AbortError') return { outcome: 'dismissed' };
    await enqueueShare(payload);      // late failure — fall back to the queue
    return { outcome: 'queued', reason: err.name };
  }
}

Because the queue write is durable, you can immediately confirm capture to the user — the same optimistic pattern the connectivity-aware share UX guide uses — and let background sync for deferred shares deliver it once the portal is cleared.

A subtle timing point: run the probe at share time, not once at page load. Portal state changes the moment the user accepts the terms page, and a value cached from load would be stale by the time they tap Share. The probe is cheap — a single sub-kilobyte round trip with a short timeout — so paying it on each share attempt is affordable, and it keeps the decision honest. If you want a background hint for the connectivity banner as well, re-run the probe on the online event and on visibilitychange, but always confirm with a fresh probe immediately before the actual share.

Failure Modes and Recovery

Failure Symptom Minimal fix
Trusting navigator.onLine Share “succeeds” behind a portal but nothing sends Gate every attempt on reachesRealServer(), not navigator.onLine
Probe answered from cache Blocked network reports healthy; probe never hits the wire Set cache: 'no-store' and exclude the probe path from the service worker
CORS on a cross-origin probe Fetch rejects on healthy networks (false offline) Probe a same-origin 204 endpoint you control
Portal returns 200 HTML Probe treated as online; share fails downstream Require exact 204 status and empty body via isGenuine204()
Portal redirect followed silently res.ok is true for the login page Use redirect: 'manual' and reject opaqueredirect responses
Hung socket on black-holed network UI freezes waiting for the probe Bound the fetch with AbortSignal.timeout()

Browser and Platform Caveat

The probe itself is portable: fetch with cache: 'no-store' and redirect: 'manual' works across Chrome, Safari, Firefox, and Edge. The one hard dependency is AbortSignal.timeout, added in Safari 16.4 and Firefox 100; the feature gate falls back to a plain navigator.onLine read on older engines. Note that a service worker with an aggressive catch-all fetch handler can itself defeat the probe by serving a cached 204 — exclude the probe URL from your caching strategy so it always reaches the network. On iOS Safari the OS may open its own captive-portal assistant before your page loads, but once your PWA is running, only this in-page probe can confirm your specific origin is reachable.

FAQ

Why does navigator.onLine return true behind a captive portal?

navigator.onLine only checks that the device has an associated network interface with a route. A captive portal gives you exactly that — an IP address and a gateway — while silently intercepting every HTTP request and returning its own login page. The interface is up, so navigator.onLine is true even though nothing can actually reach your server.

Why must the captive-portal probe use cache no-store?

Without cache: 'no-store' the browser or service worker can answer the probe from cache, returning a stale 204 that makes a blocked connection look healthy. no-store forces a real network round trip so the portal has a chance to intercept it, which is the only way the probe can detect interception.

Can I probe a third-party captive-portal endpoint like generate_204?

Not reliably from a browser. Cross-origin probes are subject to CORS, and a no-content endpoint that lacks Access-Control-Allow-Origin headers will reject your fetch even when the network is healthy, producing false negatives. Host your own same-origin 204 endpoint so CORS never interferes with the result.

What should happen when the probe result is ambiguous?

Fail closed: treat anything that is not an exact 204 with an empty body as offline and queue the share. A portal returning 200 with HTML, a redirect, a timeout, or a CORS error are all signs the request did not reach your server, so attempting navigator.share against it would fail or send nothing useful.