Building & Shipping

iOS Was Asking. The Server Didn't Know the Question.

Brett Ridenour Brett Ridenour · Published August 2026

The operator texted me on a Wednesday morning. The monthly calendar in the ops app is still taking over a minute to load in production. Can you look?

I had already written this fix. I remembered writing it. I remembered the algorithmic bug and I remembered the code review comments. I remembered clicking Merge. The whole reason the operator was reporting the symptom again was the part I got wrong.

Merged is not deployed

The first thing I did — before opening the code — was check what production was actually running. Freebo’s API deploys off a long-lived production branch. Merges land on main. A separate promotion pushes main onto production when I’ve decided a batch is ready to ship.

The last promotion was b4d16da, at 15:24 CDT on the 24th. The PR that added the fast month-view path was merged to main at 18:06 CDT on the 24th.

The fix landed on main two hours and forty-two minutes after the last thing I told production to swallow. Production had no idea ?mode=bookings was a thing. iOS did.

That gap — “merged, not deployed” — was the whole bug. But the reason production didn’t have the code even though I’d promoted a batch that day was the more interesting part.

The turbo filter that ate the deploy

Freebo is a Turborepo monorepo. Railway builds the API service only when files change under the packages the API depends on, which is expressed as a filter on the deploy step:

turbo run build --filter=@freebo/api...

The ... means “and everything it depends on.” Fine on paper. In practice, when I promote a batch that only touched apps/web and some content docs, Railway looks at the diff against its last successful build, sees zero changed files under @freebo/api or its dependency tree, and skips the deploy entirely. No email. No Slack ping. No red bar in the dashboard. The build page just says the previous artifact is still current, and the running container keeps running.

That’s the correct behavior for a monorepo — you don’t want a docs typo to burn a Node build. But the failure mode when I forget it exists is: I look at the promotion, see it landed, assume every service on it deployed, and go to lunch. Meanwhile the API is running whatever commit it last actually built for, which in this case was twelve commits behind main.

The app was speaking a language the server hadn’t learned

Here’s what makes this story specifically ugly, and specifically instructive.

iOS build 6 had already shipped. FreeboAPI.swift:115 was appending mode=bookings to every monthly calendar request. That was intentional — the iOS PR shipped after the server PR merged, on the assumption the server was ahead. Which, on main, it was.

Production was on b4d16da. On b4d16da, the querystring schema for /admin-events had no mode field. Fastify’s validator, given an unknown query parameter, doesn’t 400 by default — it drops it. So the request came in, mode=bookings was silently discarded, and the endpoint ran the default mode=slots path. Which is the slow path. The one the whole PR existed to avoid.

The app is asking the right question. The server it’s asking doesn’t know the question yet.

— A line I said out loud debugging this

There is no error class for “the client is speaking a dialect the server hasn’t learned.” It looks like the old behavior, exactly. Same latency. Same response shape. Same log lines. The only signal that anything was wrong was the operator texting me, again, about a thing I’d told them I fixed.

I could verify it structurally without deploying anything: admin-bookings.service.ts didn’t exist on the production branch, and availability-admin.ts on production had no mode property on its schema. Two file-existence checks and I knew production had the old handler.

The probe I stumbled into

While confirming the diagnosis I sent one unauthenticated request as a sanity check:

curl -s -o /dev/null -w "%{http_code}\n" \
  "https://api.freebo.example/v1/availability/loc-x/admin-events?mode=zzz"

On b4d16da this returned 401. Auth ran first, no valid token, request rejected before validation ever saw mode.

I made a mental note. After the deploy, I ran it again. It returned 400. Enum validation now ran before auth — the mode field existed on the schema, zzz was not a valid enum member, Fastify killed the request at the validation layer. The status code flip was the cleanest fingerprint I could imagine for “does this server know the new dialect.” I did not plan it. I got it as a gift.

I’m baking it in next time. If you add a new query parameter, add an intentional invalid value that flips a 401 to a 400 (or 404 to 400, whatever your stack does) so external observers can tell from outside whether the code is present. Health checks confirm liveness. This confirms version.

Probing production before and after the fast-forward — the status code flip that proved the server had learned the new dialect

The ship

The fix was a fast-forward. production was strictly behind main, eight commits, no divergence, no merge commit needed, no database migrations in the batch.

git fetch origin
git push origin origin/main:refs/heads/production

The Railway build actually ran this time, because the filter now saw changes under @freebo/api. The 502 window during the container swap was ten seconds — first failed health poll at 16:01:21Z, first successful poll at 16:01:31Z, six clean polls after that. The probe flipped 401 → 400 immediately. The operator’s next month load took under a second. Same operator, same complaint, second time was the charm.

What I’m keeping

  1. Text
    Operator: still slow
    Second report of a bug I'd already fixed. That gap is the whole story.
  2. First check
    What is production actually running?
    Not 'is the fix merged.' The right question is 'is the fix running.' Different question.
  3. Root cause
    Turbo filter skipped the build
    Promotion touched web + docs, not API packages. Railway correctly built nothing. I incorrectly assumed a deploy.
  4. Compound
    iOS shipped ahead of the server
    New query param sent by client. Old server dropped it silently. Failure mode = 'the old behavior, quietly.'
  5. Probe
    mode=zzz flipped 401 to 400
    Accidental version fingerprint. Now standard in the runbook.
  6. Ship
    Fast-forward, ten seconds of 502
    Clean FF, no merge commit, no migrations, rollback one-liner in the runbook.

Three things I’m keeping:

  1. A build that gets skipped is not the same as a build that succeeds. Any deploy pipeline that can correctly choose to build nothing needs an explicit “was this service actually rebuilt in this promotion?” step. The turbo filter is right to skip. My mental model of “the promotion deployed everything” was wrong.

  2. When the client can outrun the server, silent parameter handling is a landmine. The Fastify default of dropping unknown query params is fine for a stable API. It is dangerous the moment you have a mobile app you can’t downgrade in a hurry, because “the new client sent us a hint the old server didn’t understand” becomes indistinguishable from “the new client is running fine on the new server.”

  3. Version fingerprinting from outside beats every dashboard. A one-line curl that flips status codes based on whether a code change is live is more reliable than any deploy log I have access to. It runs from anywhere. It doesn’t care about caches. It reads the truth off the running binary.

The operator got their fast month view. I got a promotion checklist with three new lines on it, and a probe I can run from my phone to answer “is the fix out yet?” without opening a laptop.