It is 09:14 on your first Monday in a mobile AI repo, and you have been handed a file called useInference.ts with three TODOs and no tests.
The feature works on the demo phone in the meeting room: you tap the mic, wait a second, and a transcript appears. Then someone locks the screen mid-request, unlocks it, and the same sentence shows up twice with different wording.
That is not a model problem, and it is not a bug you should fix by tuning a prompt. It is an ownership problem, and you can prove it in the first hour with a stub endpoint that never touches a model at all.
The bug you inherit is not accuracy, it is ownership
On a phone, a request has a lifetime that is shorter than the network's willingness to answer it. The app can be backgrounded, the permission can be revoked, the process can be killed, and the radio can vanish for forty seconds while your fetch keeps a socket open.
If nobody decided who cancels the request and who is allowed to apply the result, the answer will arrive late and be applied anyway. That is how you get duplicate transcripts, stale UI, and a retry queue that quietly uploads audio the user thought was discarded.
Your first PR should make that decision explicit and testable, on a device you can hold, before you evaluate a single word of output quality.
Hour one: get a controllable endpoint, not a device farm
You cannot debug in-flight behavior against a real model endpoint, because you cannot ask it to be slow on demand. You need a stub that returns in exactly 3000 ms, or fails with a 503, or hangs until the client gives up.
I run that stub as a small Node service and deploy it to a throwaway backend so the whole team can hit the same URL from their own phones. MonkeyCode's free server option is a reasonable place to put it during onboarding, and its free model access means a new hire does not need a billing account or a cloud credential before their first commit.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The operator describes the free model access as including a 10M-token allowance, and free-tier terms change, so check the current project page instead of trusting a number in a blog post. Keep the stub stateless and tiny, and keep the real model behind the same route so swapping it later is a one-line change.
The stub contract you actually need
Four routes cover almost every lifecycle bug you will meet in your first month:
-
POST /echoreturns immediately, so you can separate wiring bugs from timing bugs. -
POST /slow?ms=8000sleeps exactly as long as you ask, so you can background the app mid-flight. -
POST /fail?code=503fails deterministically, so you can rehearse the retry policy. -
POST /slow?ms=8000&failAt=4000dies halfway, which is the closest cheap approximation of a dropped cellular connection.
// stub.js - run locally, then deploy to a free server option
import http from 'node:http';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const json = (res, code, body) => {
res.writeHead(code, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
const started = Date.now();
let clientGone = false;
req.on('close', () => { clientGone = true; });
if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
if (url.pathname === '/echo') {
return json(res, 200, { servedAt: started, text: 'stub' });
}
if (url.pathname === '/slow') {
const ms = Number(url.searchParams.get('ms') ?? 3000);
const failAt = url.searchParams.get('failAt');
if (failAt) {
await sleep(Number(failAt));
console.log(`aborted-after=${Date.now() - started} clientGone=${clientGone}`);
return req.destroy();
}
await sleep(ms);
console.log(`served=${Date.now() - started} clientGone=${clientGone}`);
return json(res, 200, { servedAt: started, text: 'slow stub' });
}
if (url.pathname === '/fail') {
return json(res, Number(url.searchParams.get('code') ?? 503), { error: 'forced' });
}
json(res, 404, { error: 'no route' });
}).listen(process.env.PORT ?? 8787, () => console.log('stub on 8787'));
That clientGone log is the most useful line in the file. If it stays true while your app still renders a result, you have found a duplicate-application bug without a model in the loop.
Decide who owns the request, in code
The rule I ask juniors to implement is short: one owner, one controller, and every new request supersedes the old one. Backgrounding, unmounting, and timeout all abort the same controller, and no callback applies a result it does not own.
// useOwnedRequest.tsx - React Native, AbortController-based
import { useEffect, useRef } from 'react';
import { AppState } from 'react-native';
export function useOwnedRequest() {
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => {
if (next !== 'active') {
controllerRef.current?.abort('backgrounded');
controllerRef.current = null;
}
});
return () => {
sub.remove();
controllerRef.current?.abort('unmounted');
};
}, []);
async function send(url: string, body: unknown, timeoutMs = 8000) {
controllerRef.current?.abort('superseded');
const c = new AbortController();
controllerRef.current = c;
const timer = setTimeout(() => c.abort('client-timeout'), timeoutMs);
const t0 = Date.now();
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: c.signal,
});
return { status: res.status, ms: Date.now() - t0 };
} catch (e) {
return { aborted: c.signal.aborted, reason: c.signal.reason, ms: Date.now() - t0 };
} finally {
clearTimeout(timer);
if (controllerRef.current === c) controllerRef.current = null;
}
}
return { send };
}
Log reason on every abort. superseded, backgrounded, client-timeout, and unmounted tell completely different stories when you read the device log a week later, and the distinction is what turns a mystery into a regression test.
Run the four-transition matrix on one device
You do not need a lab for the first pass. One phone, the stub, and a log line per transition is enough to catch the bug that shipped last quarter.
| Transition | Steps | Pass criteria | What to record |
|---|---|---|---|
| Background mid-flight | Tap mic, press home at ~1s, return at ~10s | No result applied; log shows backgrounded; stub logs clientGone=true
|
App state timeline, abort reason |
| Permission revoked | Grant mic, start capture, revoke in Settings, return | Explicit permission error, no silent retry upload | OS version, permission state before/after |
| Client timeout | Point at /slow?ms=8000, send with 8s timeout |
Abort at 8s ±200 ms, UI reaches an error state | Client ms, server served ms |
| Reconnect replay | Send in airplane mode, restore network, relaunch | Exactly one request, no duplicate render | Network type, duplicate count |
Write the environment at the top of the run, because a matrix without versions is a rumor: device model, OS build, framework and library versions, permission state, network type (Wi-Fi, LTE, airplane), and whether the app was cold-started between steps.
What usually leaks first
The reconnect row is where most first PRs fail, because a failed request gets queued and the retry fires after the app already restored optimistic UI. The second leak is the permission row, where the code treats a revoked microphone as a network error and retries forever.
Neither leak is visible in a happy-path demo, and neither will show up in a model evaluation table. They show up as a user hearing their own sentence read back twice.
Rehearse the rollback before you finish the PR
Your first PR should include a way to turn the feature off without a store release, and the stub lets you test that switch for free. Point the client at /fail?code=503, flip the flag, and confirm the fallback path renders in under a second instead of hanging.
If the fallback needs a network call, you have not built a fallback, you have built a second dependency. Keep the disabled state local and boring.
Limitations, and who should skip this
This harness says nothing about model quality, real tail latency, thermal throttling, or battery cost, and it cannot reproduce a genuine cellular handoff between towers. The stub also runs on the server, so clientGone proves the client stopped waiting, not that the OS released the radio.
Skip this approach if you are benchmarking on-device inference throughput, if your data flow is regulated end to end, or if you already have deterministic network-condition tooling in CI. Also re-check the free tier terms yourself before you plan a team onboarding around them.
What I want from your first hour
Run the four rows above on one device, keep the log lines, and note which transition failed first. Then open your PR with the matrix, the versions, and the abort reasons attached, because that turns a vague feature into a reviewable contract.
If you need a throwaway endpoint to point the client at, a free server option plus free model access is a low-friction way to get there, and you can swap in the real model once the ownership rules hold. Post your device, OS build, and the transition that broke, and say whether it recovered, restarted, or silently disappeared.
Top comments (0)