Skip to content

Media & System APIs

Media and system APIs are the small, focused web platform features that make a Progressive Web App behave like an operating-system citizen: lock-screen media controls, keeping the display awake, copying images to the clipboard, floating video and mini-player windows, full-screen presentation, screen sharing, orientation locking, knowing when the user walks away, and coordinating work between tabs. None of them is large on its own, but together they close most of the everyday gaps between a web app and a native one. Each has its own rules about secure contexts, user activation, visibility, permissions and Permissions Policy, and support ranges from "every engine since 2019" (Web Locks, Page Visibility) to "Chromium desktop only" (EyeDropper, Local Font Access), so every section below gives the exact API surface, the preconditions that make calls fail, production code with fallbacks, and verified support.

Key takeaways

  • Almost every API on this page requires a secure context, and most that open UI (Clipboard writes in Safari and Firefox, Fullscreen, Picture-in-Picture, Document Picture-in-Picture, getDisplayMedia(), EyeDropper, Contact Picker, Local Font Access) require transient user activation: call them synchronously from a click, tap or key handler, before any await that might take long.
  • Screen Wake Lock now works in all engines: Chrome 84, Firefox 126 and Safari 16.4, with iOS and iPadOS Home Screen web apps supported only from 18.4. Locks are released automatically whenever the page becomes hidden, so you must re-acquire on visibilitychange.
  • The Async Clipboard API is cross-browser for text and for ClipboardItem (text/plain, text/html, image/png) since Firefox 127, but the permission models differ: Chromium uses clipboard-read / clipboard-write permissions, while Firefox and Safari use a per-read "Paste" prompt and never support those permission names.
  • Document Picture-in-Picture is no longer Chromium-only: Firefox 151 (May 2026) shipped it on desktop, and Firefox 153 (July 2026) added the <video> Picture-in-Picture API. Safari supports video PiP only.
  • Page Visibility and pagehide are the only reliable "the user left" signals. Chrome is rolling out the deprecation of unload across all origins from Chrome 146, so move any unload logic to visibilitychange and pagehide now.
  • Chromium-only APIs (Idle Detection, Contact Picker on Android, EyeDropper, Local Font Access, Region and Element Capture, Keyboard Lock) must be feature-detected and treated strictly as progressive enhancements.
  • Web Locks (all engines) is the right primitive for cross-tab coordination in a PWA: leader election, serialized IndexedDB migrations and single-flight network sync.

The rules every API on this page shares

Before looking at individual APIs, it helps to know the five gates a call can fail at. Almost every "it works in my test page but not in production" bug with these APIs comes from one of them.

  1. Secure context. Screen Wake Lock, Idle Detection, the Async Clipboard API, Contact Picker, Screen Capture, Document Picture-in-Picture, Local Font Access, EyeDropper and Web Locks are [SecureContext]: on http: origins (other than localhost) the interfaces are simply absent, and feature detection such as "wakeLock" in navigator returns false rather than throwing. Media Session, Page Visibility, Screen Orientation, Vibration, Fullscreen and video Picture-in-Picture are also exposed on insecure origins, but a PWA is served over HTTPS anyway.
  2. User activation. The HTML standard defines transient activation (a flag that is set by a real keydown, mousedown, pointerdown, pointerup or touchend and expires after a browser-defined timeout, about 5 seconds in Chromium and Gecko) and sticky activation (set once the user has interacted with the page at all). Some APIs consume transient activation, so one click permits exactly one call. You can inspect both flags with navigator.userActivation.isActive and navigator.userActivation.hasBeenActive (Chrome 72, Firefox 120, Safari 16.4).
  3. Visibility and focus. Wake Lock, Vibration and Screen Orientation refuse to work when document.visibilityState is "hidden". The Chromium clipboard implementation additionally rejects when the document does not have focus, which is why calling navigator.clipboard.writeText() from the DevTools console fails with "Document is not focused".
  4. Permissions Policy. Most features have a policy-controlled feature name with a default allowlist of 'self'. Same-origin iframes inherit access; cross-origin iframes need an allow attribute such as allow="screen-wake-lock; clipboard-write; fullscreen". The Permissions page covers the header syntax in depth.
  5. Permission state. A few APIs (Idle Detection, Local Font Access, Chromium clipboard reads, Captured Surface Control) sit behind a real permission with a prompt and a persistent grant that you can query with navigator.permissions.query().
API Exposed in Activation Permission / prompt Permissions Policy feature Hidden page
Media Session Window None None None Works (that is the point)
Screen Wake Lock Window None screen-wake-lock (auto-granted in practice) screen-wake-lock Rejected; held locks released
Idle Detection Window, dedicated worker For requestPermission() idle-detection prompt idle-detection Works
Clipboard write Window Required in Firefox and Safari; Chromium: activation or clipboard-write Chromium only clipboard-write (Chromium) Chromium rejects without focus
Clipboard read Window Required in Firefox and Safari Chromium clipboard-read prompt; Firefox and Safari "Paste" menu clipboard-read (Chromium) Chromium rejects without focus
Contact Picker Window (top-level only) Required Picker UI is the consent None Not applicable
Screen Orientation lock() Window None, but fullscreen is usually required None None (sandbox flag allow-orientation-lock) Rejected with SecurityError
Vibration Window Sticky activation None None in the spec; blocked in cross-origin iframes Returns false
Fullscreen Window Required None fullscreen Not applicable
Picture-in-Picture (video) Window Required unless already in PiP None picture-in-picture Allowed (automatic PiP)
Document Picture-in-Picture Window (top-level only) Required and consumed None None Allowed via Media Session action
Screen Capture Window Required by spec Picker every time, never persisted display-capture Not applicable
Local Font Access Window Required for the first prompt local-fonts prompt local-fonts Not applicable
EyeDropper Window Required Picker UI is the consent None Not applicable
Web Locks Window, all workers None None None Works; released on freeze or unload

A helper that turns the activation rule into a readable error saves debugging time on every API that needs a gesture:

src/platform/activation.js
/**
 * Throws a descriptive error when an API that needs transient user activation
 * is about to be called without it. Call this *before* the API, synchronously,
 * so the stack trace points at the offending caller.
 */
export function assertUserActivation(apiName) {
  // navigator.userActivation: Chrome 72+, Firefox 120+, Safari 16.4+.
  const ua = navigator.userActivation;
  if (ua && !ua.isActive) {
    throw new DOMException(
      `${apiName} must be called from a click, tap or key handler ` +
        "(transient user activation has expired or was never granted).",
      "NotAllowedError",
    );
  }
}

/** True when the page has had *any* user interaction (sticky activation). */
export const hasInteracted = () => navigator.userActivation?.hasBeenActive ?? true;

Do slow work before the gesture, not after it

Transient activation expires after a few seconds and some APIs consume it. If a button needs to fetch data and then call requestFullscreen(), requestPictureInPicture() or navigator.clipboard.write(), prefetch the data when the UI renders, or pass a promise into the API where the API allows it (the clipboard pattern in Writing images and rich content).

Media Session API

The Media Session API lets a page describe what it is playing and handle the platform's media controls: the Android media notification and lock screen, the macOS Now Playing widget and Touch Bar, the Windows System Media Transport Controls overlay, hardware media keys, Bluetooth headset buttons, smart watches and the Chrome global media controls. Without it, the browser shows the page title and favicon and only play and pause work. With it, a music, podcast or video PWA gets title, artist, album art, a scrubber and next / previous buttons in the same places native apps do.

The API is exposed as navigator.mediaSession and has shipped since Chrome 57 on Android, Chrome 73 on desktop, Firefox 82 and Safari 15. Firefox for Android exposes the API but shows no user-facing controls for it.

Media Session IDL (abridged)
partial interface Navigator {
  [SameObject] readonly attribute MediaSession mediaSession;
};

enum MediaSessionPlaybackState { "none", "paused", "playing" };

enum MediaSessionAction {
  "play", "pause", "seekbackward", "seekforward", "previoustrack", "nexttrack",
  "skipad", "stop", "seekto", "togglemicrophone", "togglecamera",
  "togglescreenshare", "hangup", "previousslide", "nextslide",
  "enterpictureinpicture", "voiceactivity"
};

interface MediaSession {
  attribute MediaMetadata? metadata;
  attribute MediaSessionPlaybackState playbackState;
  undefined setActionHandler(MediaSessionAction action, MediaSessionActionHandler? handler);
  undefined setPositionState(optional MediaPositionState state = {});
  Promise<undefined> setMicrophoneActive(boolean active);
  Promise<undefined> setCameraActive(boolean active);
  Promise<undefined> setScreenshareActive(boolean active);
};

The media session belongs to the top-level browsing context, but it only becomes the active session the platform displays while the page is actually playing audible media through an <audio> or <video> element (Web Audio playback is recognized by some browsers but not all). If your app plays through Web Audio or MSE with unusual pipelines, test that the controls appear on each target platform.

MediaMetadata and artwork

navigator.mediaSession.metadata takes a MediaMetadata object. Its constructor accepts title, artist, album and artwork. Chrome 127 added chapterInfo, an array of ChapterInformation entries (title, startTime in seconds and artwork), which Chrome's media controls can use to show chapters.

src/media/metadata.js
navigator.mediaSession.metadata = new MediaMetadata({
  title: "Episode 42: Caching Strategies",
  artist: "The PWA Podcast",
  album: "Season 3",
  artwork: [
    // Offer several sizes: Android picks roughly 512px, desktop surfaces smaller.
    { src: "/art/ep42-96.png", sizes: "96x96", type: "image/png" },
    { src: "/art/ep42-256.png", sizes: "256x256", type: "image/png" },
    { src: "/art/ep42-512.png", sizes: "512x512", type: "image/png" },
  ],
});

The browser fetches artwork itself. URLs are resolved against the document base URL, and requests go through your service worker, so precache or runtime-cache artwork with the rest of the media (see Caching strategies) to keep offline playback looking right. Each MediaImage can have sizes (space-separated like the manifest icons member) and type; the user agent chooses the best fit. Assigning a new MediaMetadata object replaces the old one completely; mutating fields of the current object also updates the UI.

Action handlers

setActionHandler(action, handler) registers one handler per action; passing null unregisters it. Registering a handler is how you tell the platform that a control should be shown: if you never register nexttrack, the next button does not appear. Unknown enum values throw a TypeError because the parameter is a WebIDL enum, so wrap each registration in try / catch: an older browser that does not know skipad or enterpictureinpicture would otherwise throw and abort the rest of your setup.

The handler receives a MediaSessionActionDetails dictionary with action plus action-specific members: seekOffset (seconds, for seekbackward / seekforward, may be absent so choose a default), seekTime and fastSeek (for seekto), and in Chrome 142 the experimental enterPictureInPictureReason for enterpictureinpicture ("useraction", "contentoccluded" or "other", so you can tell an explicit button press from an automatic trigger).

