Introduction
Even today, browsers still can’t provide the same smooth calling experience as native mobile apps.
Because of that, many popular services simply block calls from mobile browsers and force users to install a native app instead.
But sometimes a native app isn’t an option: the user didn’t install it, has no storage left, or just prefers using the web.
So the question is: how close can we get to a native call experience using only the browser?
Let’s walk through the main problems you’ll face and the practical solutions that actually work.
All examples below use React, but the ideas are framework-agnostic.
Problem #1 – Audio breaks when the screen turns off
When the screen goes idle, the browser tab may become backgrounded.
Depending on the OS and browser, this can:
- pause the audio stack
- mute the microphone
- freeze playback
In short: your call randomly dies.
Solution – Wake Lock API
Use navigator.wakeLock to keep the screen awake during an active call.
You can install an npm package or implement a small hook yourself:
/* useWakeLock.ts */
import * as React from 'react';
// Keeps the screen awake
export const useWakeLock = () => {
React.useEffect(() => {
if (!('wakeLock' in navigator)) return undefined;
const abortController = new AbortController();
let wakeLock: WakeLockSentinel | null = null;
const requestWakeLock = async () => {
if (wakeLock && !wakeLock.released) return;
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener(
'release',
() => {
wakeLock = null;
},
{ signal: abortController.signal },
);
} catch (_err) {
// Wake lock request may fail (low battery, background tab, or permissions)
}
};
requestWakeLock();
// The browser automatically releases the wake lock when the document becomes hidden,
// so we need to request it again when visibility changes back to 'visible'.
document.addEventListener(
'visibilitychange',
() => {
if (document.visibilityState === 'visible') {
requestWakeLock();
}
},
{ signal: abortController.signal },
);
return () => {
abortController.abort();
wakeLock?.release().catch(_err => {
// Ignore release errors
});
};
}, []);
};
Just mount this hook inside your call component. While the call is active, the screen won’t turn off.
Problem #2 — Accidental interaction with the app UI
When users hold the phone like a real handset, their cheek touches the screen.
Native apps solve this with the proximity sensor, which automatically turns the screen off.
Browsers don’t have that.
So users accidentally:
- hang up
- mute the mic
- press random buttons
- open links
This leads to a very frustrating experience.
Solution – Manual screen lock overlay
Add a “Lock Screen” button that shows a dark overlay intercepting all touch events.
Unlocking should require a deliberate action (like a slider), not just a tap.
Example:
- Decide when locking is available
We only need this for mobile devices:
/* src/components/ScreenLock/constants.ts */
import { isMobile } from 'react-device-detect';
export const canLockScreen = isMobile;
2) Add lock button
/* src/components/ScreenLock/ScreenLockAction.tsx */
import * as React from 'react';
type Props = {
onLock: () => void;
};
export const ScreenLockAction = ({ onLock }: Props) => (
<button type='button' onClick={onLock}>
Lock screen
</button>
);
You can place it near the microphone/camera controls.
3) Create the overlay
To avoid accidental screen unlock by a user's cheek or ear, the unlock mechanism should be more complex than a standard button. Implementing a custom-styled slider is an effective, native-feeling way to handle this.
/* src/components/ScreenLock/ScreenLockOverlay.tsx */
import * as React from 'react';
type Props = {
onUnlock: () => void;
};
export const ScreenLockOverlay = ({ onUnlock }: Props) => {
const [value, setValue] = React.useState(0);
const onTouchEnd = () => {
if (value === 100) {
onUnlock();
}
setValue(0);
};
return (
<div
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
left: 0,
background: '#000000',
}}
>
<p>Slide to unlock</p>
<input
type='range'
min={0}
max={100}
value={value}
onChange={e => setValue(parseFloat(e.target.value))}
onTouchEnd={onTouchEnd}
/>
</div>
);
};
4) Use everything in the call component
/* src/components/Call/Call.tsx */
import * as React from 'react';
import {
ScreenLockAction,
ScreenLockOverlay,
canLockScreen,
} from 'components/ScreenLock';
const Call = () => {
const [isScreenLocked, setIsScreenLocked] = React.useState(false);
return (
<div>
{/* Your call layout */}
{canLockScreen && (
<>
<ScreenLockAction onLock={() => setIsScreenLocked(true)} />
{isScreenLocked && (
<ScreenLockOverlay onUnlock={() => setIsScreenLocked(false)} />
)}
</>
)}
</div>
);
};
Now users can safely lock the screen before putting the phone to their ear.
Problem #3 – Accidental interaction with browser UI
Even if your app is protected by the overlay, the browser UI is still active:
- address bar
- back/forward buttons
- navigation gestures
Users may accidentally leave the page and drop the call.
We can’t control these elements from JavaScript.
Solution – support PWA standalone mode.
When installed to the home screen, the app opens fullscreen without browser chrome.
Create a minimal manifest.json:
/* public/manifest.json */
{
"background_color": "#000000",
"display": "standalone",
"name": "My app",
"short_name": "My App",
"start_url": "/",
"theme_color": "#000000",
"icons": [
{
"purpose": "maskable",
"src": "/assets/favicon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"purpose": "maskable",
"src": "/assets/favicon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"purpose": "any",
"src": "/assets/favicon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Some browsers also require a minimal service worker with a fetch handler.
You can additionally show installation instructions inside your lock overlay.
- Detect whether the install prompt is available
Some browsers (mainly Chromium on Android) expose the beforeinstallprompt event.
It allows triggering the install dialog programmatically.
/* src/components/ScreenHomeInstruction/utils.ts */
let pwaInstallPrompt: BeforeInstallPromptEvent | null = null;
export const pwaBeforeInstallListenerCreate = () => {
// Available only for Chromium Android browsers as experimental function.
window.addEventListener('beforeinstallprompt', e => {
e.preventDefault();
pwaInstallPrompt = e as BeforeInstallPromptEvent;
});
};
export const getHasPwaInstallPrompt = () => !!pwaInstallPrompt;
export const runPwaInstallPrompt = () => {
if (!pwaInstallPrompt) return;
pwaInstallPrompt.prompt();
pwaInstallPrompt.userChoice.then(() => {
pwaInstallPrompt = null;
});
};
Call pwaBeforeInstallListenerCreate() once during app initialization (for example in App.tsx).
2) Create as installation hint component
Now let’s show a small instruction block encouraging users to install the app.
/* src/components/ScreenHomeInstruction/ScreenHomeInstruction.tsx */
import * as React from 'react';
import { getHasPwaInstallPrompt, runPwaInstallPrompt } from 'utils';
export const ScreenHomeInstruction = () => {
const showInstallButton = getHasPwaInstallPrompt();
return (
<div className='screenHomeInstruction'>
<p>For the best experience</p>
<br />
{showInstallButton ? (
<button type='button' onClick={runPwaInstallPrompt}>
Add to Home screen
</button>
) : (
<p>Tap "Share", then "Add to Home Screen"</p>
)}
</div>
);
};
If the browser supports the install prompt, we show a button.
Otherwise, we display manual instructions (useful for iOS Safari).
Hide it when running in standalone:
/* src/components/ScreenHomeInstruction/ScreenHomeInstruction.css */
.screenHomeInstruction {
@media (display-mode: standalone) {
display: none;
}
}
3) Add the hint to the screen lock overlay
Finally, render this component inside the lock overlay:
/* src/components/ScreenLock/ScreenLockOverlay.tsx */
import * as React from 'react';
import { ScreenLockInstruction } from 'components/ScreenLockInstruction';
type Props = {
onUnlock: () => void;
};
export const ScreenLockOverlay = ({ onUnlock }: Props) => {
/* Overlay logic */
return (
<div
style={
{
// Overlay styles
}
}
>
<ScreenLockInstruction />
<p>Slide to unlock</p>
{/* Overlay input */}
</div>
);
};
Now users:
- see that installation is possible
- can install with one tap (Chromium) or follow manual steps (Safari)
- get fullscreen standalone mode
- and avoid accidental browser navigation during calls
Which makes the experience much closer to native.
Conclusion
A few small improvements – Wake Lock, manual screen lock, and PWA standalone mode – significantly improve the mobile web calling experience.
They don’t fully replace native apps, but they eliminate the most painful issues:
- audio interruptions
- accidental touches
- unexpected navigation
And in many cases, that’s already enough.
Note
This article is an English adaptation of my original post published on Habr.
