A local-first app is only local-first if it survives a cut mid-request. Most survive a cut between requests. The difference is one open socket and one blocked write.
Start with the real failure. A user hits save while the network dies. Your code awaits a fetch that never resolves. The spinner runs for thirty seconds, then the offline banner appears.
That banner is a status indicator. It is not a fallback path. The fallback is the branch that must run when the promise never settles. You cannot review that branch by reading code. You have to make the wire fail on command.
A stub that fails on four ways
I do not test this against a real provider. Real providers fail politely and rarely. A stub fails on schedule, in the exact shape you choose.
Here is a stdlib-only HTTP stub, no dependencies, four modes. Place it in faultstub.py.
# python faultstub.py --mode truncate --after 40 --port 8787 import argparse, json, time from http.server import BaseHTTPRequestHandler, HTTPServer MODE = {"name": "ok", "after": 40, "stall": 5.0} class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" def do_POST(self): n = int(self.headers.get("content-length", 0)) body = self.rfile.read(n) payload = json.dumps({"ok": True, "bytes": len(body)}).encode() mode = MODE["name"] if mode == "reset": # connection dies before headers self.connection.close() return if mode == "stall": # headers arrive, body never does time.sleep(MODE["stall"]) if mode == "truncate": # 200 + short body, then close self.send_response(200) self.send_header("content-length", str(len(payload) + 64)) self.end_headers() self.wfile.write(payload[: MODE["after"]]) self.connection.close() return self.send_response(200) self.send_header("content-type", "application/json") self.send_header("content-length", str(len(payload))) self.end_headers() self.wfile.write(payload) def log_message(self, fmt, *args): print(MODE["name"], self.path, flush=True) if __name__ == "__main__": p = argparse.ArgumentParser() p.add_argument("--mode", default="ok", choices=["ok", "truncate", "stall", "reset"]) p.add_argument("--after", type=int, default=40) p.add_argument("--stall", type=float, default=5.0) p.add_argument("--port", type=int, default=8787) a = p.parse_args() MODE.update(name=a.mode, after=a.after, stall=a.stall) HTTPServer(("127.0.0.1", a.port), Handler).serve_forever() Now run your client against it. Point API_BASE at the stub and sweep all four modes.
for m in ok truncate stall reset; do echo "== mode: $m" python faultstub.py --mode "$m" --port 8787 & STUB_PID=$! API_BASE=http://127.0.0.1:8787 python client.py --runs 20 kill $STUB_PID wait $STUB_PID 2>/dev/null done The interesting mode is truncate. The server returns 200 and half a body. A naive client parses it, throws, and reports success to the UI. That is the bug this drill exists to find.
Three assertions, not a vibe
A drill without assertions is a demo. Fix three invariants before you run anything, and fail loudly when one breaks.
assert p99_local_write_ms < 50, "local write blocked on the wire" assert outbox.depth() == submitted, "queued item lost" assert len(sent_ids) == len(set(sent_ids)), "duplicate send after retry" Pick your own millisecond budget. Fifty is a proposal, not a measurement. The point is that the number is written down before the test, not after it.
The duplicate assertion matters most. Every retry path invents a second send. Without an idempotency key generated before the first attempt, reset mode will quietly double-write.
Sort each call into a class
The drill gives you facts per call site. Map every outbound call to one of three classes before you decide where it runs.
| class | offline requirement | expected verdict |
|---|---|---|
| local write (save, edit) | commits under budget, always | LOCAL-OK |
| cacheable read (list, profile) | serves last known value | LOCAL-OK on cache hit |
| enrichment (summarize, tag, suggest) | may fail, may retry later | CLOUD-ONLY |
If a "local write" returns LOCAL-DEGRADED under stall, you do not have a local-first app. You have a cache with a marketing name. Fix the write path first.
Enrichment is the opposite case. Nobody closes the tab because a tag arrives four seconds late. That class tolerates cold starts, queueing, and retries. It also tolerates running somewhere you do not own.
Where a free server actually wins
A free server wins on one property. It wins when the class is stateless, bursty, and tolerant of latency you did not choose.
Enrichment is exactly that shape. The client sends text, gets a label back, and the result is nice-to-have. Nothing in that loop needs a warm local runtime. Nothing needs to survive a partition.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode is relevant here because it offers a free model access tier and a free server option, both operator-supplied details I have not benchmarked. The operator currently states a free allowance of 10 million tokens. Treat that number as a claim to re-check on the pricing page before you depend on it, because allowances move.
Use it for the enrichment class only. Route the save path, the key-custody path, and anything under your millisecond budget to the local path. Then rerun the same four-mode sweep with the stub replaced by the real endpoint.
Keys never cross the stub
Add one more assertion. Log every outbound header name and assert that no provider credential appears in the client bundle.
The free server becomes useful precisely because it can hold the key. The browser holds a session token, not a provider secret. That is a routing decision made by custody, not by preference.
Test it the boring way. Grep the built bundle for the key prefix in CI. Fail the build if it appears.
What this drill cannot tell you
Loopback is not the internet. The stub has no DNS failure, no TLS handshake abort, no carrier-grade NAT timeout. It cannot model a captive portal that returns a login page with status 200.
It also will not tell you when your verdicts are wrong. If you never wrote a latency budget, LOCAL-OK is an opinion with a green checkmark.
Skip this approach if you need formal partition testing under a compliance regime. Use a real chaos harness and a durable store with proven recovery. Skip it too if your app is genuinely online-only by design and users know it. A clear spinner beats a half-built offline mode.
Teams that should not move enrichment to a free server: anyone under data-residency rules, anyone whose "enrichment" output feeds a billing decision, anyone who cannot tolerate a provider-side outage on a Tuesday.
Run the four modes before you run the migration. The wire will fail eventually. The only question is whether you wrote the branch first.