Action Typical trigger Chrome (desktop) Firefox Safari
play, pause Play / pause key, headset button, lock screen 73 82 15
seekbackward, seekforward Skip ±10 s buttons 73 82 15
previoustrack, nexttrack Previous / next track keys 73 82 15
stop Stop key, closing the notification 77 82 15
seekto Dragging the scrubber 78 82 15
skipad "Skip ad" button 128 82 15
togglemicrophone, togglecamera Mute buttons in the global controls 93 ❌ 18.4
hangup Hang-up button 93 ❌ ❌
previousslide, nextslide Slide navigation for presentations 111 ❌ ❌
enterpictureinpicture Automatic PiP when the user switches tabs 120 ❌ ❌

Chrome for Android has supported the core playback actions since version 57. Support data as of September 2026. See MDN browser compatibility for MediaSession for live data.

Default handlers

When you do not register play or pause, browsers apply a default behavior to the currently playing media element. As soon as you register one, the browser stops doing that and calls you instead, so a registered play handler must actually start playback and update playbackState, or the control appears to do nothing.

Position state and the scrubber

setPositionState({ duration, playbackRate, position }) feeds the scrubber and elapsed / remaining time. The browser extrapolates position from the last call using playbackRate, so you only need to call it when something changes discontinuously: on loadedmetadata, seeked, ratechange, play and pause, not on every timeupdate. The spec throws TypeError when duration is missing, negative or NaN, when position is negative or greater than duration, or when playbackRate is 0 (pause by setting playbackState instead). duration may be Infinity for live streams. Calling setPositionState() with no argument clears the state.

playbackState ("none", "paused", "playing") tells the platform which button to show when the browser cannot infer it, for example when you play through Web Audio or when the media element is paused while you show an ad. Keep it in sync with your player state explicitly.

Video conferencing controls

Chrome 93 added togglemicrophone, togglecamera and hangup actions with setMicrophoneActive(active) and setCameraActive(active), which a calling app uses to keep the browser's global controls (and, on Safari 18.4 and later, the system capture indicators) in sync with its own mute state. Safari 18.4 also added setScreenshareActive(), so the user can mute capture from the browser UI and the page is told about it in one central place. Treat the toggle handlers as the source of truth: when the user mutes from the browser UI, disable the track (track.enabled = false) and update your in-page button.

A complete media session controller

The module below wires a media element to the Media Session API with every production concern handled: per-action feature detection, throttled position updates, playlist navigation, and cleanup when the player is destroyed.

src/media/media-session-controller.js
const DEFAULT_SKIP_SECONDS = 10;

/**
 * Connects an HTMLMediaElement and a playlist to the platform media controls.
 * Returns a disposer that removes every handler and listener.
 */
export function connectMediaSession(media, playlist) {
  if (!("mediaSession" in navigator)) return () => {};
  const session = navigator.mediaSession;
  let index = 0;

  const setHandler = (action, handler) => {
    try {
      session.setActionHandler(action, handler);
    } catch {
      // TypeError: this browser does not know the action. Safe to ignore.
    }
  };

  function updateMetadata() {
    const track = playlist[index];
    session.metadata = new MediaMetadata({
      title: track.title,
      artist: track.artist,
      album: track.album,
      artwork: track.artwork, // [{src, sizes, type}, ...]
    });
  }

  function updatePosition() {
    // Invalid values throw, so guard against NaN durations before metadata loads.
    const { duration, currentTime, playbackRate } = media;
    if (!Number.isFinite(duration) && duration !== Infinity) return;
    if (playbackRate === 0) return;
    try {
      session.setPositionState({
        duration,
        playbackRate,
        position: Math.min(Math.max(currentTime, 0), duration),
      });
    } catch (err) {
      console.warn("setPositionState rejected", err);
    }
  }

  async function load(i) {
    index = (i + playlist.length) % playlist.length;
    media.src = playlist[index].src;
    updateMetadata();
    try {
      await media.play();
    } catch (err) {
      // NotAllowedError: autoplay policy. Leave the UI in the paused state.
      if (err.name !== "NotAllowedError") throw err;
    }
  }

  setHandler("play", () => {
    media.play().catch((err) => console.warn("play() from media key rejected", err));
  });
  setHandler("pause", () => media.pause());
  setHandler("stop", () => {
    media.pause();
    media.currentTime = 0;
    session.playbackState = "none";
  });
  setHandler("seekbackward", (d) => {
    media.currentTime = Math.max(media.currentTime - (d.seekOffset ?? DEFAULT_SKIP_SECONDS), 0);
    updatePosition();
  });
  setHandler("seekforward", (d) => {
    media.currentTime = Math.min(
      media.currentTime + (d.seekOffset ?? DEFAULT_SKIP_SECONDS),
      media.duration,
    );
    updatePosition();
  });
  setHandler("seekto", (d) => {
    // fastSeek() is approximate and cheaper; use it while the user is scrubbing.
    if (d.fastSeek && "fastSeek" in media) media.fastSeek(d.seekTime);
    else media.currentTime = d.seekTime;
    updatePosition();
  });
  setHandler("previoustrack", () => load(index - 1));
  setHandler("nexttrack", () => load(index + 1));

  const onPlay = () => {
    session.playbackState = "playing";
    updatePosition();
  };
  const onPause = () => {
    session.playbackState = "paused";
    updatePosition();
  };
  const events = {
    play: onPlay,
    pause: onPause,
    ratechange: updatePosition,
    seeked: updatePosition,
    loadedmetadata: updatePosition,
    ended: () => load(index + 1),
  };
  for (const [type, fn] of Object.entries(events)) media.addEventListener(type, fn);

  updateMetadata();

  return function dispose() {
    for (const [type, fn] of Object.entries(events)) media.removeEventListener(type, fn);
    for (const action of [
      "play", "pause", "stop", "seekbackward", "seekforward",
      "seekto", "previoustrack", "nexttrack",
    ]) setHandler(action, null);
    session.metadata = null;
    session.playbackState = "none";
    try {
      session.setPositionState();
    } catch {
      /* older engines */
    }
  };
}

Media Session in an installed PWA

On Android the media notification of an installed PWA carries the app's name and opens the app when tapped. On desktop, Chromium's global media controls list sessions from installed app windows alongside tabs. Media playback continues when the PWA window is minimized or the phone is locked, as long as the page is not frozen or discarded; audible pages are exempt from Chromium's background freezing, which is why a music PWA keeps playing while a silent one gets frozen (see Page Visibility and the Page Lifecycle). On iOS and iPadOS, Safari 15 and later show Media Session metadata and controls on the lock screen and in Control Center; Home Screen web apps have historically had more audio-session bugs than Safari tabs, so test background playback and lock-screen controls in standalone mode on every iOS release you support, and expect the system to suspend the app once playback stops. Offline playback relies on media being cached; see Background Fetch for downloading large media files and Caching strategies for range-request handling.

Screen Wake Lock API

The Screen Wake Lock API prevents the display from dimming and locking while your page is visible: recipe apps, boarding passes, presentation remotes, turn-by-turn directions, sheet music and fitness timers all need it. It replaces the old hack of looping an invisible video.

Screen Wake Lock IDL
[SecureContext] partial interface Navigator {
  [SameObject] readonly attribute WakeLock wakeLock;
};

[SecureContext, Exposed=(Window)]
interface WakeLock {
  Promise<WakeLockSentinel> request(optional WakeLockType type = "screen");
};

[SecureContext, Exposed=(Window)]
interface WakeLockSentinel : EventTarget {
  readonly attribute boolean released;
  readonly attribute WakeLockType type;
  Promise<undefined> release();
  attribute EventHandler onrelease;
};

enum WakeLockType { "screen" };

The only type is "screen". A system wake lock type (keep the CPU running with the screen off) was removed from the specification and is not implemented anywhere, so there is no web way to keep a PWA running in the background with the screen off.

How request() decides

navigator.wakeLock.request("screen") rejects with NotAllowedError when any of the following is true, checked in this order by the specification:

  1. The document is not fully active (for example, it is in the back/forward cache).
  2. The document is not allowed to use the screen-wake-lock policy-controlled feature (default allowlist 'self', so cross-origin iframes need allow="screen-wake-lock").
  3. The user agent denies the lock. The specification explicitly allows implementations to ignore requests when battery is low.
  4. The document's visibility state is "hidden".
  5. The screen-wake-lock permission is denied.

No user activation is required, and in practice no browser shows a prompt: requests from a visible, top-level, secure page are granted. Every call returns a new WakeLockSentinel; the platform lock stays on while at least one sentinel is unreleased.

sequenceDiagram
    participant P as Page
    participant UA as Browser
    participant OS as Operating system
    P->>UA: navigator.wakeLock.request("screen")
    UA->>UA: fully active? policy? visible? permission?
    UA->>OS: acquire display wake lock
    UA-->>P: WakeLockSentinel (released = false)
    Note over P,UA: user switches tab or minimizes the PWA
    UA->>UA: visibilityState = hidden
    UA->>OS: release display wake lock
    UA-->>P: "release" event on sentinel (released = true)
    Note over P,UA: user returns
    P->>UA: visibilitychange (visible) → request("screen") again

Automatic release, and why you must re-acquire

Whenever the document becomes hidden, the browser releases every lock the page holds and fires release on each sentinel. It does not re-acquire when the page becomes visible again. A recipe app that requests a lock on load will therefore lose it the first time the user checks a message, which is the most common wake lock bug. The fix is to track the user's intent separately from the sentinel and re-request on visibilitychange:

src/platform/screen-wake-lock.js
/**
 * Keeps the screen awake while `enabled` is true and the page is visible.
 * Survives tab switches, window minimization and app switching on mobile.
 */
export class ScreenWakeLock extends EventTarget {
  #sentinel = null;
  #wanted = false;
  #pending = null; // in-flight request, so concurrent callers never create two sentinels

  static get supported() {
    return "wakeLock" in navigator;
  }

  get active() {
    return this.#sentinel !== null && !this.#sentinel.released;
  }

