My dev environment was hiding eight production bugs
Every convenience in my development setup was concealing a defect on the other side of it. Counting the conveniences found the bugs faster than looking for the bugs.
I pushed a side-project iOS app to TestFlight and then stopped. Every test I had run up to that point had run under __DEV__.
Sixty-second unlocks, anonymous sign-in, sample recordings. Which means the paths that only exist in production had never executed once.
I don't own a spare device, so the most useful thing I could do was read those paths in code. That turned up eight defects, every one of them production-only.
Here is the conclusion first. Enumerate the places where your development environment is being convenient, and the defects are sitting right next to them. Counting conveniences is faster than hunting bugs.
A 60-second unlock hid a duplicate notification
Notifications in this app arrive seven days later. Two things scheduled them:
- creation time registered a task
- a 03:00 daily sweep picked up anything not yet registered
The creation-time path never wrote the flag saying it had registered (notificationScheduled). A seven-day capsule matches the sweep's condition exactly, so both registered it and two notifications fired at the same moment.
In development I opened everything after 60 seconds. Opening it takes it out of the condition, so the 03:00 sweep never once saw it. That's why I never saw it.
Now the creation path writes the flag and claims ownership.
The simulator not receiving notifications hid a missing main path
The tap subscription only used onNotificationOpenedApp, which fires only while the app is alive in the background.
The notification arrives seven days later. By then the app is almost certainly terminated. The main path was missing entirely.
export async function consumeLaunchNotification(): Promise<string | null> {
return capsuleIdOf(await getInitialNotification(messaging));
}
The same blind spot hid a second defect: nothing registered the device token right after the user granted permission.
1. On launch, try to register — fails, no permission (swallowed by .catch(() => {})) 2. User records something, gets asked for permission, grants it 3. Nobody registers here 4. Until the next launch, the server does not know this device exists
The very first capsule unlocks after 24 hours. That lands squarely inside the window above. The first notification is the single most important one a product sends, and it was likely never arriving.
Treating every send failure as a dead token
// before
const deadTokens = response.responses
.map((result, index) => (result.success ? null : tokens[index]))
Plenty of FCM failures are transient: internal-error, server-unavailable, dropped connections, quota. Valid tokens were being deleted by all of them.
Re-registration only happens on next launch. In an app people open once a week, once you enter "no notifications, so I don't open it," you never come back. The loop ends quietly.
Now only genuinely dead codes delete.
const DEAD_TOKEN_CODES = [
'messaging/registration-token-not-registered',
'messaging/invalid-registration-token',
'messaging/invalid-argument',
];
Anonymous sign-in hid a reset that destroys the rhythm
The user-document upsert wrote hasCompletedFirstCapsule: false unconditionally, every time.
Sign out and back in — or reinstall — and the next capsule opens in 24 hours instead of 7 days. The weekly rhythm, which is the whole product, breaks.
In development, anonymous sign-in hands you a new uid each time, so you never hit an existing document. That's why I never hit it.
An always-on connection hid "I recorded it and it vanished"
A function returning the pending-upload count existed. Nothing called it.
Record somewhere with no signal and this happens:
- the upload fails, so no document lands in Firestore
- the home screen reads that collection, so it shows "nothing recorded yet"
- to the user it looks exactly like the recording vanished
Testers use this on trains. That is not an edge case, it's the normal path.
The copy I settled on:
> 3 items haven't been sent yet. > They'll go automatically when you have signal. They are not lost.
"They are not lost" is stated explicitly because in that moment it is the only thing the user actually wants to know.
Permissions being pre-granted hid a dead end
iOS shows the system permission dialog exactly once. After someone taps "Don't Allow," requesting again shows nothing and returns granted: false immediately.
The screen still offered an "Allow" button. Pressing it did nothing. The answer screen deliberately has no way out, so anyone who denied the microphone could not answer at all. That is the response rate, dead.
Now it checks canAskAgain and switches to "Open Settings" when asking is over.
const exhausted =
(mode === 'video' && cameraPermission && !cameraPermission.canAskAgain) ||
(microphonePermission && !microphonePermission.canAskAgain);
The simulator grants permissions, so this branch never ran.
Not having shipped the public feed hid two rule holes
allow update: if isOwner(resource.data.uid); // before
The ownership check reads the uid on the existing document. Rewrite the uid in the request to someone else's and the condition still passes, because the old uid is yours.
Separately, the read condition was visibility == 'public' && moderationStatus == 'ok' while updates were unrestricted — so an owner can set both in the same write.
There is no public feed UI yet, so nothing surfaces. The moment the public feature ships, review becomes self-approval.
Moderation only means anything when the moderated party cannot write the verdict.
allow update: if isOwner(resource.data.uid)
&& request.resource.data.uid == resource.data.uid
&& request.resource.data.moderationStatus == resource.data.moderationStatus;
Some I left alone
| Issue | Call |
|---|---|
createdAt not pinned | You can fake "N days ago," but only to yourself |
status freely writable | Same |
| Owner can read own media pre-unlock | Documented as known in the rules |
Each of these only lets you break your own experience. Re-evaluating when the public feature ships.
What I'd take away
All eight were concealed by a property of the development environment itself.
| What hid it | The defect |
|---|---|
| 60-second unlock | Duplicate notification (never reached the 03:00 sweep) |
| Simulator gets no notifications | Tap from terminated state; token unregistered after grant |
| Anonymous sign-in (fresh uid each time) | Re-sign-in resets to first-run state |
| Stable always-on connection | Upload failure invisible |
| Permissions pre-granted | Anyone who denied once can never record again |
| No public feed yet | uid and moderation verdict both rewritable |
- "It worked in dev" is not evidence that it works in production
- Don't hunt bugs — enumerate where your environment is being convenient
- List the conveniences one line at a time; the defects are next to them
- Treating transient and permanent failures identically deletes the recovery path
- Put failures on screen. Anything not shown reads to a user as "gone"
The app this came out of is Wisp. It stands on your desktop, answers when you talk to it, and runs commands when you ask. It always shows you what it is about to do, and waits for your approval.