Engineering Journal · PRODUCT ENGINEERING · March 18, 2025 · 16 min read
The Phone Will Keep the Contract You Already Broke
Web deploys in an hour. A Flutter build lives in the wild for months. The backend has to survive both, or you will call a store release an incident.
By Golam Sorwar, Tech Lead and Full Stack Engineer in Dublin.
I can deploy a website between meetings. I cannot deploy every phone. That single asymmetry has done more to my API habits than any style guide. The Flutter apps I have worked on — student-facing, service booking, the kind of client that sits in a pocket on a bad radio link — keep old assumptions the way pockets keep lint.
If the backend treats every caller like yesterday's SPA, you will ship a cleanup and meet it as a one-star review. The review will not say "you removed a field." It will say the app is broken.
This is not a widget tutorial. It is the system-design bill of a second runtime you do not patch at will.
Context
The pattern I know is a Laravel API, a web admin or portal, and a Flutter client that talks HTTP, stores a token, and sometimes receives a push. Networks are hostile. Users do not update. Review cycles are slower than your patience.
I work with mobile engineers rather than pretending to be one. My job on this boundary is the contract: what the phone may assume next month, and what we will do when it assumes last month.
That contract includes the ugly verbs. Login. Refresh. Upload. Pay. Resume a list. Recover from a 401 in a tunnel. If we only design the happy GET, we will meet the ugly verbs as crashes, and we will blame Flutter. Flutter was obeying physics.
The problem
The apparent problem is feature parity. The app should do what the website does.
The real problem is time-to-force-upgrade is not a number you own. Until you are willing to block old binaries, every additive backend change must be survivable by a client that has never heard of it, and every breaking change is a coordinated programme.
Offline and slowness make it worse. The phone will retry. The phone will show cached lists. The phone will submit twice. Your pretty POST is now a distributed system with a human in the loop.
The problem is also attention. A website user who hits an error can refresh. A phone user on a platform may not. They will tap again, background the app, or decide the institution is broken. Your API's manners — idempotency, stable errors, resume — are the product those people meet. The Flutter code is how that meeting is dressed.
The tempting solution
Change the API, update the app, submit to the store, done. This works in a slide. In a week you have a mixture of binaries, some still on a beach with a week-old build.
Or build a private, mobile-only API that you break freely because "only we consume it." You still do not consume it on your schedule. You consume it on the store's.
Why that is not enough
Auth sessions that assume a short-lived browser tab will punish a phone. Tokens need refresh and revocation. A logged-in phone is a long-lived credential in a hostile pocket.
Push is not a guaranteed inbox. It is a hint. If the product requires the push to have been seen, you have designed a wish.
Pagination and search that were designed for fat web connections become jank and duplicate rows. The contract needs to say how to resume.
Error payloads that were designed for a page reload become uncaught exceptions. Mobile clients need stable codes, not only English.
Web and mobile also disagree about identity of a session. A browser cookie that dies with a tab is a different animal from a token that lives in secure storage for months. If you design auth for the tab and issue it to the pocket, you have given a long-lived credential the manners of a short one — or the reverse: you have made the website carry a phone's lifetime. Pick the stricter story and implement it twice, or you will implement the sloppy one twice.
Options
One public API for web and mobile, additive and strict about compatibility. Advantage: one test suite, one model. Disadvantage: the slowest client sets the pace. That is often correct.
A backend-for-frontend for mobile. Advantage: you can shape payloads for radio and battery. Disadvantage: two surfaces to break. I add this when the mobile needs aggregates the web should not drag around, not as a way to hide sloppy versioning.
Force upgrade. Advantage: you get to break things. Disadvantage: you spend goodwill, and you still need a window. Force upgrade is a tool for unsafe old clients, not a substitute for manners.
Accept that some features are web-only until the app catches up. Advantage: honesty. Disadvantage: product people dislike forked capability. I still prefer a fork in capability to a fork in truth.
Ship the backend change in two releases: first the additive tolerance, then the app that uses it, then — much later — any floor. Advantage: the wild binaries have a chance. Disadvantage: you live with dual shapes. That is the cost of a store. Anyone who wants one release for both clocks is asking the pocket to attend the stand-up.
Trade-offs
You carry fields you are ashamed of. You carry query parameters you renamed in your head but not in the world. That shame is cheaper than a store emergency.
You accept that "released" for mobile means "available," not "adopted." Metrics of binary versions, if you have them, should humble your cleanup plans. If you do not have them, assume a long tail.
You should not accept silent data loss because the phone was offline. Queue the write or refuse it. Do not pretend it landed. A 200 you never sent is not optimism. It is a second ledger.
Decision
I treat the mobile client as a customer of the API with a slower clock than the website. Additive changes land first. The app uses them when it can. Breaking changes wait for a version floor we can defend.
Idempotency keys on creates that the user can tap twice. Refresh tokens that we can kill. Payloads that degrade: unknown fields ignored, missing optional fields tolerated. That is not sloppy. That is adult.
I also want a kill switch that is not "hope they update." A config flag the app already knows how to read can disable a broken flow without a store review. That is not a substitute for compatibility. It is how you survive the week between discovering a bad payload and getting a binary adopted.
Implementation / Thinking process
Contract tests include an old fixture. If the new code cannot read what last quarter's app sent, we have already failed.
List endpoints that feed infinite scroll need a cursor or a very honest snapshot. "Page 2" after an insert is how users see ghosts and duplicates.
File uploads and poor radio need resume or a patient failure, not a 30-second request that dies in a tunnel.
When we add a field the old app must not require, we do not add it as required in the general schema and then "make the website send it." The next client will be a script that does not.
Push sends a pointer, not the whole privileged payload. The app fetches through the same auth as everyone else. Side-channel data in a notification is how you leak.
Auth refresh has to be designed as a state machine the app can run in the background. If every 401 dumps the user to a login screen, you will train them to hate the product, and you will also hide the real failure: the refresh token died, the clock is wrong, or the server started requiring a scope the old binary does not request. Those are different repairs.
When we change pagination, we keep the old query working. Mobile lists are long-lived in memory. A new cursor scheme that 400s the old page parameter is a crash on a train.
I also want the backend to tolerate an app that is briefly a liar. It will show a cached list. It will retry a POST. It will keep a draft the server never saw. Your job is not to punish that. Your job is to make the lie reconcilable: the list can refresh, the POST can be the same intent, the draft can fail with a sentence. A backend that assumes a fresh browser on every tap will call ordinary mobile behaviour abuse.
Failure modes
A required field added for a new web form. Old app cannot post. Users look incompetent. They are not.
A datetime format that changed from date to datetime. Parsers are petty. So are stores.
Assuming the app will refresh its config on next launch. Some users do not launch. Some launch offline. Config that is safety-critical needs a default that fails closed.
A single global error toast for every 422. The user cannot fix "eligibility." They can fix "phone number." If you do not distinguish, they tap again.
A push that contains the privileged fact because the fetch seemed expensive. The notification shade is not your ACL. Fetch through the same door as the rest of the product, or you have built a side channel with a bell on it.
Operational consequences
Support will report "the app" when the API changed. You need a way to ask which version. If you cannot, you will guess, and you will guess the latest.
Release planning now has two clocks. If product ignores the store clock, engineering will become the people who "block" features. Name the clock in the planning meeting. It is not drama. It is physics.
Support will also meet offline as a personality. "They said they submitted." Maybe they did, on a radio that died before the 200. If you cannot show the intent as pending or failed, you will argue with a human about a packet. The argument is the cost of a create that had no memory.
Lessons
A backend that serves a phone is a compatibility product. The website is a privileged client that happens to deploy with you. Do not let it set the manners for everyone else.
If you cannot force an upgrade, you have not finished the change. You have published a hope. Hope is not a release plan for a binary you cannot recall.
What I would do differently today
I would have versioned the mobile contract in conversation before we had a second screen. The first screen teaches bad freedom.
I would have added idempotency on the first tap-happy create, not after support saw doubles. Phones double-submit. That is not a user error. That is a transport.
I would have put a minimum-version signal in the first payload the app already fetches, even if we never blocked anyone. Building the pipe is cheap when you do not need it. Building it during a bad field is how you ship a panic binary.
Closing thought
The phone will keep the contract you already broke. Design the API for the binary you cannot recall. Deploy the website as often as you like. The pocket is on another calendar, and it does not attend your stand-up.