  constructor() {
    super();
    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible" && this.#wanted) this.#acquire();
    });
  }

  async enable() {
    this.#wanted = true;
    await this.#acquire();
  }

  async disable() {
    this.#wanted = false;
    const sentinel = this.#sentinel;
    this.#sentinel = null;
    if (sentinel && !sentinel.released) await sentinel.release();
  }

  #acquire() {
    // enable() and visibilitychange can race; share one in-flight request.
    this.#pending ??= this.#request().finally(() => {
      this.#pending = null;
    });
    return this.#pending;
  }

  async #request() {
    if (!ScreenWakeLock.supported || this.active) return;
    if (document.visibilityState !== "visible") return; // would reject anyway
    try {
      const sentinel = await navigator.wakeLock.request("screen");
      // The user may have called disable() while the request was pending.
      if (!this.#wanted) {
        await sentinel.release();
        return;
      }
      this.#sentinel = sentinel;
      sentinel.addEventListener("release", () => {
        this.dispatchEvent(new Event("change"));
      });
      this.dispatchEvent(new Event("change"));
    } catch (err) {
      // NotAllowedError: policy, low battery, hidden page or denied permission.
      this.dispatchEvent(new CustomEvent("error", { detail: err }));
    }
  }
}
src/pages/recipe.js
import { ScreenWakeLock } from "../platform/screen-wake-lock.js";

const wakeLock = new ScreenWakeLock();
const toggle = document.querySelector("#keep-awake");      // <input type="checkbox">
const status = document.querySelector("#keep-awake-status"); // <output>

toggle.hidden = !ScreenWakeLock.supported;
// The checkbox is the user's intent; the sentinel comes and goes with visibility.
toggle.addEventListener("change", () => (toggle.checked ? wakeLock.enable() : wakeLock.disable()));
wakeLock.addEventListener("change", () => {
  status.textContent = wakeLock.active ? "Screen stays on" : toggle.checked ? "Paused" : "";
});
wakeLock.addEventListener("error", (e) => {
  toggle.checked = false;
  console.warn("Wake lock unavailable:", e.detail.name, e.detail.message);
});

Expose the lock as a visible, user-controlled toggle ("Keep screen on while cooking"). Holding a lock silently drains the battery and users cannot tell why the phone never sleeps.

Safari, iOS and Home Screen web apps

Safari 16.4 shipped the Screen Wake Lock API on macOS, iOS and iPadOS in March 2023, but on iOS and iPadOS the lock did not work inside Home Screen web apps (standalone PWAs), only in Safari tabs. That was WebKit bug 254545. Apple's Safari 18.4 release fixed it: "The Screen Wake Lock API now also works in Home Screen Web Apps on iOS and iPadOS 18.4." If you support iOS versions 16.4 to 18.3 in standalone mode, request() resolves but the screen still dims; there is no reliable feature test for this, so key any workaround on the OS version and display mode, and keep the toggle labeled as best effort. The iOS & iPadOS platform page tracks other standalone-mode differences.

Firefox shipped the API in Firefox 126 (May 2024) on desktop and Android, which made Screen Wake Lock the first API on this page to be available in every engine in both browser tabs and installed apps.

Idle Detection API

The Idle Detection API reports whether the user is idle (no keyboard, mouse or touch input on the device for a threshold) and whether the screen is locked. Chat and collaboration PWAs use it to show "away" presence, kiosks use it to reset to the start screen, and IoT dashboards use it to stop expensive updates. Unlike Page Visibility, it reflects activity across the whole device, not just your page.

Chromium-only

Idle Detection shipped in Chrome 94 (desktop and Android). Edge shipped it, removed it in Edge 96, and re-enabled it in Edge 114. Mozilla and Apple have both declined to implement it because it reveals when a person is at their device, so treat it as an enhancement for Chromium browsers only.

Idle Detection IDL
enum UserIdleState { "active", "idle" };
enum ScreenIdleState { "locked", "unlocked" };

dictionary IdleOptions {
  [EnforceRange] unsigned long long threshold;
  AbortSignal signal;
};

[SecureContext, Exposed=(Window,DedicatedWorker)]
interface IdleDetector : EventTarget {
  constructor();
  readonly attribute UserIdleState? userState;
  readonly attribute ScreenIdleState? screenState;
  attribute EventHandler onchange;
  [Exposed=Window] static Promise<PermissionState> requestPermission();
  Promise<undefined> start(optional IdleOptions options = {});
};

The rules, from the specification:

  • IdleDetector.requestPermission() is window-only and requires transient activation; without it the promise rejects with NotAllowedError. Chrome shows a prompt the first time. The permission name for navigator.permissions.query() is "idle-detection".
  • start() rejects with TypeError if threshold is less than 60,000 ms. One minute is the minimum to limit how precisely a site can observe behavior.
  • start() rejects with InvalidStateError if the detector is already started, and with NotAllowedError if the permission is not granted or the idle-detection Permissions Policy (default 'self') blocks it.
  • Aborting the signal stops the detector and rejects a pending start() with the signal's reason. To restart, create a new detector.
  • userState and screenState are null until the first observation; after start() resolves they hold values and change fires on every transition.
src/presence/idle.js
/**
 * Presence tracking with Idle Detection where available and a
 * visibility-based fallback everywhere else.
 */
export async function watchPresence({ onChange, thresholdMs = 5 * 60_000, signal }) {
  const threshold = Math.max(thresholdMs, 60_000); // spec minimum

  if ("IdleDetector" in window) {
    const state = (await navigator.permissions
      .query({ name: "idle-detection" })
      .catch(() => null))?.state;

    if (state === "granted") {
      const detector = new IdleDetector();
      detector.addEventListener("change", () => {
        onChange({
          away: detector.userState === "idle" || detector.screenState === "locked",
          userState: detector.userState,
          screenState: detector.screenState,
          source: "idle-detection",
        });
      });
      await detector.start({ threshold, signal });
      return;
    }
  }

  // Fallback: page-level signals only. Coarser, but needs no permission.
  let timer;
  const reset = () => {
    clearTimeout(timer);
    onChange({ away: false, source: "fallback" });
    timer = setTimeout(() => onChange({ away: true, source: "fallback" }), threshold);
  };
  const onVisibility = () =>
    document.visibilityState === "hidden" ? onChange({ away: true, source: "fallback" }) : reset();
  for (const type of ["pointerdown", "keydown", "wheel", "touchstart"]) {
    addEventListener(type, reset, { passive: true, signal });
  }
  document.addEventListener("visibilitychange", onVisibility, { signal });
  signal?.addEventListener("abort", () => clearTimeout(timer));
  reset();
}

/** Call from a click handler: requestPermission() needs transient activation. */
export async function requestIdlePermission() {
  if (!("IdleDetector" in window)) return "unsupported";
  return IdleDetector.requestPermission(); // "granted" | "denied"
}

Because IdleDetector is exposed in dedicated workers, a heavy dashboard can run detection in the worker that also owns its WebSocket and pause the socket there. The permission itself must still be requested from the window.

Async Clipboard API

The Async Clipboard API (navigator.clipboard) replaces document.execCommand("copy") with promise-based reads and writes of text, HTML, PNG images and, in Chromium, custom formats. It is the backbone of "copy link", "copy as image", "paste from clipboard" and rich editor features, and the recommended fallback when the Web Share API is unavailable.

Clipboard IDL (abridged)
[SecureContext, Exposed=Window]
interface Clipboard : EventTarget {
  Promise<ClipboardItems> read(optional ClipboardUnsanitizedFormats formats = {});
  Promise<DOMString> readText();
  Promise<undefined> write(ClipboardItems data);
  Promise<undefined> writeText(DOMString data);
};

typedef (DOMString or Blob) ClipboardItemData;

[SecureContext, Exposed=Window]
interface ClipboardItem {
  constructor(record<DOMString, ClipboardItemData or Promise<ClipboardItemData>> items,
              optional ClipboardItemOptions options = {});
  readonly attribute PresentationStyle presentationStyle;  // Firefox and Safari
  readonly attribute FrozenArray<DOMString> types;
  Promise<Blob> getType(DOMString type);
  static boolean supports(DOMString type);
};

Text: writeText() and readText()

writeText() is the one-liner everyone needs; readText() returns the clipboard's text/plain representation (an empty string when there is none).

src/clipboard/copy-link.js
export async function copyText(text) {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch (err) {
    // NotAllowedError: no user activation (Firefox, Safari), document not
    // focused (Chromium), or blocked by Permissions Policy in an iframe.
    return legacyCopy(text);
  }
}

/** execCommand fallback: deprecated but still the only option in some WebViews. */
function legacyCopy(text) {
  const ta = document.createElement("textarea");
  ta.value = text;
  ta.setAttribute("readonly", "");
  ta.style.cssText = "position:fixed;inset:0 auto auto 0;opacity:0;pointer-events:none";
  document.body.append(ta);
  ta.select();
  let ok = false;
  try {
    ok = document.execCommand("copy");
  } catch {
    ok = false;
  }
  ta.remove();
  return ok;
}

Writing images and rich content

write() takes an array of ClipboardItem objects; each item maps MIME types to data. In practice browsers accept one item per write. The specification's mandatory data types are text/plain, text/html and image/png; every engine supports those three for both reading and writing (Chrome 76 for plain text and PNG and Chrome 86 for HTML, Firefox 127, Safari 13.1). Chrome 124 added image/svg+xml as an optional type. Other image formats such as JPEG must be converted to PNG first.

ClipboardItem.supports(type) (Chrome 121, Firefox 127, Safari 18.4) reports whether a type can be written, including web-prefixed custom formats. Use it instead of hard-coding browser checks.

The most important cross-browser detail is when the data is available. Safari requires navigator.clipboard.write() to be called synchronously inside the user gesture, but it accepts a promise of a Blob as the item value, so you can start the fetch or canvas encoding and hand over the pending promise. Chrome has accepted Promise<Blob> values since Chrome 98 and plain strings as values since Chrome 133; Firefox 127 accepts all forms. Writing the promise-based way therefore works everywhere current:

src/clipboard/copy-image.js
/**
 * Copies an image (any format the browser can decode) as PNG, plus a
 * text/plain fallback so pasting into a plain text field still yields the URL.
 * MUST be called synchronously from a click / key handler.
 */
export function copyImage(url) {
  if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") {
    return Promise.reject(new DOMException("Clipboard images unsupported", "NotSupportedError"));
  }

  // Start the work now, but don't await it before calling write():
  // Safari would lose the user activation.
  const pngPromise = fetch(url, { mode: "cors" })
    .then((r) => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.blob();
    })
    .then(toPng);

  const items = { "image/png": pngPromise };
  // text/plain as a Blob keeps compatibility with Chrome 98-132 (no string values).
  items["text/plain"] = new Blob([url], { type: "text/plain" });

  return navigator.clipboard.write([new ClipboardItem(items)]);
}

async function toPng(blob) {
  if (blob.type === "image/png") return blob;
  const bitmap = await createImageBitmap(blob);
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  canvas.getContext("2d").drawImage(bitmap, 0, 0);
  bitmap.close();
  return canvas.convertToBlob({ type: "image/png" });
}

When you copy rich text, supply both text/html and text/plain in the same item so the paste target can pick. Chromium strips scripts and other active content from HTML when reading it back (sanitization); a page that needs the exact markup it wrote can pass read({ unsanitized: ["text/html"] }) in Chrome 122 and later.

