ProductDecision record

ZooLog is on Cloudflare from the first commit

Two days in, the keeper app runs on Workers, D1, Queues and R2, with no other backend behind it. That was the easy decision. The hard one is what happens when a keeper logs a feed where the phone has no signal.

Day 1 set large on a colour background beside two phones showing ZooLog test data: the daily round and the offline banner, above the words: ZooLog on Workers and D1 from the first commit
3 Junefirst commit: the keeper app and its Cloudflare foundation
9D1 migrations by 5 June
57animals in the first seed
200 mstarget from tapping save to seeing the log queued

ZooLog’s first commit went in at 22:45 on 3 June. It held a keeper app for logging animal care on a phone, and a Cloudflare foundation underneath it: a Next.js app built to run on Workers, a D1 database, a Queue and an R2 bucket for photos. The second commit was the database schema. There was never a server, or a Postgres, that we would one day have to move off.

Two days later there are nine D1 migrations, a seed of 57 animals, sign-in, roles, an admin log explorer and a queue consumer that survives restarts. This post is about why it started that way, and about the problem we have not solved yet, which matters more.

One database from the first log

Our first collection already records its care through a set of online forms. The brief we wrote on 3 June made a blunt call about those: D1 is the system of record from the first log. The forms get a copy of every submission through an outbox for as long as anyone still reads them (the brief gives them three months at most), and then one file is deleted. No data migration, no schema change.

That is why Cloudflare is not a hosting detail here. Every log is written to D1 before anything else happens, and the same batch puts a row in an outbox table. A Queue picks the row up, copies the log to the old forms and sends the admin email. The outbox owns the state; the Queue owns the retry timing. We learned on day one that a timer started after a response is sent never fires on Workers, so the short grace window before an email goes out is the Queue’s delaySeconds, not a setTimeout.

2 daysfrom first commit to sign-in, roles and an admin log explorer
9D1 migrations by 5 June
200mstarget from tapping save to seeing the log queued
48pxsmallest tap target, for gloves and wet hands

The other early decision we would make again: the phone makes the log’s id. Each log carries a UUID minted on the device, and the database write is idempotent on it, so a double tap or a retry after a dropped connection gets the same answer and leaves one row. It cost nothing to do on day one. Everything we will need for bad signal depends on it.

What we did not do was write a comparison of hosting providers. The brief lists the stack and moves on. Our view is that for a product this size the number of platforms matters more than which one. Here the database, the queue, the photo store and the app live in one account, deploy with one tool, and run the same way on a laptop with wrangler dev --local.

Online-first, on paper

Here is the part we are less sure of. The brief says the app is online-first. If a session expires halfway through a submit, the draft is kept in the browser’s storage. If the phone is offline, submit is blocked and a banner says so. A real offline queue is on the list of things deferred past the first version.

On 5 June we audited the keeper flow against how keepers actually work, and the audit argued the other way. A keeper is moving, enclosure to enclosure on a fixed route, with a bucket or a hose in one hand. The phone comes out of a pocket for a few seconds and goes back, around forty times a round. Signal is patchy at the far end of the site. The audit’s verdict was that having no signal out there is the normal case, not an error.

It also found that the app only claims to handle offline. There is a banner string, but nothing on the home screen tells a keeper at the far end of the site that a log is stuck.

What offline-first would have to mean

If we take that audit seriously, and we think we have to, three things follow.

First, the log has to exist on the phone before it exists anywhere else: written to the device’s storage at the moment the keeper taps save, not held in memory waiting for a connection. A welfare record that vanishes because a phone went back into a pocket is worse than paper.

Second, the phone needs its own outbox that sends oldest first and keeps trying. The client-minted id means a log sent twice is still one log, so retrying is safe.

Third, the time on the record has to be when the keeper did the thing. Today the server stamps created_at when the request arrives. Online, the gap is small. For a feed logged at 07:30 and uploaded at 11:00, it is three and a half hours wrong, and wrong in exactly the way an inspector would notice.

None of that is built on 5 June. We think it is the real product, and that choosing Cloudflare was the easy half.

Update, 7 November 2026

The audit won. On 8 June we deferred offline drafts for the first demo version but wired them into the go-live checks as a blocker, because a welfare log lost without anyone knowing is a data-loss risk the moment real keepers use the app in the field. The same day, the submit route started honouring the time the phone sends instead of stamping its own. It refuses a time more than five minutes in the future and has no lower limit, because a keeper offline for days is valid.

When the mobile app started on 13 July, its first slice was sign in, home, compose a log, and offline sync: the draft saved before any upload is tried, then sent oldest first, one at a time, under the same log id. In October the rule on clocks was written down properly. The event time stays the phone’s, because for an offline log the phone is the only witness. The received time is the server’s. The app reports its own clock and any correction it applied, so neither is trusted silently. The native apps that replaced the first mobile app in October open their database with SQLite’s synchronous=FULL, so a saved draft is on disk before the app says it is saved. The mobile app was later rewritten natively in Swift and Kotlin, and why keepers need it to work one-handed outdoors is in Designing for wet gloves.

How a log gets from a phone with no signal into D1

The log is saved on the phone first and sent later. The time on the record is the time the keeper tapped save.

Keeper's phoneworks with no signal1. Draftwritten to disk the momentthe keeper taps save2. Outboxsame log id on every retry,oldest sent firstno signal: it waits here,and still counts as savedsignal backWorkerCloudflare Workerschecks the logevent time: the phone'sreceived time: its ownsame id twice = one loga retry never doubles a recordD1logs, animals,audit trailsystem of recordR2photos, sent withtheir log

It is still all on Cloudflare, and still one database. On 7 November production holds 5,795 logs, excluding our own test and demo use, across about 152 animals, with 899 photos in R2, and the schema has been through 133 D1 migrations.

Logs recorded per month

All collections, June to October 2026. November is left out because it is only a week old.

528Jun993Jul1,201Aug1,517Sep1,365Oct

Source: Aggregate counts from the ZooLog production database, 7 November 2026

ZooLog home screen with a Review 2 chip in the header, reminders due today, a Today's round card and a Drafts tab in the bottom bar.
A farm round checklist for Wednesday 14 October, 3 of 5 recorded, with Done, Not done and N/A buttons and the time each check was recorded.
The app today, with test data. Drafts waiting for the keeper are counted in the header and listed under Drafts; a daily round records the time of each check as it happens.

The backend question took one line of a brief in June and has not come back. The offline question took five months, and when the app was rewritten in October, the check that decided whether the new build could go to anyone was whether logs queued by the old app were still there, and still sent, after the update.

Where the numbers come from

  1. ZooLog web repository, git history from 3 to 5 June 2026.
  2. ZooLog MVP brief and data model, as committed on 3 June 2026.
  3. ZooLog keeper app UX audit and experience layer notes, 5 June 2026.
  4. ZooLog design decision log, entries for 8 June and 13 October 2026.
  5. ZooLog mobile repository, first commits of 13 July 2026, and the native cutover plan of 19 October 2026.
  6. Aggregate counts from the ZooLog production database, read on 7 November 2026.

Mark Lovelady, Director. Builds most of our software.