The Boring Features That Decide Whether I Can Trust My Own App
The app had reached a weird stage.
The exciting systems were working.
Vocabulary had become adaptive.
Exam mode had become a real exam engine.
The app could analyse mistakes, timing, retention and weak areas.
It could generate repair sets.
It could start making recommendations instead of just showing numbers.
Which meant the next milestone was obviously...
Backup and sync logic.
Very exciting.
But this was also the point where I realised something important.
The smarter the app becomes, the more dangerous unreliability becomes.
If a simple question app loses one answer, that is annoying.
If a learning system loses part of my history, it can start understanding me incorrectly.
That is much worse.
So I stopped building intelligence for a while.
And started building trust.
The app now had something worth losing
Earlier versions stored very little.
A few attempts.
Bookmarks.
Basic progress.
If something went wrong, I would be annoyed.
Now the app was accumulating much richer state.
Vocabulary mastery.
Lapses.
Practice history.
Exam attempts.
Timing evidence.
Error patterns.
Weekly activity.
Recommendations.
Repair history.
Personal intelligence depended on that information being correct.
So reliability stopped being infrastructure work happening somewhere underneath the product.
It became part of the learning system itself.
If the data is wrong, the intelligence built on top of it is wrong too.
Offline-first had to become more serious
I had already decided much earlier that the app should work offline.
That part was not new.
The principle was still:
The student should be able to keep studying even when the internet disappears.
But the implementation now needed to become much more robust.
It was no longer enough to say:
"Save something locally and sync later."
There could now be many changes waiting to sync.
Attempts.
Vocabulary interactions.
Progress updates.
Review actions.
Exam results.
Different parts of the system could generate state at different times.
So I needed a proper way of remembering pending work.
That led to an offline queue.
Or more accurately, an outbox.
The outbox became the safety net
The idea is simple.
When the app creates an important change, it should not immediately assume the cloud received it.
The local device records that change first.
If the network is available, the app can sync it.
If the network is unavailable, the change waits.
When connectivity returns, the outbox can process those pending actions.
That means the user's progress does not depend on one network request succeeding at exactly the right moment.
The important part is not that the app knows the internet is offline.
The important part is that it knows:
There is unfinished synchronization work here.
That is a much safer model.
Then came race conditions
This is one of those terms I had heard before without really caring about.
Then I built something where it mattered.
Imagine this sequence:
The app loads old cloud state.
I complete new work locally.
A sync finishes slightly later.
The old server response gets applied after my new local action.
Now the app has accidentally overwritten fresh progress with stale information.
Everything technically "worked."
The requests succeeded.
The data still became wrong.
That is a race condition.
And when I started thinking about sync properly, I realised there are many versions of that problem.
Two processes finishing in the wrong order.
Old state arriving after new state.
Multiple updates touching the same data.
A background sync running while the user continues working.
Those are not visual bugs.
They can silently damage progress.
Fresh local actions should not lose
That became one of the rules.
If I just completed something on the device, an older cloud snapshot should not be allowed to erase it.
The sync model needed protection against stale state winning simply because it arrived later.
This is the kind of feature nobody will ever compliment.
No student is going to say:
"Wow, excellent race-condition handling."
They will simply use the app and never realise a problem was prevented.
That is probably the best possible result.
Content needed offline rules too
There was another side to offline behaviour.
Progress is only useful if the actual learning content is available.
Questions.
Vocabulary.
Exam configuration.
Explanations.
The app cannot behave offline if every session depends on downloading everything again from the server.
So content needed its own offline strategy.
That led to versioned offline content packs.
The device can keep a usable package of learning material locally.
That way the app still has something to serve when the network disappears.
And because the content is versioned, the system can eventually understand whether the local pack is current or needs updating.
That is much cleaner than treating offline content as a random cache and hoping nothing changes.
Versioning matters more than it sounds
Suppose I change a question.
Or fix an explanation.
Or update exam rules.
The device may still have an older copy.
Now the system has two realities.
That is not necessarily a problem if the app understands versions.
It becomes a problem if it does not.
Versioning lets the system reason about what it has.
Which content pack is installed?
Is a newer one available?
Can the current one still be used?
What happened when a historical attempt was completed?
As the product becomes larger, these boring identifiers become increasingly important.
They make change manageable.
Updates should not destroy studying
I also had to think about the app itself being updated.
A student should not install a newer version and discover that:
Progress vanished.
Offline content disappeared unexpectedly.
The local database no longer works.
Something critical became incompatible.
Or the app suddenly requires a fresh connection before anything opens.
Updates are supposed to improve software.
They should not feel like gambling with your study history.
So install and update behaviour became part of the reliability milestone too.
The goal is not to make updates impressive.
It is to make them boring.
You update.
The app still works.
That is success.
Backup started sounding less paranoid
Earlier in the project, backup and restore would have felt excessive.
Why create backup systems for a tiny private prototype?
Now it made much more sense.
I had enough history that losing it would genuinely hurt.
So I wanted the user to have another layer of protection.
A backup should make it possible to preserve important state.
A restore process should make it possible to recover it.
This is especially valuable while the product is still evolving.
I am changing architecture.
Changing storage.
Changing features.
Changing content.
Having a way to recover data makes experimentation much less dangerous.
Restore has to be trustworthy too
A backup button is easy to add.
A trustworthy restore flow is harder.
What exactly gets restored?
Does it overwrite newer information?
What happens to account data?
What happens to local-only changes?
What happens if the backup comes from an older version?
I did not want "backup" to become another checkbox feature that exists mostly for screenshots.
If I ever need it, that is probably already a bad day.
The system needs to behave predictably.
Account controls finally became real product work
The original prototype did not even have accounts.
That simplicity was one of the reasons I was able to build it quickly.
But a serious product eventually needs control over account state.
That includes things like:
Understanding what is stored.
Managing the account.
Signing out cleanly.
And being able to delete it.
Account deletion is especially important.
If someone decides they no longer want the product, they should not be trapped inside it just because deleting data is inconvenient for me to implement.
That is a basic product responsibility.
Deletion is harder once data is connected
Of course, once the app has:
Practice history.
Vocabulary state.
Exam attempts.
Progress.
Personal intelligence.
Cloud records.
Possibly offline copies.
"Delete my account" becomes more complicated than removing one row.
The system needs to understand what belongs to the user and what should disappear.
That complexity is not an excuse to avoid giving the control.
It is a reason to design the data model properly.
Reliability changed how I evaluate features
Earlier in the project, a milestone often felt done when something worked.
Now I ask more annoying questions.
What happens if the internet disappears here?
What happens if the app closes?
What happens if this action happens twice?
What happens if sync finishes in the wrong order?
What happens after an update?
What happens if local and cloud state disagree?
What happens if the user wants their data back?
What happens if they want it deleted?
That makes development slower.
It also makes the product less fake.
The happy path is no longer enough
The happy path looks like this:
Internet works.
Server responds.
User completes action.
Data saves.
Everything displays correctly.
That path is easy to demo.
But real software spends a surprising amount of time outside the happy path.
Connections fail.
Tabs close.
Devices sleep.
Requests repeat.
Users click things twice.
Updates happen.
Old data exists.
New data arrives.
The interesting engineering is often everything around the perfect scenario.
That is what this milestone forced me to confront.
This was also the point where the app started feeling less like a prototype
Not because it looked more polished.
Actually, visually, this milestone probably changed less than some of the earlier ones.
But the system underneath it became much more serious.
There is a difference between:
I built something that works on my laptop.
and:
I am designing something that should survive real use.
Offline queues.
Versioned content.
Sync protection.
Backups.
Restore.
Account controls.
Deletion.
None of those make a good hero screenshot.
Together, they change what kind of product I am building.
I still have cosmetic things I want to fix
A lot of them.
There are screens I want to redesign.
Animations I can improve.
Spacing I want to change.
Interactions that can feel better.
That work matters.
But at this stage, I deliberately prioritised reliability first.
A beautiful app that occasionally loses progress is a bad study tool.
A slightly imperfect app that preserves everything correctly is at least trustworthy.
I can polish trustworthy software.
It is much harder to decorate my way out of broken data.
And then we reached the question bank problem again
Once the reliability milestone was finished, the major product systems were starting to look surprisingly complete.
The vocabulary engine existed.
The exam engine existed.
Personal intelligence existed.
Offline behaviour was much stronger.
Account controls were taking shape.
The architecture was ready for much more content than the app actually contained.
Which brought me back to the problem I had been postponing.
Questions.
A lot of questions.
Earlier, I could get away with 32.
Then 64.
Then a growing research bank.
But the app I had built now deserved enough content to actually exercise all of these systems properly.
The obvious solution would be:
Generate thousands of questions.
Dump them into the database.
Celebrate.
I think that would destroy the product.
So instead, the next problem became one of the hardest ones yet:
How do I build thousands of exam questions without filling the app with garbage?