Reading: read() and the three permission models

Reading the clipboard is where engines differ most. The specification requires transient activation and a user-initiated paste, but each browser implements the protection differently:

Behavior Chromium (Chrome, Edge, Samsung Internet) Firefox Safari
writeText() / write() Allowed with transient activation or a granted clipboard-write permission (since Chrome 107) Requires transient activation Requires transient activation
readText() / read() Prompts for clipboard-read permission once; grant persists Requires transient activation; shows a one-item "Paste" context menu Requires transient activation; shows a "Paste" callout
Paste prompt suppressed After the permission is granted When clipboard content came from the same origin When clipboard content came from the same origin
permissions.query({name: "clipboard-read"}) Supported Throws TypeError (not supported, not planned) Throws TypeError (not supported, not planned)
Iframe access Needs allow="clipboard-read; clipboard-write" Transient activation Transient activation
Focus required Yes (NotAllowedError: Document is not focused) No No

Firefox shipped readText() to web content in Firefox 125 and read(), write() and ClipboardItem in Firefox 127. In Firefox the "Paste" menu item becomes clickable only after a short delay (the security.dialog_enable_delay preference, one second by default), so a page cannot trick the user into clicking it by accident. Build your paste UX so that the prompt appears directly under the user's pointer as a result of their click on your own "Paste" button, which is where both engines anchor it.

src/clipboard/paste.js
/**
 * Reads the best representation from the clipboard.
 * Call from a click handler (Firefox and Safari require transient activation).
 * Returns { kind: "image" | "html" | "text", data } or null.
 */
export async function readClipboard() {
  if (navigator.clipboard?.read) {
    try {
      const items = await navigator.clipboard.read();
      for (const item of items) {
        const imageType = item.types.find((t) => t.startsWith("image/"));
        if (imageType) return { kind: "image", data: await item.getType(imageType) };
        if (item.types.includes("text/html")) {
          const html = await (await item.getType("text/html")).text();
          return { kind: "html", data: html }; // sanitize before inserting into the DOM
        }
        if (item.types.includes("text/plain")) {
          return { kind: "text", data: await (await item.getType("text/plain")).text() };
        }
      }
      return null;
    } catch (err) {
      if (err.name === "NotAllowedError") return null; // denied or dismissed the prompt
      throw err;
    }
  }
  if (navigator.clipboard?.readText) {
    return { kind: "text", data: await navigator.clipboard.readText() };
  }
  return null;
}

Clipboard HTML is untrusted input

Treat everything read from the clipboard as attacker-controlled. Chromium's sanitization removes scripts but not every dangerous construct your app might mishandle, and the unsanitized option removes that protection entirely. Run pasted HTML through a sanitizer (for example the HTML Sanitizer API where available, or a vetted library) before inserting it, and apply your Content Security Policy.

Paste events versus the Async Clipboard API

For "paste into this editor" you do not need navigator.clipboard.read() at all. The synchronous paste event exposes event.clipboardData (a DataTransfer) without any permission prompt in every browser, because the user already chose to paste with Ctrl+V or the context menu. Use the async API only for your own Paste button or for reading without a paste gesture.

src/editor/paste-handler.js
editor.addEventListener("paste", (event) => {
  const files = [...event.clipboardData.files].filter((f) => f.type.startsWith("image/"));
  if (files.length) {
    event.preventDefault();
    for (const file of files) insertImage(file); // Blob, no permission needed
    return;
  }
  const html = event.clipboardData.getData("text/html");
  if (html) {
    event.preventDefault();
    insertSanitizedHtml(html);
  }
});

Likewise, copy and cut events let you replace what the user copies with event.clipboardData.setData() and event.preventDefault(), for example to append an attribution link.

Web custom formats

Chrome 104 added web custom formats: MIME types prefixed with web (for example "web application/vnd.example.slide+json"). Chromium writes them to the OS clipboard unsanitized under a browser-managed mapping, so two web apps, or a web app and a native app that knows the convention, can exchange proprietary formats. Always write a standard format alongside (text/plain or text/html) so ordinary apps can paste something useful.

src/clipboard/custom-format.js
const SLIDE_TYPE = "web application/vnd.example.slide+json";

export function copySlide(slide) {
  const json = JSON.stringify(slide);
  const record = {
    "text/plain": new Blob([slide.title], { type: "text/plain" }),
  };
  if (ClipboardItem.supports?.(SLIDE_TYPE)) {
    record[SLIDE_TYPE] = new Blob([json], { type: SLIDE_TYPE.slice(4) });
  }
  return navigator.clipboard.write([new ClipboardItem(record)]);
}

The clipboardchange event

Chrome 144 added a clipboardchange event on navigator.clipboard, fired when the system clipboard contents change, with a ClipboardChangeEvent carrying types and a changeId. From Chrome 145, the event fires only when the page has sticky activation or the clipboard-read permission. It lets an editor enable or disable its Paste button based on whether compatible data is available, without polling read(). Firefox and Safari do not support it; feature-detect with "onclipboardchange" in navigator.clipboard.

Contact Picker API

The Contact Picker API opens the operating system's contact chooser and returns only the fields and contacts the user selects. A messaging, invitation or payments PWA gets names, email addresses, phone numbers, postal addresses or avatars without ever seeing the whole address book.

Android only

The Contact Picker API is implemented in Chrome for Android 80 and later (and other Chromium browsers on Android). It is not available in any desktop browser. Safari on iOS 14.5 and later has an implementation behind the "Contact Picker API" experimental feature flag, which users must enable manually, so do not rely on it.

src/contacts/pick.js
const SUPPORTED = "contacts" in navigator && "ContactsManager" in window;

/**
 * Must be called from a user gesture in a top-level document.
 * Returns [] if the user cancels.
 */
export async function pickRecipients() {
  if (!SUPPORTED) throw new DOMException("Contact Picker unavailable", "NotSupportedError");

  // Ask only for fields the platform can provide; requesting an unsupported
  // property rejects with TypeError.
  const available = await navigator.contacts.getProperties(); // e.g. ["email","name","tel","address","icon"]
  const wanted = ["name", "email", "tel"].filter((p) => available.includes(p));

  try {
    const contacts = await navigator.contacts.select(wanted, { multiple: true });
    // Each contact: { name: string[], email: string[], tel: string[], address?: ContactAddress[], icon?: Blob[] }
    return contacts.map((c) => ({
      name: c.name?.[0] ?? "",
      email: c.email?.[0] ?? null,
      tel: c.tel?.[0] ?? null,
    }));
  } catch (err) {
    if (err.name === "InvalidStateError") {
      // Another picker is open, not top-level, or the picker failed to launch.
      return [];
    }
    throw err; // SecurityError (no user activation) or TypeError (bad properties)
  }
}

select(properties, { multiple }) rejects with InvalidStateError when the browsing context is not top-level, when a picker is already open or when launching fails; with SecurityError when there is no user activation; and with TypeError when properties is empty or contains an unsupported value. Every returned field is an array, because a contact can have several emails or numbers; the user may also deselect individual fields in the picker, so any field can be an empty array. Addresses come back as ContactAddress objects with the same shape as the Payment Request API's address (addressLine, city, region, postalCode, country and so on), and icons as Blobs.

Always provide a manual entry form next to the picker button; hide the button when SUPPORTED is false rather than showing an error.

Screen Orientation API

screen.orientation reports the current orientation and, on devices that allow it, locks the orientation. Games, video players, camera and scanner PWAs use it; the manifest orientation member sets the default orientation for an installed app at launch, while lock() changes it at runtime.

src/platform/orientation.js
const o = screen.orientation;
console.log(o.type);  // "portrait-primary" | "portrait-secondary" | "landscape-primary" | "landscape-secondary"
console.log(o.angle); // 0 | 90 | 180 | 270, relative to the device's natural orientation

o.addEventListener("change", () => {
  document.documentElement.dataset.orientation = o.type.split("-")[0]; // "portrait" | "landscape"
});

Reading orientation works in every engine (Chrome 38, Firefox 43, Safari 16.4). Before Safari 16.4, iOS only had the legacy window.orientation number and orientationchange event, which are deprecated. For layout, prefer the CSS orientation media feature or container queries; the JavaScript API is for behavior such as pausing a game or rotating a canvas.

lock() and unlock()

screen.orientation.lock(type) accepts "any", "natural", "landscape", "portrait", "portrait-primary", "portrait-secondary", "landscape-primary" or "landscape-secondary" and returns a promise that resolves once the rotation has happened. It rejects with:

  • InvalidStateError if the document is not fully active.
  • SecurityError if the document is hidden, or is in a sandboxed iframe without allow-orientation-lock.
  • NotSupportedError if the platform cannot lock (every desktop in Chromium and Safari) or cannot lock to that orientation.
  • AbortError if another lock() call or unlock() supersedes it while it is pending.

unlock() returns undefined synchronously and restores the default orientation (the manifest orientation for installed apps, otherwise the OS setting).

The precondition that trips most developers is fullscreen. In Chromium on Android, the browser-side check in ScreenOrientationProvider requires that the web contents is either in element fullscreen (requestFullscreen()) or running in the fullscreen display mode; otherwise the lock fails with a "fullscreen required" result. An installed PWA with "display": "standalone" is not exempt: it must still enter element fullscreen before locking. Chromium also unlocks automatically when the page leaves fullscreen or navigates to a new document. Firefox 144 (October 2025) enabled lock() and unlock() on Android and on Windows tablets. Safari exposes screen.orientation but has no lock() implementation, on iPhone or iPad.

src/game/landscape.js
/**
 * Enter fullscreen and lock to landscape, from a click handler.
 * Degrades gracefully: fullscreen without a lock, or neither.
 */
export async function startLandscapeGame(container) {
  if (document.fullscreenEnabled && !document.fullscreenElement) {
    // requestFullscreen() needs the click's transient activation;
    // lock() itself does not need activation, so it can run after the await.
    await container.requestFullscreen({ navigationUI: "hide" });
  }
  try {
    await screen.orientation.lock("landscape");
    return "locked";
  } catch (err) {
    // NotSupportedError on desktop and Safari; SecurityError if the tab got hidden.
    console.info("Orientation lock unavailable:", err.name);
    return "unlocked";
  }
}

document.addEventListener("fullscreenchange", () => {
  // Chromium unlocks when fullscreen exits; mirror that in app state.
  if (!document.fullscreenElement) pauseGame();
});

Vibration API

navigator.vibrate(pattern) asks the device to vibrate for a duration or an alternating vibrate / pause pattern in milliseconds. It is useful for haptic confirmation in games and scanners on Android, and nothing else.

