The widget that worked on the second visit
A blocked-notifications widget appeared only after a reload. Three small fixes: a race between two scripts, a cookie mistaken for the permission, and one state with three names.

When a visitor blocks notifications, a website can't ask again. The browser remembers the answer, and a second call to the permission API returns "denied" without showing anything.
The only way back is manual. The visitor has to open the site settings in their browser and flip the switch themselves.
Revotas has a widget for exactly that case. A colleague built it in December 2024: when notifications are blocked, it shows the store's message and numbered steps for turning them back on. In spring 2025 it landed on my desk with a strange report. It worked, but only sometimes. Reload the page and it appeared.
It took three small fixes, across two scripts, to make it appear the first time. Together they are well under a hundred lines.
Two scripts, one value
A store that uses web push loads two scripts from us. One handles push: it asks for permission, registers the subscription and keeps a cookie with the visitor's answer. The other runs the on-site widgets, including this one.
The widget needs one piece of information the push script fetches from the server: the store's push domain. The push script requests it, and when the answer arrives it writes the value to localStorage.
On a first visit, nothing is in localStorage yet. Both scripts start. The widget script is ready first, looks for the value and finds nothing, so the widget doesn't appear. A moment later the push script's request returns and the value is saved, with nobody left to read it.
On the second visit the value is already there, so the widget works. That was the whole "sometimes".
Fix one: wait for the value, don't poll for it
The widget had been waiting for the wrong thing. It ran when the DOM was ready, and the DOM being ready says nothing about a network request in another script.
I changed both ends. The push script now dispatches a custom event on window right after it saves the value. The widget checks storage first. If the value is there, it runs. If not, it adds a listener for that event and runs when it fires.
There is no timer and no retry loop. The check covers every visit after the first, and the event covers the first one.
The widget side went in on April 18, 2025. By June 8 the push script side was in the main line too, with the event dispatched on the line right after the write.
Fix two: the cookie isn't the permission
With the timing fixed, the widget still stayed hidden for some visitors who had clearly blocked notifications.
Widgets are shown by targeting rules, and the rule for this one asked: is web push blocked? The check read our own cookie, the one the push script writes when a visitor answers the prompt.
That cookie only exists if the visitor answered our prompt on this browser and the cookie is still around. Someone who blocked notifications from the browser's own settings, or whose cookie was cleared or expired, had no cookie at all. For them the check compared "nothing" against "blocked" and concluded the rule didn't match.
The browser already knows the real answer. So when the cookie is missing, the check now reads Notification.permission and treats "denied" as blocked. The cookie stays as the first source because it's ours and cheap. The browser is the fallback because it's the truth.
Fix three: three names for one state
The last one was vocabulary. The cookie stores the blocked state as the string false. The targeting rules called the same state blocked, and in some configurations blocked_webpush.
Three spellings of one state meant a string comparison that could never be true for two of them. I normalized the rule's value before comparing, so all three mean the same thing.
In the same change the push script got a direct path. When a permission request is rejected, it calls the widget itself if the page's configuration asks for it, through the same check-then-listen entry point. The visitor who has just blocked notifications is the one most likely to want the way back.
What the bug was really about
None of the three was hard once it was visible. What made the report confusing is that each one produced the same symptom, a widget that doesn't show, for a different group of visitors:
- first-time visitors, because of timing,
- visitors without our cookie, because of where the state was read from,
- stores whose rule used a different word, because of naming.
The timing fix went in in April. The other two followed in June, for the visitors the first fix didn't reach.
A few things I took from it:
- "It works on reload" means an ordering bug. Something is being read before it's written.
- Signal completion, don't guess it. An event at the point of the write is one line and removes the race. A delay only moves it.
- Prefer the platform's state over your copy of it. Our cookie was a cache of the browser's permission, and caches go missing.
- Give a state one name. If two systems must agree on a string, normalize at the boundary.


Notes on this post
Join in
Loading…
Leave a comment.
Sign in to add your note to this post.
Only your name and avatar are used.