Device & Capability APIs
There is a moment every web app gets wrong at least once: it loads, and before you have done anything, three permission prompts stack up in the corner of your screen. Location. Notifications. Camera. You reflexively hit Block on all of them, and now that site can never ask again — you have poisoned the well.
The APIs in this chapter hand your page real device power: where the user is, the ability to buzz their phone, access to the operating system’s share sheet. That power comes with a social contract. Ask at the wrong time and the browser doesn’t just annoy the user, it remembers the denial and quietly refuses you forever. So the skill here is less about the method signatures — those are small — and more about the choreography: check state before you prompt, prompt only on a gesture, detect the feature before you touch it, and push work you don’t need right now out of the critical path.
We’ll cover five APIs that share that theme: the Permissions API (the unified “where do I stand?” check), Notifications, Geolocation, Web Share, and requestIdleCallback. Each one is a small surface. The judgment is the lesson.
Feature detection comes first, always
Every capability in this chapter is optional. It may be missing because the browser is old, because you’re in a non-secure context (http:// instead of https://), because a permissions policy in an iframe disabled it, or because the platform simply never shipped it (navigator.share on a desktop that has no share sheet). Calling a method that doesn’t exist throws a TypeError and takes your whole handler down with it.
So the first line of any capability code is a guard. The pattern is boringly consistent: check that the object and method exist before reaching for them.
if ('geolocation' in navigator) {
// safe to call navigator.geolocation.getCurrentPosition(...)
}
if ('share' in navigator) {
// safe to call navigator.share(...)
}
if (typeof requestIdleCallback === 'function') {
// safe to schedule idle work
}
Feature detection is not abstract — every one of these checks returns a concrete answer in your browser right now. The demo below runs the exact guard expressions from above and reports what this browser actually exposes. Note that a green check means the API object is present, not that you have permission to use it.
The Permissions API: knowing before you ask
Here’s the problem the Permissions API solves. Say your app wants the user’s location. You could just call getCurrentPosition() and let the browser sort it out. But you have no idea, before that call, whether the user already granted location (in which case you get it silently), already blocked it (in which case your feature is dead and you should hide the button), or has never been asked (in which case a prompt appears). Three very different UIs, and the old APIs gave you no way to tell them apart ahead of time.
navigator.permissions.query() is the unified read. You pass a descriptor naming the capability; it returns a promise for a PermissionStatus whose .state is one of three strings:
"granted"— you may use the feature now, no prompt needed."denied"— blocked. Calling the underlying API will fail. Hide or disable the UI."prompt"— undecided. The user will be asked the next time you actually invoke the feature.
const status = await navigator.permissions.query({ name: 'geolocation' });
switch (status.state) {
case 'granted':
showLocationFeature(); // already allowed — just use it
break;
case 'prompt':
showEnableButton(); // ask on click, not now
break;
case 'denied':
showManualEntryFallback(); // blocked — offer a different path
break;
}
The states form a small machine. A capability starts at prompt. The user’s decision moves it to granted or denied. Crucially, in most browsers prompt and denied are functionally identical to your code until the user acts — you can’t use the feature from either. The difference is only in what happens when you next ask: from prompt you get a dialog, from denied you get silence.
This simulator makes the machine tangible. It models a single capability’s permission state without touching any real browser permission. Watch how the UI you would render changes with the state — and notice that query() can read the state at any point without ever prompting.
Watching for changes
The PermissionStatus object is live. A user can flip a permission from the browser’s site-settings panel while your page is open — grant location after previously blocking it, say. The object emits a change event when that happens, so you can react without polling.
const status = await navigator.permissions.query({ name: 'geolocation' });
status.addEventListener('change', () => {
console.log('location permission is now', status.state);
updateLocationUI(status.state);
});
navigator.permissions itself is Baseline and available across current browsers. The catch is per-name coverage: the machinery is universal, but whether a given capability answers a query varies. Always guard.
Notifications: the most-abused prompt on the web
The Notifications API can show system-level messages — the little cards that slide in from your OS, outliving the tab that spawned them. It is also the single most misused permission on the web, which is exactly why browsers have clamped down hard on it.
There are two moving parts: permission and display.
Permission is a promise-returning static method:
// Only ever call this from inside a click / tap handler.
button.addEventListener('click', async () => {
const result = await Notification.requestPermission();
// result is "granted", "denied", or "default"
if (result === 'granted') {
showThanks();
}
});
The result strings are "granted", "denied", and "default" (equivalent to “not yet asked” — note it’s "default" here, not "prompt" as in the Permissions API). The current value is also readable synchronously as Notification.permission, which is handy for a quick gate.
Once granted, there are two ways to actually show a notification, and picking the wrong one will crash on mobile.
The Notification constructor creates a simple, page-tied notification:
if (Notification.permission === 'granted') {
const n = new Notification('Build finished', {
body: 'Your deploy is live.',
icon: '/icons/check.png',
});
n.addEventListener('click', () => window.focus());
}
That works on desktop. But new Notification(...) throws a TypeError in nearly all mobile browsers — it simply isn’t implemented there. For anything that needs to work on phones, or to survive after the tab closes, you go through a service worker and call showNotification() on its registration:
const reg = await navigator.serviceWorker.ready;
await reg.showNotification('Build finished', {
body: 'Your deploy is live.',
icon: '/icons/check.png',
tag: 'deploy-status', // replaces an earlier notification with the same tag
});
Notifications shown this way are persistent: clicks fire a notificationclick event inside the service worker (on ServiceWorkerGlobalScope), which can focus an existing tab or open a new one even if your page is gone.
The whole Notifications surface — constructor and permission model — carries limited Baseline status precisely because of that mobile gap in the constructor. Treat service-worker notifications as the real cross-platform path and the constructor as a desktop convenience.
Geolocation: location with an escape hatch
Geolocation is the oldest of these APIs and, refreshingly, one of the most stable — Baseline, widely available across browsers for years, though it requires a secure context (https://). It has exactly three methods:
getCurrentPosition(success, error, options)— one-shot fix.watchPosition(success, error, options)— repeated fixes as the device moves; returns a numeric watch id.clearWatch(id)— stop a watch.
A one-shot lookup, done properly, always passes an error callback and options:
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords;
console.log(`±${Math.round(accuracy)}m at ${latitude}, ${longitude}`);
},
(error) => {
// error.code tells you *why* it failed
if (error.code === error.PERMISSION_DENIED) {
showManualEntry();
}
},
{
enableHighAccuracy: false, // true asks for GPS-grade fixes (slower, battery-hungry)
timeout: 10_000, // give up after 10s
maximumAge: 60_000, // accept a cached fix up to 1 min old
}
);
The success callback receives a GeolocationPosition with a coords object (latitude, longitude, accuracy in metres, and often altitude, heading, speed) plus a timestamp. The error callback receives a GeolocationPositionError whose code is one of three constants:
code |
constant | meaning |
|---|---|---|
| 1 | PERMISSION_DENIED |
user blocked it (or the context isn’t secure) |
| 2 | POSITION_UNAVAILABLE |
the device couldn’t get a fix |
| 3 | TIMEOUT |
no fix within your timeout window |
watchPosition is the streaming version — the browser calls your success callback every time it has a materially better fix. Always keep the returned id and clear it when you’re done, or you’ll keep the GPS awake and the battery draining.
const watchId = navigator.geolocation.watchPosition(
(pos) => drawUserDot(pos.coords),
(err) => console.warn('watch failed', err.code),
{ enableHighAccuracy: true, maximumAge: 0 }
);
// later, when the map view closes:
navigator.geolocation.clearWatch(watchId);
Because location is sensitive, pair it with the Permissions API from the start: query { name: 'geolocation' } on load to decide whether to show a live map, a “Use my location” button, or a manual search box — before you ever trigger a prompt.
Web Share: handing off to the OS
navigator.share() opens the operating system’s native share sheet — the same panel that appears when you tap Share in any native app, letting the user pick Messages, Mail, a specific contact, or any installed app. You don’t build the UI; you hand the OS a payload and it takes over. That’s the appeal: instead of hard-coding buttons for five social networks, you get every target the user has installed, on their platform, respecting their choices.
async function shareArticle() {
const data = {
title: 'Device & Capability APIs',
text: 'A tour of asking for browser capabilities the respectful way.',
url: location.href,
};
if (navigator.canShare && navigator.canShare(data)) {
try {
await navigator.share(data);
// resolved → the sheet opened and the user completed or dismissed a target
} catch (err) {
if (err.name !== 'AbortError') {
console.error('share failed', err); // AbortError just means "user cancelled"
}
}
} else {
showFallbackShareButtons(); // copy-link, mailto, etc.
}
}
Two methods work together. navigator.canShare(data) is a synchronous boolean check: would this exact payload be shareable here? It doesn’t need a user gesture and doesn’t open anything — use it to decide whether to show a native share button at all. navigator.share(data) actually opens the sheet, returns a promise, and — this is the strict part — requires transient activation. It must be called from within a genuine user gesture (a click, tap, or keypress handler). Call it from a setTimeout, a promise chain that lost the gesture, or on load, and it rejects with a NotAllowedError.
You can also share files — images, PDFs — but support is narrower, so canShare earns its keep here: check navigator.canShare({ files }) specifically, because a browser can support sharing text and URLs while rejecting files.
const files = [new File([blob], 'chart.png', { type: 'image/png' })];
if (navigator.canShare && navigator.canShare({ files })) {
await navigator.share({ files, title: 'Q3 chart' });
}
Web Share is not Baseline: it’s solid on mobile Safari and Chrome/Android, but desktop coverage is uneven (desktop Firefox, in particular, has historically lacked it). That’s exactly why the canShare/feature-detect gate isn’t optional — treat native share as a progressive enhancement over a plain copy-link fallback, never as the only way to share.
requestIdleCallback: work that can wait
The previous four APIs were about asking permission. This last one is about timing — being a good citizen of the main thread by doing non-urgent work only when the browser has nothing better to do.
Every frame, the browser has a budget. It runs your input handlers, recalculates styles, does layout, and paints — ideally all within about 16 milliseconds so it can hit 60fps. If it finishes early, there’s a gap of idle time before the next frame is due. requestIdleCallback lets you slot low-priority work into exactly those gaps, so it never competes with rendering or a user’s tap.
requestIdleCallback((deadline) => {
// Keep working only while there's time left in this idle slot.
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
processOne(tasks.shift());
}
// Didn't finish? Ask for another idle slot.
if (tasks.length > 0) {
requestIdleCallback(handleTasks);
}
});
The demo below runs that exact loop for real. It queues 60 small “tasks” and drains them in whatever idle time each slot offers — you can watch the progress bar fill in bursts, one idle slot at a time, and see how many slots it took. (If your browser lacks requestIdleCallback, it transparently falls back to a setTimeout shim, just like production code should.)
The callback receives an IdleDeadline object with two members:
deadline.timeRemaining()— an estimate, in milliseconds, of how much idle time is left in this slot. You loop while this is positive, and yield the moment it hits zero so you don’t push the browser past its frame budget.deadline.didTimeout— a boolean,trueif the callback is running because yourtimeoutelapsed rather than because the browser went idle.
That timeout is the safety valve. Idle callbacks are best-effort — if the page is busy, seconds can pass before one runs, and on a page that never goes idle it might not run at all. For work that must eventually happen (flushing analytics before the user leaves, say), pass a timeout so the browser promises to run your callback even if it has to interrupt:
requestIdleCallback(flushAnalytics, { timeout: 2000 });
// runs during idle time if possible, but no later than 2s from now
When it fires because of the timeout, deadline.timeRemaining() returns 0 and didTimeout is true — a signal that you’re now stealing time from the frame and should do the bare minimum and get out.
Where it sits among the schedulers
It helps to place requestIdleCallback next to the other ways to schedule work, because they answer different questions:
Seeing the order beats memorising it. The demo below queues one callback on each mechanism in a deliberately scrambled order, then logs each as it actually fires. The synchronous line runs first, then the microtask, then the frame-aligned and timer callbacks, with the idle callback bringing up the rear — regardless of the order you scheduled them in.
The distinction from requestAnimationFrame is the one people mix up. rAF says “do this before the next frame paints” — you use it when the work is the visual update. requestIdleCallback says “do this after frames, when nothing’s happening” — you use it for work that would only make the frame slower if it ran inside it. Prefetching the next page, lazily building a search index, sending batched telemetry, warming a cache: all classic idle work.
Putting the choreography together
Every API in this chapter rewards the same discipline. Detect, then check, then gate on a gesture, then use. Here’s the shape of a “share my location” button that respects all four steps at once:
async function setupLocationShare(button) {
// 1. detect — bail quietly if the platform can't do this
if (!('geolocation' in navigator) || !('share' in navigator)) {
button.hidden = true;
return;
}
// 2. check state passively, no prompt yet
try {
const status = await navigator.permissions.query({ name: 'geolocation' });
if (status.state === 'denied') {
button.disabled = true;
button.title = 'Location is blocked in your browser settings';
return;
}
} catch {
// name not queryable here — proceed and let the real call decide
}
// 3. only now, on a real gesture, do we prompt and act
button.addEventListener('click', () => {
navigator.geolocation.getCurrentPosition(async (pos) => {
const { latitude, longitude } = pos.coords;
const data = {
title: 'Here I am',
url: `https://maps.example/?ll=${latitude},${longitude}`,
};
if (navigator.canShare?.(data)) {
try { await navigator.share(data); }
catch (e) { if (e.name !== 'AbortError') console.error(e); }
}
});
});
}
Notice what the code doesn’t do: it never prompts on load, never assumes an API exists, never treats a denial as an error to shout about, and never calls share() outside the click. That restraint is the entire craft. The user grants power to apps that feel considerate, and revokes it — permanently, with one click — from apps that don’t.
Summary
- Feature-detect first. Every capability here is optional; guard with
'x' in navigatorortypeof fn === 'function'before you touch it. Missing APIs throw and kill your handler. - The Permissions API (
navigator.permissions.query(descriptor)) reads the current state —"granted","denied", or"prompt"— without prompting, so you can pick the right UI on load. The returnedPermissionStatusfires achangeevent when the user flips a setting. Querying an unsupported name rejects, so wrap it intry/catch. - Notifications need
Notification.requestPermission()called only from a user gesture (result:"granted"/"denied"/"default"). Thenew Notification()constructor is desktop-only and throws on mobile; use a service worker’sshowNotification()for cross-platform, persistent notifications. Limited Baseline. - Geolocation (Baseline, secure context only) gives you
getCurrentPosition,watchPosition+clearWatch. Always pass an error callback and readerror.code(PERMISSION_DENIED/POSITION_UNAVAILABLE/TIMEOUT).enableHighAccuracyis a battery-cost dial, not a precision guarantee. - Web Share:
navigator.canShare(data)is a gesture-free boolean pre-check;navigator.share(data)opens the OS share sheet but requires transient activation and rejects withAbortErroron user cancel. Not Baseline — keep a copy-link fallback. requestIdleCallbackruns low-priority work in the idle gaps after rendering. Loop whiledeadline.timeRemaining() > 0, requeue the rest, and pass atimeoutfor work that must eventually run. It sits belowrAFand microtasks in urgency. Not Baseline (Safari lags) — feature-detect with asetTimeoutfallback.- The through-line: detect → check → gesture → use. Ask respectfully, because a denial is forever.