src/platform/haptics.js
export function haptic(pattern = 15) {
  // Returns false when unsupported, hidden, lacking sticky activation or denied.
  return typeof navigator.vibrate === "function" && navigator.vibrate(pattern);
}

haptic(10);                  // short tick
haptic([100, 50, 100]);      // buzz, pause, buzz
navigator.vibrate(0);        // cancel any running pattern (also: vibrate([]))

The specification's algorithm:

  • Returns false without vibrating when the page lacks sticky activation (the user has never interacted with it) or when the document is not visible. Chromium has enforced the user-gesture requirement since Chrome 60.
  • Truncates patterns longer than the implementation's maximum length and caps each entry at the maximum duration; the specification uses 10 entries and 10,000 ms, and implementations may choose other limits.
  • An empty pattern, a single 0, or a device without a vibration motor returns true and cancels any running pattern. With an even-length pattern the final pause has no effect.
  • A running pattern stops when the page becomes hidden.

Support is narrow and shrinking. Chromium browsers on Android support it (Chrome 32 and later; cross-origin iframes are blocked since Chrome 55). Safari has never implemented it. Firefox for Android has returned true without vibrating since Firefox 79 because of abuse concerns, and Firefox removed the API from desktop in Firefox 129. Never convey information only through vibration; it is a supplement to visual feedback (see Accessibility).

Page Visibility and the Page Lifecycle

A PWA has no reliable "app closed" event. What it has is a set of lifecycle signals: visibilitychange from the Page Visibility API (every engine), pagehide / pageshow for navigation and the back/forward cache (every engine), and Chromium's Page Lifecycle additions freeze, resume and document.wasDiscarded (Chrome 68). Using them correctly decides whether your app saves drafts, flushes analytics, releases resources and restores state after the operating system has killed it.

document.visibilityState and visibilitychange

document.visibilityState is "visible" or "hidden" (the old "prerender" value is gone), and document.hidden is the boolean equivalent. The page is hidden when its tab is in the background, the browser or PWA window is minimized, the screen is locked, or on mobile when the user switches apps. Some browsers also report "hidden" when another window completely covers the browser window (Chromium's native window occlusion tracking on Windows and macOS, for example), so do not treat hidden as "minimized". visibilitychange fires on document whenever the value changes.

Two historical details still matter for code you inherit. Safari before 14.1 did not fire visibilitychange when the user navigated away from the page, so older code also listens for pagehide. And on mobile, the transition to hidden is often the last event your page ever receives: after the user swipes the app away, iOS and Android can kill the process without firing pagehide, beforeunload or unload. Treat visibilitychange to hidden as "save everything now".

src/lifecycle/persist-on-hide.js
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "hidden") return;

  // 1. Persist unsaved UI state synchronously-ish: IndexedDB writes started
  //    here usually complete, but keep them small.
  saveDraftToIndexedDB();

  // 2. Flush analytics with sendBeacon (or fetch keepalive), which survive
  //    the page being frozen or killed right after this handler.
  const payload = JSON.stringify(collectPendingEvents());
  navigator.sendBeacon("/analytics", new Blob([payload], { type: "application/json" }));
});

See Analytics for PWAs for session measurement built on these events and IndexedDB for durable writes.

The Page Lifecycle states

Chrome's Page Lifecycle model names six states and the events that move a page between them. Firefox and Safari implement the underlying behavior (background throttling, the back/forward cache, tab unloading) but not the freeze / resume events.

stateDiagram-v2
    [*] --> Active
    Active --> Passive: blur
    Passive --> Active: focus
    Passive --> Hidden: visibilitychange (hidden)
    Hidden --> Passive: visibilitychange (visible)
    Hidden --> Frozen: freeze
    Frozen --> Hidden: resume
    Frozen --> Active: resume, pageshow (bfcache restore)
    Hidden --> Terminated: pagehide (persisted = false)
    Frozen --> Discarded: browser reclaims memory
    Terminated --> [*]
    Discarded --> [*]
State Meaning What your code should do
Active Visible and has input focus Prioritize responsiveness
Passive Visible but not focused (another window is focused) Keep animating; persist unsaved state periodically
Hidden Not visible, still running (throttled timers) Save state, flush analytics, stop UI work and polling
Frozen Task queues suspended; no timers or callbacks run Close IndexedDB connections, WebSockets, BroadcastChannel and WebRTC; release Web Locks
Terminated Page is being unloaded Nothing reliable; rely on the hidden state
Discarded Unloaded by the browser to save memory, tab still shown Detect on the next load with document.wasDiscarded and restore state

Chromium freezes pages in the background (more aggressively with Energy Saver or on memory pressure), and every browser freezes a page when it enters the back/forward cache. Frozen pages do not run timers, so a setInterval heartbeat simply stops; code that assumes continuity must use resume or pageshow to catch up.

src/lifecycle/lifecycle.js
/**
 * Emits normalized lifecycle transitions across engines.
 * Chromium adds freeze/resume/wasDiscarded; elsewhere they simply never fire.
 */
export function observeLifecycle(onChange) {
  const getState = () =>
    document.visibilityState === "hidden" ? "hidden" : document.hasFocus() ? "active" : "passive";

  let state = getState();
  const set = (next, event) => {
    if (next === state) return;
    const prev = state;
    state = next;
    onChange({ prev, next, event: event.type });
  };

  const opts = { capture: true };
  // capture: true so listeners run before any stopPropagation() in app code.
  for (const type of ["pageshow", "focus", "blur", "visibilitychange", "resume"]) {
    addEventListener(type, (e) => set(getState(), e), opts);
  }
  addEventListener("freeze", (e) => set("frozen", e), opts);
  addEventListener(
    "pagehide",
    (e) => set(e.persisted ? "frozen" : "terminated", e), // persisted: entering bfcache
    opts,
  );

  if (document.wasDiscarded) {
    // The browser discarded this tab while hidden; the user just came back.
    onChange({ prev: "discarded", next: state, event: "load" });
  }
}

The listeners are registered on window in the capture phase on purpose. freeze, resume and visibilitychange are dispatched at document, and freeze and resume do not bubble, but the capture phase always travels from window down to the target, so one set of capturing listeners on window sees every lifecycle event, and it sees them before any application handler can call stopPropagation().

The back/forward cache, pageshow and pagehide

When the user navigates away and the page is eligible for the back/forward cache (bfcache), the browser fires pagehide with event.persisted === true and freezes the page instead of destroying it. Going back fires pageshow with persisted === true and the page resumes exactly where it was, with no new load event. In a multi-page PWA this makes back navigations instant; see SPA vs MPA PWAs. Things that commonly prevent bfcache include unload handlers, Cache-Control: no-store on the document in some browsers, and open connections that cannot be suspended. Refresh stale data in pageshow when persisted is true, because the page may have been cached for minutes.

The end of the unload event

unload never fired reliably on mobile, blocks the bfcache, and is being removed. Chrome is rolling out the deprecation of unload handlers for all origins over eight milestones, starting at 1% of page loads in Chrome 146 (March 2026) and planned to reach 100% around Chrome 154 (September 2026); Google notes the versions and dates may change. Once the default flips for a page, unload listeners are ignored. You can opt out of unload early, and gain bfcache eligibility, with the Permissions-Policy: unload=() response header. Replace unload with visibilitychange (for saving state) and pagehide (for navigation-specific cleanup), and use beforeunload only conditionally, while there are unsaved changes: add the listener when the user starts editing and remove it after saving, because some browsers have historically excluded pages with beforeunload handlers from the bfcache.

Fullscreen API

The Fullscreen API puts a single element (and its descendants) into the top layer and asks the operating system to give the browser the whole screen. Games, video players, presentations, image viewers and kiosk UIs use it.

Member Purpose
element.requestFullscreen(options) Returns a promise; requires transient activation
document.exitFullscreen() Returns a promise; no activation needed
document.fullscreenElement The current fullscreen element or null (also on ShadowRoot)
document.fullscreenEnabled false if Permissions Policy or the platform forbid fullscreen
fullscreenchange, fullscreenerror Fired on the element and bubble to document
:fullscreen, ::backdrop CSS for the fullscreen element and the layer behind it

requestFullscreen() options:

  • navigationUI: "auto" (default), "hide" or "show", a hint about whether mobile browsers keep their navigation bars (Chrome 71, Safari 16.4; Firefox ignores it).
  • screen: a ScreenDetailed from the Window Management API to go fullscreen on a specific display (Chrome 100 on desktop).
  • keyboardLock: "none" (default) or "browser", which lets the page receive keys the browser normally reserves and changes the exit gesture from a press to a long-press of Esc. It shipped in Firefox 151, and MDN's compatibility data records it for Safari 26.4. A browser that knows the option but not the value rejects with NotSupportedError.

The promise rejects with a TypeError (and fullscreenerror fires) when the document is not fully active, the element is not connected, the fullscreen Permissions Policy (default 'self'; iframes also need allow="fullscreen" or the legacy allowfullscreen attribute) forbids it, the element is a <dialog> or a showing popover, or there is no transient activation. Exiting happens by the user (Esc, a system gesture), by exitFullscreen(), or automatically when the document is hidden or navigated.

src/platform/fullscreen.js
const doc = document;

export const fullscreenSupported =
  doc.fullscreenEnabled || doc.webkitFullscreenEnabled || false;

export function currentFullscreenElement() {
  return doc.fullscreenElement ?? doc.webkitFullscreenElement ?? null;
}

/** Call from a user gesture. Resolves to true when fullscreen was entered. */
export async function enterFullscreen(el, options = { navigationUI: "hide" }) {
  try {
    if (el.requestFullscreen) {
      await el.requestFullscreen(options);
      return true;
    }
    if (el.webkitRequestFullscreen) {
      // Safari before 16.4 and older iPadOS: prefixed and not promise-based.
      el.webkitRequestFullscreen();
      return true;
    }
    if (el instanceof HTMLVideoElement && el.webkitEnterFullscreen) {
      // iPhone Safari: the only fullscreen available is the native video player.
      el.webkitEnterFullscreen();
      return true;
    }
  } catch (err) {
    console.warn("Fullscreen refused:", err.message);
  }
  return false;
}

export function exitFullscreen() {
  if (!currentFullscreenElement()) return Promise.resolve();
  return (doc.exitFullscreen ?? doc.webkitExitFullscreen)?.call(doc);
}

export async function toggleFullscreen(el) {
  return currentFullscreenElement() ? exitFullscreen() : enterFullscreen(el);
}
src/styles/fullscreen.css
.player:fullscreen {
  width: 100vw;
  height: 100vh;
  background: #000;
}
.player::backdrop {
  background: #000; /* hides the page behind letterboxed content */
}

Fullscreen on iPhone and iPad

