You unlock a staging Pixel on morning one and the chat bubble is still spinning after a twenty-minute commute. The phone is on Android 15, the React Native build is signed in, and the stream that looked healthy at the office never returns. That sleep-to-foreground handoff is your first-hour job, not a polish task after you ship copy. Treat the spinning bubble as the product, because every later agent loop will inherit the same socket.
Hour one is a lifecycle map, not a repo tour
Most onboarding docs walk you through login, a sample prompt, and then a pull request template. You should invert that order whenever the screen talks to a model. The composer, the retry queue, and the streaming parser all assume the process stayed warm. Sleep, Doze, and a force-stop each violate that assumption in a different way, and a junior merge will hide the difference.
Ask the person who assigned the ticket for three facts before you clone extra tools. You need the inference base URL, whether the client holds a streaming HTTP connection, and what the UI does when that socket dies. If nobody can answer those, you already have your first pull request: write the gaps down. Do not start by swapping models or wrapping the call in an agent. Agentic samples this week spend pages on tools and almost none on what the OS does when the screen turns off.
Record the environment before you touch code
Write this block into the ticket even when the values feel obvious to the team:
- Device and OS, such as Pixel 8 on Android 15, or iPhone 14 on iOS 18
- App stack: React Native version, New Architecture on or off, debug versus release
- Network: Wi-Fi, cellular, or airplane mode after the first tokens arrive
- Power: charging, battery saver, and whether Adaptive Battery is enabled
- Permissions: only those the feature actually uses, including network
- Starting state: signed in, composer focused, one completion already in flight
- Transition: lock for ten minutes,
adbDoze, then unlock and read the bubble
You are not collecting vanity benchmarks for a launch blog. You are collecting enough context that a reviewer can replay the failure on their own phone. If you skip the starting state, “it hung” is not a bug report they can act on. Do not invent timings for a device class you did not hold. Label every observation as recovered, restarted, or silently disappeared.
Proposed test: sleep, Doze, then a dead process
This is a proposed single-device experiment, not a lab result measured on a farm. Run it on the hardware in your hand and write down what the UI did after each step.
1. Establish a live stream
- Disable battery saver and keep the device on a known Wi-Fi network.
- Open the staging build and start a long completion, not a one-token ping.
- Confirm tokens arrive for at least ten seconds so the socket is actually live.
- Note the request id from logcat, Charles, or your debug overlay.
2. Sleep without killing the app
Press the power button, wait ten minutes, then unlock and return to the same chat. Watch the bubble instead of sending another prompt. An honest result is one of three outcomes: the stream resumes, the client retries with a new id, or the spinner dies with no error. Optional check that the process is still cached:
adb shell dumpsys activity processes | grep -i your.package.name
3. Force Doze and App Standby
Only do this on a device you own or on a dedicated staging phone.
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle
# wait two minutes with the stream still "open" in the UI
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset
Expected observation, not a promised millisecond count: Doze should freeze background network for a non-exempt app. If tokens continue anyway, the build may hold a foreground service or a battery exemption you did not know about. That exemption belongs in the pull request description, because reviewers cannot see it from a screenshot of the chat.
4. Kill the process and reopen
adb shell am force-stop your.package.name
# relaunch from the home screen, not from the debugger
If the retry queue still holds the original prompt, you now have a privacy and correctness bug, not a UX nit. The first rollback drill later depends on this queue being inspectable in a debug overlay. On iOS, pair the same idea with a swipe-kill from the app switcher, because inactive is not process death.
Decision table for the handoff
Use this table in the ticket. Fill the last column on the device, and refuse to merge on guesswork.
| Transition | Fail closed looks like | Record on device |
|---|---|---|
| Lock for 10 minutes | Typed error or explicit retry id | recovered / restarted / disappeared |
deviceidle force-idle |
No hidden tokens without an exemption | recovered / restarted / disappeared |
am force-stop |
Prompt is not replayed against production | recovered / restarted / disappeared |
| Stub host stopped | Error in the composer, no host fallback | recovered / restarted / disappeared |
A row that says disappeared is already enough to block the first pull request. Do not “fix” it by pointing the client at production from a debug binary.
Put the inference host in config, then point it at a stub
Your first pull request should not train anything and should not add tools. It should make the base URL injectable so hour-one failures stay cheap and reversible.
// src/config/inference.ts
// Proposed client config. Verify against your repo's existing env pattern.
export type InferenceConfig = {
baseUrl: string;
timeoutMs: number;
allowRetry: boolean;
};
export function loadInferenceConfig(): InferenceConfig {
const baseUrl = process.env.INFERENCE_BASE_URL;
if (!baseUrl) {
throw new Error("INFERENCE_BASE_URL is missing; refusing to guess production");
}
return {
baseUrl,
timeoutMs: Number(process.env.INFERENCE_TIMEOUT_MS ?? 20000),
allowRetry: process.env.INFERENCE_ALLOW_RETRY === "true",
};
}
# .env.staging.example
INFERENCE_BASE_URL=https://stub.example.internal/v1
INFERENCE_TIMEOUT_MS=20000
INFERENCE_ALLOW_RETRY=false
Leave INFERENCE_ALLOW_RETRY false until the sleep test has a defined recovery path. Silent retry after process death is how prompts get duplicated on the wire. It is also how caches wander into backups you have not audited yet.
If you need a host you can destroy without filing a ticket, MonkeyCode’s free server option is enough to hold a tiny streaming stub. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free model access on that product can draft the AppState glue below; it cannot watch your lock screen or prove Doze.
Keep the stub intentionally stupid so the phone remains the system under test:
// stub/server.mjs — proposed local stand-in, not a production gateway
import http from "node:http";
http
.createServer((req, res) => {
if (req.url !== "/v1/chat/completions" || req.method !== "POST") {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, { "content-type": "text/event-stream" });
const timer = setInterval(() => {
res.write(
`data: {"id":"stub-1","choices":[{"delta":{"content":"x"}}]}\n\n`
);
}, 400);
req.on("close", () => clearInterval(timer));
setTimeout(() => {
clearInterval(timer);
res.write("data: [DONE]\n\n");
res.end();
}, 15000);
})
.listen(process.env.PORT ?? 8080);
You now have a stream you can start, a device you can sleep, and a process you can kill. That combination is the onboarding lab. Do not add authentication theater until the four rows in the table have real words in them.
Watch AppState so the UI tells the truth
// Proposed React Native harness. Confirm AppState on your RN version.
import { useEffect, useRef } from "react";
import { AppState, AppStateStatus } from "react-native";
export function useInferenceLifecycle(
onInterrupted: (reason: string) => void
) {
const state = useRef<AppStateStatus>(AppState.currentState);
useEffect(() => {
const sub = AppState.addEventListener("change", (next) => {
const prev = state.current;
state.current = next;
if (prev === "active" && next.match(/inactive|background/)) {
onInterrupted(`app_left_foreground:${next}`);
}
if (next === "active" && prev !== "active") {
onInterrupted("app_returned_foreground");
}
});
return () => sub.remove();
}, [onInterrupted]);
}
On iOS, write down whether you backgrounded from Control Center or from the lock button, because inactive is not the same as background. On Android, pair this listener with the Doze commands so you do not confuse an OS network freeze with React Native blurring the activity. Log the reason next to the request id. Reviewers cannot guess which transition you actually hit from a cropped screen recording.
First pull request: a checklist, not a model swap
Title the pull request around the lifecycle, for example “Fail closed when inference drops after sleep”. Include all five of these artifacts:
- A config flag for the stub URL and a screenshot of the sleep outcome.
- The three-way result: recovered, restarted, or silently disappeared.
- Whether a foreground service or battery exemption was required.
- Retry disabled until the queue is visible in debug builds.
- A rollback note: how to point the build back at production and uninstall the stub host.
Skip agent frameworks until this checklist is green on one physical device. Cheap generated code will happily add tools that call the network from a background isolate you never ran through Doze. Your reviewer should be able to repeat the lock-and-unlock path without asking you which phone you used.
First rollback: kill the stub while a stream is open
Rollback is not git revert alone when the binary already lives on a phone. You need the installed build to survive the host disappearing without quietly retargeting production.
- Start a stream against the stub while the app is in the foreground.
- Sleep the device for two minutes so the socket is no longer obvious.
- Stop the free server process or undeploy the stub entirely.
- Unlock and open the same chat, without sending a new prompt.
- Record whether the UI shows a typed error, retries against production, or hangs.
If the client falls back to a hardcoded production host, that is a release bug. Juniors ship that fallback because it unblocks a hallway demo. You should refuse it. Production is not a spare tire for a staging stub, and a first rollback that leaks prompts into the real account is worse than a red bubble.
# Prove the installed binary no longer references the stub after revert
npx react-native bundle --platform android --dev false \
--entry-file index.js --bundle-output /tmp/app.jsbundle
grep -n "stub.example" /tmp/app.jsbundle || echo "no stub URL in bundle"
An empty grep result is the expected observation after a clean rollback. A hit means the URL was compiled in, and you need another build before you call the drill done. Repeat the bundle check on iOS with your usual npx react-native bundle --platform ios path so the two store binaries cannot drift.
Tradeoffs and who should not do this
This workflow is for a junior joining a mobile AI repo that already has a chat or completion screen. It is not a substitute for Play policy review, medical-device process, or load testing. Do not run deviceidle force-idle on a personal phone that holds the only authenticator. Do not point a debug build at production keys. Do not store stub prompts in device backups; that is a separate audit.
Write these limitations into the ticket so the next hire does not over-read your notes:
- Doze and iOS background rules change by OS version, so one Pixel is not a fleet.
- React Native
AppStatedoes not equal process death, and emulators lie about both. - A free server stub will not reproduce production auth, chunking, or TLS pinning.
- Free model access can draft harnesses; it cannot see your lock screen or thermal state.
- Airplane mode, Doze, and
am force-stopare three different failures, not one flaky bug.
If your team already has a device lab with scripted lifecycle tests, use that lab and ignore the stub. If you cannot hold a physical device, stop here and ask for one before you merge anything that talks to a model.
What to send back after you run it
Send comparable environment evidence, not a screenshot of a happy path on Wi-Fi. Include device, OS, framework versions, permission and network state, the exact transition steps, and whether the stream recovered, restarted, or silently disappeared. If you used a killable stub, say whether rollback left a compiled URL in the bundle. That report is the onboarding artifact; the model name in the composer is not.
Top comments (0)