Skip to main content

How to Detect the Network Status in a Capacitor App

Detect the network status in a Capacitor app: read online/offline state, listen for changes, verify real internet access, and handle metered networks.

Article credits

Martin Donadieu

Writer

Valeria

Reviewer

Jordan

Editor

How to Detect the Network Status in a Capacitor App

To detect the network status in a Capacitor app, install @capacitor/network, call Network.getStatus() for the current state, and subscribe to networkStatusChange for updates. That tells you whether the device has a connection and whether it is Wi-Fi or cellular. To know if the internet actually works, or if the connection is metered, add a reachability check or @capgo/capacitor-network-diagnostics. This guide covers all three layers with Capacitor 8 code.

The three questions behind “is the user online?”

Question API Notes
Is there an active network interface? Network.getStatus().connected Fast, event-driven, says nothing about internet access
What kind of link is it? connectionType wifi, cellular, none, unknown in the official plugin
Does the internet actually work? HTTP check or internetReachable Needs a request or OS validation
Should I avoid big downloads? expensive, constrained Metered networks and iOS Low Data Mode

Most offline bugs come from treating the first row as the third. A hotel Wi-Fi with a login page reports connected: true while every API call times out.

Install the Network plugin

bun add @capacitor/network
bunx cap sync

No configuration is needed. On Android the plugin adds ACCESS_NETWORK_STATE to the merged manifest, a normal permission granted at install. iOS requires nothing.

Read the current status

import { Network } from '@capacitor/network';

const status = await Network.getStatus();
console.log(status.connected);      // true or false
console.log(status.connectionType); // 'wifi' | 'cellular' | 'none' | 'unknown'

Call it on app start before deciding whether to load from cache or network. It returns in a few milliseconds because it reads the OS state, it does not send traffic.

Listen for network changes

import { Network, type ConnectionStatus } from '@capacitor/network';
import type { PluginListenerHandle } from '@capacitor/core';

let handle: PluginListenerHandle | undefined;

export async function watchNetwork(onChange: (s: ConnectionStatus) => void) {
  onChange(await Network.getStatus());
  handle = await Network.addListener('networkStatusChange', onChange);
}

export async function stopWatchingNetwork() {
  await handle?.remove();
}

Two practical details:

  • Debounce the event. When switching from Wi-Fi to cellular, both platforms can briefly report none before the new link is up. A 1 to 2 second debounce before showing an “offline” banner avoids flicker.
  • Remove listeners when a component unmounts, or you will run the callback several times after hot reloads and navigation.

React hook

import { useEffect, useState } from 'react';
import { Network, type ConnectionStatus } from '@capacitor/network';

export function useNetworkStatus() {
  const [status, setStatus] = useState<ConnectionStatus>({ connected: true, connectionType: 'unknown' });

  useEffect(() => {
    let timer: ReturnType<typeof setTimeout>;
    const listener = Network.addListener('networkStatusChange', (s) => {
      clearTimeout(timer);
      timer = setTimeout(() => setStatus(s), s.connected ? 0 : 1500);
    });
    Network.getStatus().then(setStatus);
    return () => {
      clearTimeout(timer);
      listener.then((l) => l.remove());
    };
  }, []);

  return status;
}

Going online is applied immediately, going offline waits 1.5 seconds. The same pattern works as a Vue composable or an Angular service with a BehaviorSubject.

Why “connected” does not mean online

connected: true comes from the OS network stack. It stays true when:

  • the Wi-Fi network has a captive portal (airports, hotels, trains)
  • the router has no upstream connection
  • DNS is broken or filtered
  • a corporate firewall or VPN blocks your API domain
  • your own backend is down

The fix is a reachability check against something you control:

export async function canReachApi(timeoutMs = 4000): Promise<boolean> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch('https://api.example.com/health', {
      method: 'HEAD',
      cache: 'no-store',
      signal: controller.signal,
    });
    return res.ok;
  } catch {
    return false;
  } finally {
    clearTimeout(timer);
  }
}