On iPadOS, Safari 16.4 unprefixed the API; element fullscreen shows an overlay close button that cannot be hidden, and a downward swipe exits, which makes it unsuitable for some games. On iPhone there is no element fullscreen at all: only <video> can go fullscreen through the native player (webkitEnterFullscreen()), and document.fullscreenEnabled is false. A Home Screen web app with "display": "fullscreen" or "standalone" already hides Safari's UI, which is usually the better answer on iPhone.

Fullscreen API versus the fullscreen display mode

An installed PWA with "display": "fullscreen" launches without browser UI or (on Android) system bars, but that is not element fullscreen: document.fullscreenElement stays null and :fullscreen does not match. Detect it with the display-mode: fullscreen media feature instead. The two combine: a standalone PWA can still call requestFullscreen() for a video. The display modes page covers the manifest side, and App-like UX patterns covers when each is appropriate.

Keyboard Lock for games and remote desktops

Chromium on desktop (Chrome 68 and later) also has navigator.keyboard.lock(keyCodes?), which captures system keys such as Esc, Alt+Tab or Ctrl+W while the page is in fullscreen that the page itself requested. Pass specific KeyboardEvent.code values to capture only what you need; navigator.keyboard.unlock() releases them. Users exit by holding Esc. It is ignored outside fullscreen, which is why the standardized keyboardLock option on requestFullscreen() in Firefox and Safari folds both steps into one call.

Picture-in-Picture for video

The Picture-in-Picture (PiP) API moves a <video> element's frames into an always-on-top floating window that survives tab switches and app switches, so users can keep watching or keep a camera self-view visible.

src/media/pip.js
export const pipSupported =
  "pictureInPictureEnabled" in document && document.pictureInPictureEnabled;

/** Call from a click handler. */
export async function togglePictureInPicture(video) {
  if (document.pictureInPictureElement) {
    await document.exitPictureInPicture();
    return null;
  }
  if (video.readyState === HTMLMediaElement.HAVE_NOTHING) {
    // Waiting for loadedmetadata here would outlive the click's transient activation.
    // Keep the PiP button disabled until loadedmetadata instead (see bindPipState).
    throw new DOMException("Video has no metadata yet", "InvalidStateError");
  }
  const pipWindow = await video.requestPictureInPicture(); // first await in the handler
  pipWindow.addEventListener("resize", () => {
    // App-specific: pick an ABR rendition that fits the floating window, not the page player.
    selectRendition(pipWindow.width, pipWindow.height);
  });
  return pipWindow;
}

/** Keeps in-page controls in sync, including PiP closed from the floating window. */
export function bindPipState(video, button, onChange) {
  const sync = () => {
    button.hidden = !pipSupported || video.disablePictureInPicture;
    button.disabled = video.readyState === HTMLMediaElement.HAVE_NOTHING;
  };
  video.addEventListener("loadedmetadata", sync);
  video.addEventListener("emptied", sync); // src changed: metadata is gone again
  video.addEventListener("enterpictureinpicture", (e) => onChange(true, e.pictureInPictureWindow));
  video.addEventListener("leavepictureinpicture", () => onChange(false, null));
  sync();
}

requestPictureInPicture() resolves with a PictureInPictureWindow (width, height and a resize event) and rejects with:

  • NotAllowedError when no element is currently in PiP and the document lacks transient activation (switching PiP from one video to another does not need a new gesture).
  • InvalidStateError when the video's readyState is HAVE_NOTHING, it has no video track, or it has the disablepictureinpicture attribute.
  • SecurityError when the picture-in-picture Permissions Policy blocks it.
  • NotSupportedError when the user or platform disabled PiP.

Only one element per document can be in PiP; requesting it for a second video moves the window's content. The media keeps playing through the page's element, so pausing, seeking and Media Session handlers continue to work. A MediaStream from getUserMedia() or canvas.captureStream() can be shown in PiP too, which was the workaround for custom PiP content before Document Picture-in-Picture.

Support: Chrome 69 on desktop and Chrome 105 on Android, Safari 13.1 on macOS and Safari on iOS and iPadOS, and, new in 2026, Firefox 153 on desktop (July 2026). Firefox has long offered its own PiP toggle over videos, but until version 153 pages could not trigger or observe it. disablePictureInPicture in Firefox only hides that toggle.

Document Picture-in-Picture

Document Picture-in-Picture opens an always-on-top window that contains arbitrary HTML, not just video: a video player with custom controls and captions, a meeting grid with mute buttons, a timer, a stock ticker, a music mini-player or a chat. The window is a real same-origin Window, so you move existing DOM nodes into it and they keep their event listeners and state.

Chrome and Edge shipped it in version 116 on desktop, and Firefox 151 (May 2026) shipped it on desktop platforms. It is not available on Android, iOS or in Safari.

requestWindow() in detail

const pipWindow = await documentPictureInPicture.requestWindow({
  width: 400,                          // viewport size in CSS px; both or neither
  height: 240,
  disallowReturnToOpener: false,       // true hides the "back to tab" button (Chrome 124)
  preferInitialWindowPlacement: false, // true ignores the remembered size/position (Chrome 130)
});

The algorithm from the specification:

  1. Throws NotAllowedError unless called from a top-level window (not an iframe, and not from a PiP window itself).
  2. Throws NotAllowedError without transient activation, and then consumes it.
  3. Throws RangeError if only one of width and height is non-zero, or either is negative. The browser may clamp sizes it considers too large or too small.
  4. Closes any existing PiP window of this opener: there is at most one per top-level document.
  5. Creates a new window with an about:blank document whose origin is the opener's origin and whose base URL falls back to the opener's, so relative URLs resolve correctly.
  6. Fires enter on documentPictureInPicture (a DocumentPictureInPictureEvent with a window property) and resolves with the new Window.

The PiP window cannot be navigated (any navigation closes it), cannot be moved with moveTo() / moveBy(), cannot go fullscreen, and closes when the opener closes or navigates. Chrome allows resizeTo() / resizeBy() from the PiP window with user activation (Chrome 121) and window.focus() on the opener from the PiP window to bring the tab back (Chrome 123). documentPictureInPicture.window returns the current PiP window or null.

A complete mini-player

The window starts empty and unstyled. You must copy stylesheets yourself (the early copyStyleSheets option was removed from the specification), move your element in, and move it back when the window closes (pagehide fires on the PiP window).

src/media/mini-player.js
/**
 * Moves `playerEl` into a Document Picture-in-Picture window and back.
 * Falls back to video-only PiP when Document PiP is unavailable.
 */
export async function openMiniPlayer(playerEl, videoEl) {
  if (!("documentPictureInPicture" in window)) {
    return videoEl.requestPictureInPicture(); // Safari, older Firefox, Android
  }

  // Already open: focus-free no-op instead of closing and reopening.
  if (documentPictureInPicture.window) return documentPictureInPicture.window;

  const placeholder = document.createElement("div");
  placeholder.className = "player-placeholder";
  placeholder.style.height = `${playerEl.offsetHeight}px`;

  const pipWindow = await documentPictureInPicture.requestWindow({
    width: playerEl.clientWidth,
    height: playerEl.clientHeight,
  });

  copyStyles(document, pipWindow.document);
  pipWindow.document.documentElement.classList.add("in-pip"); // CSS hook

  playerEl.replaceWith(placeholder);
  pipWindow.document.body.append(playerEl); // adoptNode happens implicitly

  pipWindow.addEventListener("pagehide", () => {
    // Fires when the user closes the window, clicks "back to tab" or the opener navigates.
    placeholder.replaceWith(playerEl);
  }, { once: true });

  return pipWindow;
}

function copyStyles(fromDoc, toDoc) {
  for (const sheet of fromDoc.styleSheets) {
    try {
      // Same-origin sheets: copy the rules (works for constructed and inline sheets).
      const css = [...sheet.cssRules].map((r) => r.cssText).join("\n");
      const style = toDoc.createElement("style");
      style.textContent = css;
      toDoc.head.append(style);
    } catch {
      // Cross-origin sheet: cssRules throws SecurityError, so link it instead.
      if (!sheet.href) continue;
      const link = toDoc.createElement("link");
      link.rel = "stylesheet";
      link.href = sheet.href;
      link.media = sheet.media.mediaText;
      toDoc.head.append(link);
    }
  }
  // Adopted (constructed) stylesheets are realm-bound: recreate them.
  for (const sheet of fromDoc.adoptedStyleSheets ?? []) {
    const clone = new toDoc.defaultView.CSSStyleSheet();
    clone.replaceSync([...sheet.cssRules].map((r) => r.cssText).join("\n"));
    toDoc.adoptedStyleSheets = [...toDoc.adoptedStyleSheets, clone];
  }
}
Deep dive: cross-realm gotchas inside the PiP window

Elements moved into the PiP window now belong to a different Window (a different JavaScript realm). Four things break in practice:

  • instanceof checks against the opener's constructors fail: el instanceof HTMLElement is false for elements in the PiP document. Use el.nodeType or pipWindow.HTMLElement.
  • requestAnimationFrame, ResizeObserver, matchMedia and getComputedStyle must be called on pipWindow, or they observe the wrong viewport and throttle with the (possibly hidden) opener.
  • Keyboard shortcuts registered on the opener's document do not fire while the PiP window has focus; register them on pipWindow.document too.
  • Custom elements are defined per realm. Elements that were already upgraded keep working, but new ones created with pipWindow.document.createElement() are not upgraded unless you define them in pipWindow.customElements.

Use the display-mode: picture-in-picture media query (Chrome 123, Firefox 151; see Display Modes) or the .in-pip class above to switch to a compact layout inside the window. Because the PiP document is a separate media query context, the query matches only inside the floating window.

Automatic picture-in-picture

Chrome on desktop can open a PiP window automatically when the user switches away from the tab, without a gesture, if the page registered a Media Session enterpictureinpicture action handler and meets the eligibility rules. Chrome 120 introduced it for video-conferencing pages that are actively capturing the camera or microphone, and Chrome later extended it to media playback. Users control it through a per-site setting that is on by default. Inside the handler, call either video.requestPictureInPicture() or documentPictureInPicture.requestWindow():

src/meeting/auto-pip.js
try {
  navigator.mediaSession.setActionHandler("enterpictureinpicture", async (details) => {
    // details.enterPictureInPictureReason: "useraction" | "contentoccluded" | "other" (Chrome 142, experimental)
    if (!documentPictureInPicture.window) await openMiniPlayer(meetingGrid, selfView);
  });
} catch {
  // Action not supported in this browser.
}

Screen Capture API

