Juan Hurtado
07/17/2026, 7:01 PMretry-journal intercepts your Ktor client's connectivity failures, persists them to disk, and replays them exactly as sent once you're back online. Android, iOS, JVM. One Long (8 bytes) of memory per pending request, no matter how deep the backlog gets.
It's small in scope on purpose — a dedicated HTTP outbox, not a general state-sync engine — but the internals got a lot of attention to be genuinely crash-safe, not just "probably fine":
• A hand-rolled CRC-32 in pure Kotlin: Neither the Kotlin stdlib nor Okio ships a multiplatform CRC32 — java.util.zip.CRC32 is JVM/Android-only. Every record on disk is framed and verified against a from-scratch, table-based CRC-32 (IEEE 802.3) that runs identically on all three targets.
• A dense LongArray index instead of `LinkedHashMapLong, Long`: Sequence IDs are assigned monotonically, so a live entry's position is always sequenceId - baseSequenceId — no hashing needed. JOL-measured (not guessed): 8 bytes/entry vs. ~98 bytes/entry, ~90% smaller. A queue that crash-recovers with 100,000 pending entries loads its whole index at ~800KB.
• Recovery that never trusts a corrupted length field: Append-only file, tombstone-based removal, atomic-rename compaction — standard crash-safety moves. The less standard part: if a record's own length field is corrupted, the scanner refuses to jump by that untrusted length and instead resyncs byte-by-byte to find the next real record boundary. One bad record can't silently swallow everything written after it.
• Real cross-process locking, not just an in-memory mutex: Two processes (your UI + a background worker) sharing one queue file get an advisory OS file lock plus a JVM-intra-process ReentrantLock layered underneath it — because FileChannel.lock() throws OverlappingFileLockException instead of blocking when two channels in the same JVM contend for it. Canonical-path resolution handles symlinks and case-insensitive filesystem aliasing.
• Selective queueing that plays nice with codegen clients: By default only mutating methods (POST/PUT/PATCH/DELETE) get queued. Need to override per-endpoint without HttpRequestBuilder access (like in Ktorfit/Retrofit)? A header (X-Retry-Journal-Enqueue) works via @Headers, and gets stripped before the request ever touches the wire.
• Tested like it's going to eat your production data: 240+ tests across JVM/Android/iOS from one `commonTest`source set, mutation testing (PIT), differential fuzzing against a reference LinkedHashMap, real cross-process file-lock tests, and Android instrumented tests against a real Context/ART runtime — because a stub android.jar unit test proves less than you'd think.
// libs.versions.toml
retry-journal = { module = "com.ghostserializer:retry-journal", version = "1.1.0" }
val retryJournal = RetryJournal.create(engineFactory = CIO, queuePath = dataDir.resolve("queue.bin"))
try {
retryJournal.client.post("<https://api.example.com/orders>") { setBody(order) }
} catch (_: OfflineQueuedException) {
showMessage("Saved — will sync when you're back online.")
}
// later:
retryJournal.flush()
Honest about its limits too: at-least-once delivery (plan for duplicates on payments), you decide when to call flush() (no automatic network listening baked in), advisory locks (some exotic NFS setups may differ). Full breakdown in docs/guarantees.md.
• GitHub: github.com/juanchurtado1991/retry-journal
• Architecture deep dive: docs/architecture.md
• License: Apache 2.0
Feedback, comments, "why don't you use X instead" are all welcome! 🙏