DEV Community

Renga
Renga

Posted on • Originally published at wisp-gules-mu.vercel.app

My dev environment was hiding eight production 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));
}
Enter fullscreen mode Exit fullscreen mode

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]))
Enter fullscreen mode Exit fullscreen mode

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',
];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)