navigator.mediaDevices.getDisplayMedia() captures a browser tab, an application window or a whole monitor as a MediaStream, for screen sharing in meeting apps, bug-report recorders, tutorials and remote support. Unlike camera access, the permission is never persisted: the browser shows its picker on every call, and the user chooses the surface. Options only influence which choices are offered and how; they never let the page choose the surface.

It is available on desktop in Chrome 72, Edge 79, Firefox 66 and Safari 13. No mobile browser supports it: Chrome for Android and Firefox for Android exposed the method for a while but it always rejected, and it does not exist in iOS Safari.

getDisplayMedia() options and their defaults

Option Values (default) Effect Support
video true or constraints (true) Must not be false (TypeError). Constraints such as displaySurface, width, frameRate, cursor apply after selection; min and exact are not allowed. All
audio false or constraints Request an audio track (tab audio everywhere in Chrome; system audio on Windows and ChromeOS when sharing a screen) Chrome 74
controller CaptureController Focus behavior and Captured Surface Control after capture starts Chrome 109
preferCurrentTab false Offer the current tab prominently Chrome 94
selfBrowserSurface "include" / "exclude" ("exclude") Whether the current tab is offered at all, preventing hall-of-mirrors captures Chrome 112
surfaceSwitching "include" / "exclude" ("exclude") Show a "Share this tab instead" button during tab capture Chrome 107
systemAudio "include" / "exclude" ("include") Offer system audio when a monitor is shared Chrome 105
monitorTypeSurfaces "include" / "exclude" ("include") Offer entire screens; exclude them to protect users from oversharing Chrome 119
windowAudio "exclude" / "system" / "window" Audio when a window is shared; Chrome 141 supports "exclude" and "system" only Chrome 141 (partial)

Support data as of September 2026. See MDN getDisplayMedia() compatibility for live data.

Exceptions: NotAllowedError when the user cancels the picker or the display-capture Permissions Policy blocks the call; InvalidStateError without transient activation, when the document is not fully active or focused, or when the controller was already used; NotFoundError when no source is available; NotReadableError when the OS refuses (on macOS, the browser lacks the Screen Recording privacy permission, which the user must grant in System Settings and which requires restarting the browser); TypeError for invalid options.

A complete screen-share session

src/meeting/screen-share.js
/**
 * Starts a screen share from a click handler and returns a handle.
 * Handles "Stop sharing" in browser UI, focus behavior and OS-level denial.
 */
export async function startScreenShare({ onEnded } = {}) {
  if (!navigator.mediaDevices?.getDisplayMedia) {
    throw new DOMException("Screen sharing is not supported on this device", "NotSupportedError");
  }

  const controller = "CaptureController" in window ? new CaptureController() : undefined;

  let stream;
  try {
    stream = await navigator.mediaDevices.getDisplayMedia({
      video: { frameRate: { ideal: 15, max: 30 }, cursor: "always" },
      audio: true,
      controller,
      selfBrowserSurface: "exclude",
      surfaceSwitching: "include",
      monitorTypeSurfaces: "include",
    });
  } catch (err) {
    if (err.name === "NotAllowedError") return null; // user cancelled: not an error
    if (err.name === "NotReadableError") {
      throw new Error("The operating system blocked screen recording. Check privacy settings.");
    }
    throw err;
  }

  const [videoTrack] = stream.getVideoTracks();
  const { displaySurface } = videoTrack.getSettings(); // "browser" | "window" | "monitor"

  // Conditional focus: must be decided right after the promise resolves,
  // and only applies to tab and window captures.
  if (controller && displaySurface !== "monitor") {
    try {
      controller.setFocusBehavior("no-focus-change"); // keep the meeting app focused
    } catch {
      /* called too late or unsupported for this surface */
    }
  }

  // Content hint helps the encoder: "detail" for slides and code, "motion" for video.
  videoTrack.contentHint = displaySurface === "browser" ? "detail" : "motion";

  // Fired when the user clicks the browser's "Stop sharing" button.
  videoTrack.addEventListener("ended", () => onEnded?.(), { once: true });

  return {
    stream,
    displaySurface,
    controller,
    stop() {
      for (const track of stream.getTracks()) track.stop();
    },
  };
}

setFocusBehavior() accepts "focus-captured-surface", "focus-capturing-application" or "no-focus-change". By default Chrome focuses the captured tab or window; a meeting app usually wants to stay in front so the presenter can see participants.

Region Capture, Element Capture and Captured Surface Control

When a page captures its own tab (for example with preferCurrentTab: true), Chromium offers two ways to share part of it, both on desktop only:

  • Region Capture (Chrome 104): const target = await CropTarget.fromElement(el); await track.cropTo(target); crops the video to the element's bounding box. Anything drawn on top of that area, such as a tooltip, is captured too.
  • Element Capture (Chrome 132): const target = await RestrictionTarget.fromElement(el); await track.restrictTo(target); captures only the element's own rendering and excludes occluding and underlying content. The element must form its own stacking context. Pass null to either method to revert to the full tab.

Captured Surface Control (Chrome 136) lets a meeting app that captures another tab scroll and zoom it without switching tabs: controller.forwardWheel(previewVideoEl) forwards wheel events from your preview element to the captured tab, and increaseZoomLevel(), decreaseZoomLevel(), resetZoomLevel(), getSupportedZoomLevels(), the zoomLevel attribute and the zoomlevelchange event manage zoom. These calls prompt for the captured-surface-control permission on first use and only work while a tab (not a window or screen) is being captured.

Experimental

Region Capture, Element Capture, Captured Surface Control, preferCurrentTab, surfaceSwitching, monitorTypeSurfaces, systemAudio and windowAudio are Chromium-only and marked experimental in MDN's compatibility data. Feature-detect each one ("CropTarget" in window, "restrictTo" in videoTrack, "forwardWheel" in controller) and keep a full-tab fallback.

For recording rather than streaming, pass the stream to MediaRecorder and save the chunks with the File System Access API or offer a download; see Web Share to share the resulting file on platforms that support file sharing.

Local Font Access API

window.queryLocalFonts() enumerates the fonts installed on the user's computer and gives you their raw SFNT data. Design tools, document editors and print or signage PWAs use it to offer the user's own fonts (including licensed professional ones) instead of only web fonts.

Chromium desktop only

Local Font Access shipped in Chrome 103 on desktop. It is not available on Android, in Firefox or in Safari, because a full font list is a strong fingerprinting signal. Mozilla and Apple have not implemented it.

Local Font Access IDL
[SecureContext] partial interface Window {
  Promise<sequence<FontData>> queryLocalFonts(optional QueryOptions options = {});
};
dictionary QueryOptions { sequence<DOMString> postscriptNames; };

[Exposed=Window]
interface FontData {
  Promise<Blob> blob();
  readonly attribute USVString postscriptName;  // "Inter-BoldItalic"
  readonly attribute USVString fullName;        // "Inter Bold Italic"
  readonly attribute USVString family;          // "Inter"
  readonly attribute USVString style;           // "Bold Italic"
};

The first call needs transient activation to show the local-fonts permission prompt; once granted, later calls do not. It rejects with NotAllowedError when the user denies and with SecurityError when the local-fonts Permissions Policy blocks it, when there is no user activation for the prompt, or when the origin is opaque.

src/fonts/local-fonts.js
/** Call from a click handler the first time. */
export async function listFontFamilies() {
  if (!("queryLocalFonts" in window)) return null;

  const status = await navigator.permissions.query({ name: "local-fonts" }).catch(() => null);
  if (status?.state === "denied") return null;

  const fonts = await window.queryLocalFonts();
  const families = new Map();
  for (const f of fonts) {
    if (!families.has(f.family)) families.set(f.family, []);
    families.get(f.family).push({ style: f.style, postscriptName: f.postscriptName });
  }
  return families; // Map<family, faces[]>
}

/** Use a local face in the page without shipping it: CSS local() by PostScript name. */
export async function useLocalFace(family, postscriptName) {
  const face = new FontFace(family, `local("${postscriptName}")`);
  await face.load();
  document.fonts.add(face);
}

/** Detect whether a face is TrueType- or CFF-based, for export or PDF embedding. */
export async function sfntFlavor(fontData) {
  const blob = await fontData.blob();
  const tag = new DataView(await blob.slice(0, 4).arrayBuffer()).getUint32(0);
  if (tag === 0x00010000 || tag === 0x74727565 /* 'true' */) return "truetype";
  if (tag === 0x4f54544f /* 'OTTO' */) return "cff";
  return "unknown";
}

Respect font licensing: reading a font's bytes to render it locally is different from uploading or embedding it in a file other people receive. Ask before you embed a local font in an exported document.

EyeDropper API

The EyeDropper API lets the user pick a color from anywhere on the screen, including other applications, and returns it to the page. It exists for design tools, theme editors and drawing PWAs. The page learns only the one color the user explicitly clicked; it cannot read pixels any other way.

src/tools/eyedropper.js
export const eyeDropperSupported = "EyeDropper" in window;

/** Call from a click handler. Resolves to "#rrggbb" or null if cancelled. */
export async function pickScreenColor({ timeoutMs } = {}) {
  if (!eyeDropperSupported) return null;
  const controller = new AbortController();
  const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null;
  try {
    const { sRGBHex } = await new EyeDropper().open({ signal: controller.signal });
    return sRGBHex;
  } catch (err) {
    if (err.name === "AbortError") return null; // Esc pressed or aborted
    throw err; // NotAllowedError (no activation), InvalidStateError (already open), OperationError
  } finally {
    clearTimeout(timer);
  }
}

open() requires transient activation (NotAllowedError otherwise), allows only one open eyedropper (InvalidStateError), rejects with AbortError when the user presses Esc or your signal aborts, and resolves with { sRGBHex }, a string like "#aabbcc". EyeDropper shipped in Chrome 95 on desktop. It is unavailable on Linux Wayland, arrived on ChromeOS in Chrome 120, and is not available on Android, in Firefox or in Safari. Always offer a regular <input type="color"> as the fallback; on some platforms its native picker includes its own eyedropper.

Web Locks API

The Web Locks API gives all same-origin tabs, windows, iframes and workers (including the service worker) a shared, asynchronous mutex keyed by name. It is supported everywhere: Chrome 69, Firefox 96 and Safari 15.4. For a PWA, which users open in several windows at once, it replaces fragile localStorage "lock" flags with a primitive the browser guarantees.

Web Locks IDL (abridged)
[SecureContext, Exposed=(Window,Worker)]
interface LockManager {
  Promise<any> request(DOMString name, LockGrantedCallback callback);
  Promise<any> request(DOMString name, LockOptions options, LockGrantedCallback callback);
  Promise<LockManagerSnapshot> query();
};

enum LockMode { "shared", "exclusive" };
dictionary LockOptions {
  LockMode mode = "exclusive";
  boolean ifAvailable = false;
  boolean steal = false;
  AbortSignal signal;
};

