DEV Community

Riley Wu
Riley Wu

Posted on

Offline Is a State, Not a Flag

Offline Is a State, Not a Flag

Local-first apps fail at the seam, not at the start.

Teams usually test offline as a boolean. Airplane mode on, then off. That test passes and the duplicate-charge bug ships anyway. The failure sits in the gap between a local step and a remote step inside one task.

The same shape shows up in bug reports often. A job runs three steps. Two run on the laptop, one runs on a server. The process dies between step two and step three. On restart, the app replays step three.

Now the bill has two line items instead of one.

Latency probes will not catch this. Routing scores will not catch this either. Those answer a placement question. The seam asks a different one: what already happened.

Durability before placement

Placement is a performance decision. Durability is a correctness decision. Ship the second one first.

One rule holds up well. Record intent before the side effect, record completion after it. Then replay only what the journal says is unfinished.

That gives three states per step, not two. New, sent, done. The sent state is the interesting one, and it is the state most apps forget to persist.

Offline is the same case viewed from the other side. When the network returns, the queue is full of sent rows. You either deduplicate them or you pay twice.

A journal you can kill

Here is a single-file runner. No dependencies, Python 3.10 or newer. Save it as seam.py.

# seam.py - resumable steps across the local/remote seam
import json, os, sys, hashlib

JOURNAL = "journal.jsonl"
REMOTE_STORE = "remote_idem.json"  # stands in for the server's dedupe table


def append(entry):
    with open(JOURNAL, "a", encoding="utf-8") as f:
        f.write(json.dumps(entry) + "\n")
        f.flush()
        os.fsync(f.fileno())      # durability happens here, not later


def load():
    if not os.path.exists(JOURNAL):
        return []
    with open(JOURNAL, encoding="utf-8") as f:
        return [json.loads(line) for line in f if line.strip()]


def status(key):
    rows = [r for r in load() if r["key"] == key]
    if not rows:
        return "new", None
    last = rows[-1]
    return last["status"], last.get("out")


def idem_key(task_id, step):
    raw = f"{task_id}:{step}".encode()
    return hashlib.sha256(raw).hexdigest()[:16]


def step_double(x):
    return x * 2


def step_remote_summary(text, idem):
    store = json.load(open(REMOTE_STORE)) if os.path.exists(REMOTE_STORE) else {}
    if idem in store:
        return store[idem]        # replay: no second side effect
    out = {"idem": idem, "summary": text[:40].upper()}
    store[idem] = out
    json.dump(store, open(REMOTE_STORE, "w"))
    return out


def run(task_id, kill_at=None):
    st, out = status("double")
    if st != "done":
        append({"key": "double", "status": "started"})
        if kill_at == 1:
            os._exit(137)         # crash inside the local step
        append({"key": "double", "status": "done", "out": step_double(21)})
        st, out = status("double")
    doubled = out

    st, out = status("summary")
    if st == "done":
        return out

    key = idem_key(task_id, "summary")
    if st != "sent":
        append({"key": "summary", "status": "sent", "idem": key})
        if kill_at == 2:
            os._exit(137)         # crash after intent, before completion

    result = step_remote_summary(f"value={doubled}", key)
    append({"key": "summary", "status": "done", "out": result, "idem": key})
    return result


if __name__ == "__main__":
    kill = int(sys.argv[1]) if len(sys.argv) > 1 else None
    print(run("task-1", kill_at=kill))
Enter fullscreen mode Exit fullscreen mode

Two details carry the weight. The append call hits fsync before the network call. And every remote step carries a deterministic idempotency key.

The fsync is not decoration. Without it, the journal itself is the crash.

The crash test

Run this sequence. The first invocation dies on purpose.

rm -f journal.jsonl remote_idem.json
python seam.py 2; echo "exit=$?"     # 137, killed at the seam
python seam.py                       # resumes, reuses the same idem key
python seam.py                       # no-op, reads "done"
python -c "import json;print(len(json.load(open('remote_idem.json'))))"
Enter fullscreen mode Exit fullscreen mode

That last command prints 1. The remote side saw exactly one side effect, even though the local process touched the step twice.

Now change one line. Drop the idem field from step_remote_summary and rerun the whole sequence. The count becomes 2, and the bug reproduces in under two minutes.

That is the entire test plan. Kill at every seam, count the effects, assert one.

The seam is also a data boundary

The moment a step leaves the laptop, the input becomes someone else's log line. That cost is real and separate from latency.

An allowlist gates the payload. Nothing else crosses.

ALLOW = {"prompt", "locale", "max_tokens"}

def gate(payload):
    return {k: v for k, v in payload.items() if k in ALLOW}
Enter fullscreen mode Exit fullscreen mode

After the gate, the remote side sees a prompt and a locale. No bearer token, no customer id, no raw database row. Redaction is cheap. Rotation after a leak is not.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode fits this workflow as the spillover tier, not the primary one. It offers free model access and a free server option, which is enough to run the seam test on hardware you do not own. Check the project page for the current allowance, since free-tier terms move and a quoted number ages badly.

When the free server wins

Local wins when the constraint is the network or the payload. The server wins when the constraint is the machine.

Constraint Keep local Free server tier
Hard latency floor under ~50 ms yes no
Payload holds credentials or customer rows yes no, redact first
Session must survive hours offline yes no
Task exceeds laptop time or memory budget no yes
Queued burst of batch work no yes

Read that table by row, not by column. Most real apps take both paths, and take them per step.

A cheap diagnostic helps here. Count journal rows still in sent after five minutes. A rising number means the remote leg is unreliable, not that the routing rule is wrong.

Limitations

The journal grows without compaction. fsync per step costs milliseconds that hurt at high volume. The remote side has to genuinely support idempotency keys, and accepting a header while ignoring it does not count.

Free tiers are not SLAs. Treat the server tier as best-effort capacity that can vanish, and keep the local path able to finish degraded work.

Skip this pattern if data cannot cross a boundary at all. Skip it if you need the remote side to be a system of record. This is a spillover mechanism, not a ledger.

The takeaway

Test the seam, not the switch. Write intent before the effect, and carry a key that makes replay safe. Placement then gets cheap, because a wrong choice costs latency instead of money.

If you want to rehearse the spillover step without provisioning anything, MonkeyCode's free model access and free server option are enough to run the crash test above end to end.

Top comments (0)