Run it on app start, when networkStatusChange reports connected: true, and after a request fails. Do not poll every few seconds, it drains battery and data. Make sure the health endpoint allows your app’s origin in CORS (capacitor://localhost on iOS, https://localhost on Android by default), or use CapacitorHttp to send it from native code.

Get richer status with network diagnostics

@capgo/capacitor-network-diagnostics reads more of what the OS knows and can run native checks that bypass WebView CORS:

bun add @capgo/capacitor-network-diagnostics
bunx cap sync
import { NetworkDiagnostics } from '@capgo/capacitor-network-diagnostics';

const status = await NetworkDiagnostics.getNetworkStatus();
// {
//   connected: true,
//   connectionType: 'wifi' | 'cellular' | 'ethernet' | 'vpn' | 'other' | 'unknown' | 'none',
//   internetReachable: true,
//   expensive: false,      // metered / cellular
//   constrained: false,    // iOS Low Data Mode
//   captivePortal: false,  // Android only
//   details: { ... }
// }

How the fields map to the OS:

Field Android iOS
internetReachable NET_CAPABILITY_VALIDATED (Android probed the internet) Mirrors the NWPathMonitor path status
expensive isActiveNetworkMetered() NWPath.isExpensive
constrained Always false NWPath.isConstrained (Low Data Mode)
captivePortal NET_CAPABILITY_CAPTIVE_PORTAL Not reported
vpn type Yes Yes

On Android, internetReachable is a strong signal because the OS already tested the connection. On iOS it is closer to connected, so keep the HTTP check for critical flows.

Native reachability checks

const api = await NetworkDiagnostics.testUrl({
  url: 'https://api.example.com/health',
  method: 'HEAD',
  timeoutMs: 4000,
});
// api.reachable, api.statusCode, api.durationMs, api.errorCode

const socket = await NetworkDiagnostics.testWebSocket({ url: 'wss://realtime.example.com' });
const port = await NetworkDiagnostics.testPort({ host: 'mqtt.example.com', port: 8883 });

Native requests are not subject to CORS, so you can test third-party hosts too.

A support “network report” screen

When users say “the app does not work”, a one-tap diagnostic report saves hours of back and forth:

const report = await NetworkDiagnostics.runDiagnostics({
  urls: [{ url: 'https://api.example.com/health' }, { url: 'https://cdn.example.com/ping' }],
  websockets: [{ url: 'wss://realtime.example.com' }],
  download: { url: 'https://cdn.example.com/1mb.bin', maxBytes: 1_048_576 },
  packetLoss: { url: 'https://api.example.com/health', count: 5 },
});

console.log(report.issues);        // human-readable list of problems
console.log(report.download?.mbps);
console.log(report.packetLoss?.lossPercent);

Attach the JSON to a support ticket. You will see right away whether the problem is DNS, a blocked WebSocket, a captive portal, or a slow link.

Adapt to metered and Low Data Mode connections

Respecting data limits is a quick win for reviews in markets with expensive mobile data:

export async function shouldPrefetchMedia() {
  const s = await NetworkDiagnostics.getNetworkStatus();
  if (!s.connected) return false;
  if (s.constrained) return false; // user asked iOS to save data
  if (s.expensive) return false;   // cellular or metered Wi-Fi
  return true;
}

Use it to decide on video autoplay, image quality, background sync, and large downloads. On Android, users can mark a Wi-Fi network as metered, so do not assume Wi-Fi is free.

Build an offline banner

A small banner is clearer than failing requests. Using the hook above:

export function OfflineBanner() {
  const { connected } = useNetworkStatus();
  if (connected) return null;
  return (
    <div role="status" aria-live="polite" className="offline-banner">
      You are offline. Changes will sync when you reconnect.
    </div>
  );
}

role="status" with aria-live="polite" makes screen readers announce the change. For framework-specific UI, see our guide to building an offline screen in Vue, Angular and React.

Queue work while offline

Detection is only half of offline support. When the status goes offline:

  1. Serve reads from local storage. A SQLite database such as @capgo/capacitor-fast-sql handles large datasets better than localStorage.
  2. Write user actions to an outbox table with a timestamp and an idempotency key.
  3. On networkStatusChange to connected, run canReachApi(), then flush the outbox in order.
  4. Retry failed requests with exponential backoff, capped at a few minutes.

For uploads that must survive the app being closed, hand them to a native uploader rather than fetch.

Web fallback

@capacitor/network also runs in the browser, where it is based on navigator.onLine and the online and offline events. That means your code works in bun run dev and in a PWA without changes, but connectionType will often be unknown on the web. Chrome’s navigator.connection exposes effectiveType and saveData, Safari does not, so treat them as optional hints.

Troubleshooting

The listener never fires on iOS Simulator: the simulator shares the Mac’s network. Toggle Wi-Fi on the Mac or test on a device with airplane mode.

connectionType is unknown on a VPN: the official plugin only reports wifi, cellular, none and unknown. Use the diagnostics plugin, which returns vpn and ethernet.

The app shows offline for a second when switching networks: debounce the offline transition as shown above.

The health check fails on device but works in the browser: CORS. Allow capacitor://localhost and https://localhost, or use NetworkDiagnostics.testUrl, which runs natively.

navigator.onLine is true while the plugin says offline: trust the plugin. Some Android WebViews do not update navigator.onLine reliably.

Ship offline fixes over the air

Offline handling is mostly JavaScript: thresholds, banners, retry logic, cache rules. With Capgo live updates, you can tune those for every user the same day. Capgo downloads update bundles in the background and keeps the current version if a download fails, so a bad connection never leaves users with a half-installed update. The network diagnostics docs list every option.

Live updates for Capacitor apps

When a web-layer bug is live, ship the fix through Capgo instead of waiting days for app store approval. Users get the update in the background while native changes stay in the normal review path.

human support from Martin

Get Started Now

Latest from our Blog

Capgo gives you the best insights you need to create a truly professional mobile app.