I spent three days building a guided tour. It trapped users in an infinite loop and told me it was healthy. The bug report came in Spanish. "Me aparece una infinidad de mensajes superpuestos que dice 'Follow the light'. Es bastante molesto y entorpece el uso de la aplicacion." An endless number of overlapping messages saying "Follow the light" are stacking on my screen and I cannot use the app.
"Follow the light" was mine. A caption in a guided tour, meant to appear once, for one second, pointing at an icon.
It was appearing forever.
---
## The idea was good
We built a Pro Audio wing for our radio network. A rack-mount stereo you assemble in the browser, real DSP, a plugin store behind it.
The problem with shipping a wing is nobody visits it. You add a nav item and watch nothing happen.
So we decided to escort people there. A sequence that starts wherever you land, walks you to the stereo, hands you a free 5-Band EQ, seats it into your rack with a little animation, and leaves you tuning.
Not a modal saying "check out our new feature." An actual walk, with a gift at the end.
Three days on the build. Eight hours on the debugging. The debugging is the useful part.
---
## Five ways I fooled myself
**1. I debugged correct code for hours because the browser had an old copy.**
The tour script was versioned in one file, unversioned in another, and cached for four hours in a third. I shipped five fixes. All five "did nothing." So I went hunting downstream for problems that were not there.
The check that would have saved the whole afternoon:
```js
document.lastModified // compare to your deploy timestamp
```
Three seconds. Do it before anything else.
**2. I wrote a test that could not fail.**
To confirm a fix had shipped, I searched the deployed file for a string. That string also appeared in the comment I had written explaining the fix.
So it returned the same result whether the fix was there or not. And I read that result as evidence.
**3. Looking modal is not being modal.**
The tour dims the page and spotlights one control. The dim came from a large box shadow:
```css
.awt-spot {
box-shadow: 0 0 0 9999px rgba(6,6,10,0.40);
pointer-events: none; /* clicks pass through */
}
```
The page looked locked. Every button under the darkness was live. My own comment said so. I had read it more than once without registering what it meant for the person on the other side of the screen.
A box shadow is not a hit test surface.
**4. I optimized away the feature.**
I added a guard to a polling loop to skip work when the tab was hidden. Saves requests. Sensible.
```js
if (!isLive || document.hidden) return;
```
The loop was for radio. Radio is the one medium people deliberately leave in a background tab.
That line meant nobody heard a single ad break for an entire evening, while the server served every one of them, on schedule, perfectly.
**5. My health check could only see survivors.**
I built a monitor for the tour. It read the table of users who had completed it and reported a steady completion rate.
It printed HEALTHY while three users were trapped in an infinite loop.
Of course it did. A user stuck in the loop never completes, so they never land in the table the monitor reads. I built an instrument that could only see the people who escaped, and read their success as the health of the system.
That one still bothers me.
---
## The part I had been ignoring
Around day two, before any of this broke, something was nagging at me. Two halves.
**One: this is a loop running inside other people's sessions.**
Writing to their browser storage. Rewriting where their links point. Blocking their clicks. Navigating their tab. All well intentioned, all invisible, none of it asked for.
There is a version of onboarding that is hospitality and a version that is puppetry. The code is nearly identical. The only difference is how easy it is to leave.
Ours was not easy to leave. The exit card offered four ways out, and the button most people would press, "Start tuning," dismissed the card and left them on a page with no navigation at all. We had deleted the header when new frame art went in.
I had built a room with a door, then quietly taken the handle off the inside.
**Two: automation is loops, and loops multiply.**
That is the whole trick. A loop is how one action becomes ten thousand, and ten thousand is the entire point of doing it programmatically.
But the loop does not know what it is multiplying. Point it at something good, you get scale. Point it at something slightly wrong, you get scale.
Here is the actual defect:
```js
var clicked = await awaitClick(stereo, 30000);
if (!clicked) { /* they idled - the link is still there */ }
```
If you did not click within thirty seconds, the sequence stayed marked active. Every page load after that found an unfinished tour and started it again. And again.
Pause to read. Take a call. Open a second tab. Now you are in an unbreakable loop watching green text stack over your screen on every navigation, forever.
The abstract worry was that loops multiply unwanted action.
The bug was a loop multiplying an unwanted action.
I did not need the metaphor. I got the thing itself.
---
## You cannot roll back an impression
I killed it in four minutes. One environment variable, one redeploy. Stuck users self healed on their next page load.
The four minutes fixed the software.
Three people wrote in. All of them polite, which somehow makes it worse. What I cannot know is how many hit that loop, decided the app was broken, closed the tab, and said nothing.
We roll back code constantly. It is cheap, it is reversible, and that cheapness quietly trains you to believe shipping is reversible in general.
It is not. You can roll back a deploy. You cannot roll back an impression. The user's memory is the one piece of production state you have no write access to.
And that asymmetry hits automated sequences harder than ordinary bugs. A normal bug is met by someone actively trying to do something, who will try again. An automated sequence is met by someone who did not ask for it, in the middle of something else. It fails at them, not for them.
---
## What changed
The idle timeout now ends the tour instead of arming a loop. The spotlight blocks clicks with real panels, not a shadow. The immersive view has permanent navigation, so choosing to stay is not a one way door.
And the monitor needs to count the people who are stuck, not the people who finished. That is the fix I have not written yet and the one I care about most.
The tour is off until it is done.
The last change is not code. I am going to trust the nagging feeling earlier. It showed up on day two with nothing to point at, which made it easy to file as vague nerves about a fun feature.
It was not vague. It was correctly identifying that I was about to run a loop inside eight thousand sessions, and that the cost of getting it slightly wrong was every user at once, permanently, with no undo.
That is not a reason to skip the fun thing. The escorted walk with a gift at the end is still right, and we will ship it.
It is a reason to build the exits first, and to instrument for the people who never make it out.
Follow the light. Just make sure they can walk away from it.