Semantics you need to know

  • Held for the callback's lifetime. The lock is granted, your callback runs, and the lock is released when the promise the callback returns settles. request() resolves or rejects with the callback's result. Return a promise that stays pending to hold a lock indefinitely.
  • Modes. Any number of "shared" holders, or exactly one "exclusive" holder. Requests are granted in FIFO order per name, so a waiting exclusive request blocks later shared requests (no writer starvation).
  • ifAvailable: true grants immediately or calls the callback with null, never queues.
  • steal: true releases any held lock of that name and grants yours immediately; the previous holder's request() promise rejects with AbortError even though its callback keeps running. Use it only for recovery from a stuck holder.
  • signal aborts a queued request (rejects with AbortError); it cannot release a lock that was already granted.
  • Validation. Names starting with - are reserved (NotSupportedError), as is combining steal with ifAvailable, or signal with either.
  • Scope and cleanup. Locks are scoped to the origin's storage partition (third-party iframes do not share locks with first-party pages), and the browser releases every lock held by a context when that document or worker is destroyed, so a crashed tab never leaves a lock behind. Chrome's lifecycle guidance is to release locks when a page is frozen, because a frozen holder blocks everyone else.
  • query() returns { held, pending }, arrays of { name, mode, clientId }, which is handy for debugging and for showing "open in another window" states.

Leader election across windows

A classic PWA need: several windows are open, but only one should hold the WebSocket, run the sync loop or show notifications. Whichever window holds the leader lock is the leader; when it closes, the next queued window is granted the lock automatically.

src/sync/leader.js
/**
 * Runs `lead()` in exactly one same-origin context at a time.
 * `lead(signal)` should run until `signal` aborts.
 */
export function electLeader(name, lead) {
  const stepDown = new AbortController();

  const done = navigator.locks
    .request(`leader:${name}`, { signal: stepDown.signal }, async () => {
      const leading = new AbortController();
      stepDown.signal.addEventListener("abort", () => leading.abort(), { once: true });
      try {
        await lead(leading.signal); // keep the lock as long as we lead
      } finally {
        leading.abort();
      }
    })
    .catch((err) => {
      if (err.name !== "AbortError") throw err; // aborted while still queued: fine
    });

  return {
    done,
    resign() {
      stepDown.abort(); // queued: request rejects; leading: lead() is told to finish
    },
  };
}
src/sync/main.js
import { electLeader } from "./leader.js";

const channel = new BroadcastChannel("sync");

async function lead(signal) {
  const socket = new WebSocket("wss://api.example.com/live");
  socket.onmessage = (e) => {
    const data = JSON.parse(e.data);
    render(data); // BroadcastChannel never delivers to the sender, so render locally too
    channel.postMessage(data); // fan out to follower windows
  };
  await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true }));
  socket.close();
}

let leadership = electLeader("sync", lead);

// Followers render what the leader broadcasts.
channel.onmessage = (e) => render(e.data);

// Give up leadership (or leave the queue) before being frozen (Chromium) or cached
// in the bfcache, and rejoin the election when the page comes back.
const resign = () => leadership?.resign();
const rejoin = () => {
  leadership?.resign();
  leadership = electLeader("sync", lead);
};
document.addEventListener("freeze", resign);
document.addEventListener("resume", rejoin);
addEventListener("pagehide", (e) => e.persisted && resign());
addEventListener("pageshow", (e) => e.persisted && rejoin());

Serializing IndexedDB migrations and sync

Two windows opened after an update can race to run a data migration, or both try to flush the same outbox. Wrap the critical section in an exclusive lock and the second window simply waits:

src/data/migrate.js
// `db` is an IDBPDatabase from the `idb` library (promise wrapper around IndexedDB).
export function runMigrations(db) {
  return navigator.locks.request("db-migrations", async () => {
    const done = await db.get("meta", "schemaVersion");
    if (done >= CURRENT_SCHEMA) return; // another window finished while we waited
    await migrate(db, done ?? 0);
    await db.put("meta", CURRENT_SCHEMA, "schemaVersion");
  });
}

The same pattern in the service worker prevents a sync event and a page-initiated flush from uploading the same records twice; see Offline-first data & sync and Background Sync. Locks are also available in the service worker, which makes them the cleanest way to coordinate between pages and the worker without inventing a message protocol (compare Messaging & the Clients API).

Browser support

Support data as of September 2026. Versions are the first stable release with the feature enabled by default. For live data see MDN and caniuse.com.

API Chrome desktop Chrome Android Edge Firefox Safari macOS Safari iOS / iPadOS
Media Session ✅ 73 ✅ 57 ✅ ✅ 82 ⚠️ ✅ 15 ✅ 15
Screen Wake Lock ✅ 84 ✅ 84 ✅ 84 ✅ 126 ✅ 16.4 ⚠️ 16.4 / ✅ 18.4
Idle Detection ✅ 94 ✅ 94 ✅ 114 ❌ ❌ ❌
Clipboard text (writeText / readText) ✅ 66 ✅ 66 ✅ ✅ 63 / 125 ✅ 13.1 ✅ 13.1
ClipboardItem, read(), write() ✅ 76 ✅ 84 ✅ ✅ 127 ✅ 13.1 ✅ 13.1
ClipboardItem.supports() ✅ 121 ✅ 121 ✅ ✅ 127 ✅ 18.4 ✅ 18.4
clipboardchange event ✅ 144 ✅ 144 ✅ ❌ ❌ ❌
Contact Picker ❌ ✅ 80 ❌ ❌ ❌ 🧪 14.5
Screen Orientation (read) ✅ 38 ✅ 38 ✅ ✅ 43 ✅ 16.4 ✅ 16.4
screen.orientation.lock() ❌ ✅ 38 ❌ ⚠️ 144 ❌ ❌
Vibration ✅ 32 ✅ 32 ✅ ❌ ❌ ❌
Page Visibility ✅ ✅ ✅ ✅ ✅ ✅
freeze / resume / wasDiscarded ✅ 68 ✅ 68 ✅ ❌ ❌ ❌
Fullscreen (unprefixed) ✅ 71 ✅ 71 ✅ ✅ 64 ✅ 16.4 ⚠️ 16.4
Picture-in-Picture (video) ✅ 69 ✅ 105 ✅ ✅ 153 ✅ 13.1 ✅
Document Picture-in-Picture ✅ 116 ❌ ✅ 116 ✅ 151 ❌ ❌
Screen Capture (getDisplayMedia) ✅ 72 ❌ ✅ 79 ✅ 66 ✅ 13 ❌
Local Font Access ✅ 103 ❌ ✅ ❌ ❌ ❌
EyeDropper ✅ 95 ❌ ✅ ❌ ❌ ❌
Web Locks ✅ 69 ✅ 69 ✅ ✅ 96 ✅ 15.4 ✅ 15.4

Notes:

  • Media Session, Firefox: Firefox for Android exposes the API but shows no media controls for it.
  • Screen Wake Lock, iOS: from 16.4 to 18.3 it works in Safari tabs but not in Home Screen web apps; fixed in 18.4.
  • Clipboard text, Firefox: writeText() since 63, readText() for web content since 125.
  • Contact Picker, iOS: behind an experimental feature flag, off by default.
  • Orientation lock, Firefox: Firefox 144 supports lock() on Android and Windows tablets only. Chrome desktop throws NotSupportedError; Chrome Android requires fullscreen.
  • Vibration, Firefox: removed on desktop in Firefox 129; on Android it returns true without vibrating.
  • Fullscreen, iOS: iPad only; iPhone supports only native <video> fullscreen via webkitEnterFullscreen().
  • Picture-in-Picture and Document PiP, Firefox: desktop only.
  • Local Font Access, EyeDropper, Region / Element Capture and Keyboard Lock are desktop Chromium only.

Common pitfalls

  1. Awaiting before a gesture-gated call. await fetch(...) followed by requestFullscreen(), requestPictureInPicture(), EyeDropper.open() or a Safari clipboard write fails once activation expires. Prefetch, or pass promises where the API allows (ClipboardItem).
  2. Acquiring a wake lock once. Locks are released on every hide. Re-request on visibilitychange, and track user intent separately from the sentinel.
  3. Assuming standalone PWAs bypass fullscreen rules. Chromium's orientation lock still needs element fullscreen unless the app uses "display": "fullscreen"; iPhone has no element fullscreen at all.
  4. Querying clipboard-read in Firefox or Safari. navigator.permissions.query({ name: "clipboard-read" }) throws TypeError there. Wrap every permission query in try / catch or .catch().
  5. Registering unknown Media Session actions without try. One TypeError from skipad or enterpictureinpicture in an older engine aborts the rest of your setup.
  6. Calling setPositionState() with NaN. Before loadedmetadata, duration is NaN and the call throws. Guard, and do not call it on every timeupdate.
  7. Relying on unload. It does not fire on mobile, blocks bfcache, and is being turned off in Chrome. Use visibilitychange and pagehide.
  8. Leaving Document PiP content stranded. If you do not handle pagehide on the PiP window, the moved element disappears with it. Always restore to a placeholder.
  9. Treating a getDisplayMedia() cancellation as an error. NotAllowedError usually means the user closed the picker. Also handle ended on the video track for the browser's own "Stop sharing" button.
  10. Holding Web Locks in frozen pages. A frozen or bfcached tab that holds an exclusive lock blocks every other window. Release on freeze and pagehide, and use steal only as an explicit recovery path.
  11. Testing clipboard calls from DevTools. Chromium rejects with "Document is not focused" because focus is in DevTools. Use the Rendering panel's "Emulate a focused page" option or trigger from page UI.

Debugging

  • Chrome DevTools, Sensors panel: emulate orientation and the Idle Detector state (user active or idle, screen locked or unlocked) without waiting a minute.
  • Chrome DevTools, Rendering panel: "Emulate a focused page" keeps clipboard calls working while DevTools has focus.
  • Chrome DevTools, Application panel, Back/forward cache: test whether a page is eligible for the bfcache and see the reasons it is not, such as unload handlers.
  • chrome://discards: freeze and discard tabs on demand to test freeze, resume and document.wasDiscarded handling.
  • chrome://media-internals and the DevTools Media panel: inspect players, and verify that playback events drive your Media Session updates.
  • Global media controls: in Chrome desktop, the media controls in the toolbar show exactly the metadata, artwork and actions the browser received.
  • Permissions: reset site permissions (clipboard, idle detection, local fonts, captured surface control) from the site information panel in the address bar, then reload.
  • Web Locks: log await navigator.locks.query() from each window to see who holds what, and which requests are pending.
  • iOS: use Safari Web Inspector connected to the device to debug Home Screen web apps; see Browser DevTools for setup.

Further reading

On this site

External references