Update (Nov 7, 2026): Epic has since published the official Pirate Token rate, 1 in 137,500 per chest, and the tracker now uses it instead of the old estimates.
I run a small fan site for a Fortnite event, and one page is a chest counter: you tap +1 every time you open a chest, and it turns the count into odds. Analytics showed something I hadn't designed for. On phones, people tap it about 50 times per visit. They prop the phone next to the console and keep tapping between fights.
Which means the phone goes to sleep between matches, and every time they come back they have to unlock it first. So I added the Screen Wake Lock API. The happy path is one line:
const lock = await navigator.wakeLock.request('screen');
The rest of this post is the stuff that one line doesn't tell you.
1. The browser takes the lock away, a lot
A wake lock is released automatically whenever the page is hidden: switching apps, locking the phone, changing tabs. When the user comes back, you don't get it back for free. You have to request it again on visibilitychange:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') acquire();
});
If you only request once on load, it silently stops working the first time someone checks a message.
2. Don't grab it on page load
Plenty of visitors read the page and leave. Holding the screen on for them just drains their battery. I only ask for the lock after the first tap on a counter button, and only re-acquire on visibilitychange if counting has started:
let started = false;
function onCount() {
started = true;
acquire();
// ...update the count
}
3. Requests can fail, and that's fine
request() rejects in battery-saver mode, in some embedded browsers, and under certain permission policies. The counter must keep working anyway, so the failure is swallowed:
try {
sentinel = await navigator.wakeLock.request('screen');
} catch {
sentinel = null; // counting still works, the screen just sleeps
}
Also guard against double requests: a fast double-tap can fire two request() calls before the first resolves. I keep a pending promise and skip if one is in flight.
4. Give people an off switch, and remember it
Some people don't want their screen pinned on. A checkbox, "Keep my screen on while counting", is on by default, and the choice goes into localStorage (wrapped in try/catch, since private mode can throw). Turning it off releases the current lock right away.
5. Hide the option where it can't work
Older iOS Safari (before 16.4) has no navigator.wakeLock. Showing a checkbox that does nothing is worse than no checkbox, so the row starts hidden and is only revealed when the API exists.
The whole thing
Here is the module as it runs on the site, small enough to paste:
export function createWakeKeeper({ wakeLock, doc, enabled = true }) {
const supported = !!(wakeLock && typeof wakeLock.request === 'function');
let on = enabled, started = false, sentinel = null, pending = null;
async function acquire() {
if (!supported || !on || !started || sentinel || pending) return;
if (doc && doc.visibilityState && doc.visibilityState !== 'visible') return;
pending = (async () => {
try {
const s = await wakeLock.request('screen');
sentinel = s;
s.addEventListener('release', () => { if (sentinel === s) sentinel = null; });
} catch {
sentinel = null;
} finally {
pending = null;
}
})();
return pending;
}
if (supported && doc) doc.addEventListener('visibilitychange', () => { acquire(); });
return {
supported,
poke() { started = true; return acquire(); },
async setEnabled(v) {
on = !!v;
if (on) return acquire();
const s = sentinel; sentinel = null;
if (s) { try { await s.release(); } catch {} }
},
active: () => !!sentinel,
enabled: () => on,
};
}
Passing wakeLock and doc in, instead of reading navigator and document inside, is what makes it testable: in Node I hand it a fake wakeLock that records calls and a fake document I can "show" again, then assert things like "the first tap requests once, later taps reuse it" and "after the browser releases it, showing the page takes it back". Eight small tests, no browser needed.
You can try it on the Pirate Token chest tracker: tap +1 once, then leave your phone alone and watch it not go dark.
If you've hit other Wake Lock edge cases (I'd love to hear how Samsung Internet behaves), tell me in the comments.
Top comments (2)
The visibilitychange re-acquire was the detail that stuck with me — requesting once on load would silently stop working the first time someone checks a message mid-game. I also liked only grabbing the lock after counting starts, since holding the screen on for people who just read and leave would only drain their battery. The pending-promise guard against double-taps is a neat touch too.
The dependency injection pattern (createWakeKeeper({ wakeLock, doc })) is a brilliant way to make browser APIs testable in Node without a heavy headless setup. Also, waiting for that first tap to avoid draining battery for passive readers is great UX. I haven't tested Samsung Internet specifically, but re-requesting on visibilitychange is the magic key that fixes 90% of those mobile browser quirks!