While auditing the live build of an Android port, I found it: every logged-in new user was stuck on the splash screen.
Existing users were fine. So there were zero reports.
Is your new-signup path exercised by the same code that verifies your existing-user path?
A default does not rescue a null
One column on the user-row table is nullable. The model read:
val timezone: String = "UTC"A default applies when the key is absent. It does not apply to an explicit null.
For accounts that haven't finished onboarding, that column is always null. So the call that reads one row blew up entirely.
JsonDecodingException: Unexpected JSON token at offset 183:
Expected string literal but 'null' literal was found at path: $[0].timezoneThat failure became a profile-load failure, and the failure handler reset the "profile loaded" flag to false. The spinner ran forever.
One date column that erased the whole screen was the same family — this time it kept new users out of the app entirely. Ported apps are especially prone to it. You carry the iOS model across and assume "it has a default, so it's safe."
A second defect hid the first
Finding the cause took four minutes. For those four minutes the screen showed nothing but a spinner.
The error snackbar host lived only inside the main screen's scaffold. So errors raised on the splash and auth screens had nowhere to appear. Typing a wrong password changed nothing on screen, which is indistinguishable from "the button is broken."
I fixed three instances of that shape in one session: profile-load failure now shows a retry screen, sign-in failure an inline message.
Which gives a rule: when you build a gate screen, check that the gate screen itself can display a failure. If error display lives only behind the gate, users blocked at the gate get no explanation at all.
Two settings that look alike
I had explicitNulls = false on the decoder and felt safe. That is an encoding-side setting. It does not protect reads.
Reads are this one:
coerceInputValues = trueAnd even that only half-rescues you. coerceInputValues saves fields that have a default. Fields without one (val content: String) need = "" added explicitly. I checked every one by hand.
One more: leaving the serialization config inline inside the client-construction block means tests can't reach that config. I extracted it into a dependency-free object and pass it in.
What would you do?
You have eight apps in the fleet and need to know which share the defect.
- Read the model files — dozens of fields have defaults. Nullability isn't in the code at all.
- Query column metadata — pull nullable columns from
information_schema.columns. - Query real tables only — filter out views.
I started at the second and ended at the third. information_schema.columns reports every column of a view as nullable. One app looked entirely at risk while its real table columns were NOT NULL — confirmed by UPDATE-ing a null in and reading the constraint-violation code.
To restrict to real tables:
join pg_class k on k.relname = c.table_name
join pg_namespace n on n.oid = k.relnamespace and n.nspname = c.table_schema
where k.relkind = 'r'Across the fleet the real exposure was two apps; the other six were clean. One was a false positive — a server-function response model, whose DB row model was fully nullable anyway.
Three checks
- Are you assuming a model default guards a nullable column? Defaults cover absent keys only. An explicit null still blows up.
- Can your gate screens (splash, sign-in) display an error? If not, users blocked there see only a spinner.
- Are you judging nullability from
information_schemaalone? Views report every column as nullable, so false positives mix in. Restrict withrelkind = 'r'.
The honest part
This defect hits new signups only. And new signups are exactly the people with no reason to contact me yet — they've never used the app.
So "zero reports" carried no information. I found this through a live audit, not a user report, and I don't know how much longer it would have run without one.