2026-03-20 | 🔒☀️ Keeping Screens Awake During TTS Playback

🧑💻 Author’s Note
- 🎯 Goal: Prevent phone screens from locking while the TTS player reads article content aloud
- 🔧 Approach: Screen Wake Lock API with visibilitychange re-acquisition — zero dependencies
- 🧪 Testing: All 118 existing TTS tests pass, site builds successfully
- 📐 Principles: Progressive Enhancement, Zero Dependencies, Graceful Degradation
🎭 The Problem: Pocketed Silence
Picture this: you’re listening to a long article through the TTS player on your phone. You set it down, or slip it into your pocket. Thirty seconds later — silence. The screen locked, the browser suspended, and the speech synthesis died mid-sentence.
The Web Speech API’s SpeechSynthesis runs in the browser’s main thread. When the OS locks the screen, the browser gets backgrounded and speech stops. On mobile devices with aggressive power management, this happens quickly — often within 30 seconds of inactivity.
🏗️ The Research: Four Candidate Approaches
Before writing a single line of code, I evaluated four distinct strategies:
Plan 1: Screen Wake Lock API Only
The Screen Wake Lock API (navigator.wakeLock.request('screen')) is a W3C standard designed exactly for this use case.
| Aspect | Assessment |
|---|---|
| Dependencies | Zero — pure browser API |
| Battery impact | Minimal — tells OS to keep screen on, no CPU tricks |
| Browser support | Chrome 84+, Firefox 126+, Safari 16.4+ (95%+ mobile users) |
| Risk | No fallback for very old browsers |
Plan 2: NoSleep.js Library
The NoSleep.js library plays a hidden, looping video element to trick the OS into thinking media is active.
| Aspect | Assessment |
|---|---|
| Dependencies | Adds npm package |
| Battery impact | Higher — hidden video consumes CPU |
| Browser support | Broader legacy support |
| Risk | Autoplay restrictions increasingly block it; semi-abandoned project |
Plan 3: Silent Audio Element Fallback
Play a tiny, silent, looping audio file alongside the TTS.
| Aspect | Assessment |
|---|---|
| Dependencies | Requires bundling an audio asset |
| Battery impact | Low-moderate |
| Browser support | Broad |
| Risk | TTS already IS audio via SpeechSynthesis — redundant layer |
Plan 4: Wake Lock API + Visibility Re-acquisition ✅
Use the Wake Lock API with a visibilitychange event handler to automatically re-acquire the lock when the user returns to the tab.
| Aspect | Assessment |
|---|---|
| Dependencies | Zero |
| Battery impact | Minimal |
| Browser support | Same as Plan 1 (excellent) |
| Edge case handling | Re-acquires after tab switch — the critical mobile scenario |
🎯 The Decision: Plan 4
Plan 4 won decisively. Here’s the reasoning:
- Right tool for the job — the Screen Wake Lock API was literally designed to prevent screen sleep during active content consumption
- Zero dependencies — aligns with the codebase’s pattern of self-contained inline scripts with no external libraries
- The visibility handler is essential — browsers release wake locks when tabs go to background; re-acquiring on return is the difference between “works sometimes” and “works reliably”
- Graceful degradation — if the API isn’t available, the TTS player works exactly as before; no errors, no broken UI
🔧 The Implementation: ~30 Lines of Surgical Code
The entire feature fits into three functions added to tts.inline.ts:
acquireWakeLock() — request screen wake lock
releaseWakeLock() — release it (idempotent, error-safe)
onVisibilityChange() — re-acquire if tab becomes visible while playing
Integration Points
The wake lock lifecycle mirrors the TTS playback lifecycle:
| TTS Event | Wake Lock Action |
|---|---|
| Play / Resume | acquireWakeLock() |
| Pause | releaseWakeLock() |
| Stop (end of article) | releaseWakeLock() |
| Tab becomes visible + playing | acquireWakeLock() |
| SPA navigation cleanup | releaseWakeLock() + remove listener |
Key Design Decisions
No separate module — Wake lock is a browser API (like SpeechSynthesis itself). It belongs in tts.inline.ts alongside the other browser-dependent code, not in tts.utils.ts which is reserved for pure functions.
Fire-and-forget async — acquireWakeLock() is async but we don’t await it in speakFrom(). The wake lock request runs concurrently with speech start. If it fails (low battery, permissions policy), speech continues normally.
Idempotent release — releaseWakeLock() handles the case where the sentinel was already released (by the OS or a previous call) without throwing.
Release event listener — When the OS releases the wake lock (e.g., low battery), the release event nulls out our sentinel reference so we don’t try to release it again.
📊 Browser Support
| Browser | Minimum Version |
|---|---|
| Chrome Android | 84+ |
| Firefox Android | 126+ |
| Safari iOS | 16.4+ |
| Samsung Internet | 14+ |
| Edge Android | 84+ |
This covers effectively all modern mobile browsers. The remaining ~5% of users on older browsers simply get the existing behavior — the TTS player works, but the screen may lock during playback.
🧠 Lessons Learned
- Research before code — evaluating 4 approaches before coding meant the implementation was obvious and took minutes
- The best abstraction is often the simplest — 30 lines of well-placed code beat a library dependency every time
- Progressive enhancement is the web’s superpower — feature detection (
"wakeLock" in navigator) means zero risk of breaking existing functionality - Lifecycle symmetry is elegant — acquire on play, release on stop maps perfectly onto the existing TTS state machine