Skip to content
Plazoleta
· Raúl López

The capitalization bug that made "Continue watching" disappear

Mobile and TV wrote "MOVIE", the web wrote "movie", and it was the same column. On why a bug that fails halfway is worse than one that blows up completely.

You start a movie on your laptop, leave it halfway through, turn on the TV. And it's not in "Continue watching".

But you go into the catalog, search for the movie, and there's its page with the progress bar halfway filled. The TV knows exactly where you left off. It just doesn't offer it to you.

That was the bug. And what made it expensive wasn't fixing it — that's a call to uppercase() — it was believing it.

One column, two platforms, two languages

In LoverCast the user's state is stored locally on the device and synced to the cloud. "Continue watching" lives in a column called watch_progress, and each item in that list has a type field saying whether it's a movie or a series.

Mobile and TV are Kotlin, and there they have an enum:

enum class WatchType { MOVIE, SERIES }

When they serialize an item to upload it, they use what everyone uses: WatchType.name. That produces "MOVIE" and "SERIES", uppercase, because that's how the enum constants are named.

The web is TypeScript, and there the type is written the way it's written in TypeScript:

type: "movie" | "series"

Lowercase. Both decisions are correct on their own. Each is idiomatic for its language, and nobody did anything weird. The problem is that both write to the same column, and when reading it each platform compared against its own form.

Result: web items didn't show up in "Continue watching" on mobile and TV. And mobile and TV items didn't show up in the equivalent category on the web. Each platform saw its own stuff and silently discarded the other's.

Why it failed halfway, which is the worst thing that can happen

Here's what turned a one-line bug into an afternoon of looking in the wrong place.

The progress bar did show up. And it showed up because it doesn't go through the same column. Per-item progress lives separately, in movie_progress, and that row doesn't have a type field at all:

data class RemoteMovieProgressData(
    @SerialName("movie_id") val movieId: String,
    @SerialName("progress_ms") val progressMs: Long = 0L,
    @SerialName("duration_ms") val durationMs: Long = 0L,
    @SerialName("manually_completed") val manuallyCompleted: Boolean = false,
)

It's indexed by movie_id and that's it. There's nothing to misinterpret, so nothing got misinterpreted. The half of the system that didn't depend on the agreement worked perfectly.

Think about what that does to diagnosis. If the movie had disappeared entirely — no bar, no progress, no trace — the hypothesis is immediate: it didn't sync. You check the sync, see it did arrive, and in ten minutes you're looking at the type field.

But since the progress was there, the hypothesis that comes to mind first is the opposite one: sync works, so the bug must be in the home screen. And you go check the "Continue watching" ViewModel, which is not where the problem is.

A total failure is a sign. A partial failure is a false lead: it proves to you that the part you suspect works, and sends you off in the wrong direction.

The fix: tolerate in both directions

The obvious move would be to pick one format and make everyone write it. And that's not what was done, for a very specific reason: there are installed APKs that are going to keep writing "MOVIE" for months, and there's already data written in both forms in real users' rows. You can change what you write from today onward. You can't change what's already written, nor what an app someone hasn't updated writes.

So the rule that got set is to tolerate in both directions. In Kotlin, normalize on read from the cloud:

// The web writes the type in lowercase ("movie"/"series"); mobile/TV in uppercase
// (WatchType.name). We normalize before valueOf so we don't drop the web's items.
val watchType = runCatching { WatchType.valueOf(type.uppercase()) }.getOrNull() ?: return null

And on the web, normalize on ingest, not on render:

const normalizeType = (x: RemoteWatchProgress): RemoteWatchProgress =>
  x.type === "movie" || x.type === "series" ? x : { ...x, type: x.type.toLowerCase() };

The detail that matters in the web version is where it's placed. It normalizes as the data comes in, not at every place that consumes it. If you normalize on render, you have to remember to do it in the Series filter, in the Movies filter, in the conversion to playable, and in the three places that will get written next year. You'll forget one. Normalizing at the boundary, the rest of the code never even knows the problem existed.

That's what separates a fix from a patch: the patch fixes the symptom where it's visible, the fix addresses it where it enters.

What tolerance hides

Being tolerant on read has a cost worth saying out loud, because it's easy to settle for the nice moral of the story.

Look at the ?: return null in the Kotlin version. If someday a type arrives that's neither of the two — a new platform, a typo, a format someone thought was a good idea — that item gets silently dropped. Exactly the same behavior that caused the original bug, just now for a case that hasn't happened yet.

It's a defensible decision: dropping a weird item is better than crashing someone's home screen over bad data. But it's a decision, not a solution. And tolerance has the bad habit of papering over the problem well enough that nobody ever fixes it for real.

Because the real fix isn't better normalization. It's that this field shouldn't be a free-form String traveling across three platforms:

data class RemoteWatchProgressData(
    val id: String,
    val type: String,   // ← anything can go here
    ...
)

"MOVIE" fits, "movie" fits, "Movie" fits, and "pelicula" fits too. Kotlin's type system and TypeScript's are both excellent, and neither helps you here, because the agreement doesn't live in either language: it lives in the gap between them.

The enemy wasn't the bug

And that's the bottom of the matter, beyond LoverCast.

The agreement on how type gets serialized existed. It was in the head of whoever wrote the Android client and in the head of whoever wrote the web, and in each head it was different, and both were right by their own standard. A verbal agreement, with no single place that defines it.

The places where this happens are easy to spot once you've seen one: a JSON column written by several clients, an API field with no shared schema, a config file that one service reads and another writes, any String that's actually a disguised enum. In all of them the compiler is happy, each side's tests pass, and the contract only gets checked in production with someone's real data.

The countermeasure isn't remembering better. It's having a single place that defines the shape, and having that place generate or validate what everyone else does: a shared schema, a contract test that writes with one client and reads with another, or — the cheapest option of all, if nothing else fits — a database constraint that rejects anything that isn't one of the two forms. Anything that turns "we agreed" into something that fails on its own.

In LoverCast, for now, the countermeasure is more modest: the consistency rules for "Continue watching" and "Already watched" are written as numbered invariants in SYNC.md, locked in after a round of bugs like this one, with instructions to keep them in mind for any future change. It's documentation, not a test. It doesn't run. It's worth less than a contract test and we know it — but it turns a tacit agreement into a written one, which is the first step, and it cost an afternoon.

What we're taking away

A partial bug is more expensive than a total one. A failure that leaves half the feature standing doesn't just hide the problem: it hands you proof that the layer you suspect works fine. When something fails "just a little," be suspicious of exactly what the symptom seems to rule out.

Normalize at the boundary, not at the consumer. If the data comes in through one place, fix it there. Normalizing where it's rendered means signing up to remember forever, in every place, including the ones that don't exist yet.

A String that only allows two values is an enum missing the part that actually helps. The agreement between platforms doesn't live in either platform's language, which is why no compiler is going to defend it for you. If the contract isn't in a place that fails on its own, it's not a contract: it's just that both of you remember, for now.

None of this shows up in the app. You turn on the TV and the movie you left on your laptop is first in line. Which is where it should've always been.


LoverCast is live and you can download it. If you have a cross-platform contract that only gets checked in production, tell us about it.

Got a similar problem?

Tell us about it. You'll be talking straight to whoever writes the code.