Category

Shipping & Infra

Deployment, cache, indexing, and local automation notes from keeping many tiny products alive.

All posts

66
Concept diagram: on the left, a paragraph with a name removed; on the right, the same name still embedded in a URL path and an OG image filename.
Shipping & Infra3 min read

The name I scrubbed from the text was still in the URL

I set out to mask two identifiers across my blog and internal docs. I assumed that meant editing sentences. One of them was in a post's filename, which is its URL slug, so renaming it also meant changing the URL, the OG images, and a slug list in a design doc.

#privacy#slug#build-output#grep
Concept diagram: the phone sends a message with no delegate on the watch to receive it, so the send still logs as success
Shipping & Infra4 min read

The recovery score was always 72

I checked phone-to-watch wiring across the eight apps that ship a watch app. Seven were real. One was a screen with nothing listening on the other end.

#ios#app-store#gotchas#reality-check
Two-panel diagram. Left is the copy I edited: HEAD eight days old, no remote, and it does not contain the other machine's newest commits. Right is the actual source of truth: a different monetization design committed the same day, with the build toolchain installed. The two repositories share no common ancestor.
Shipping & Infra5 min read

The Screenshot Showed a String I Never Wrote

The feature was finished. The build passed, the tests passed. Then I took one simulator screenshot and the screen showed a line that wasn't in my code. Repo-wide grep: zero hits. That one line cost me a day's work.

#source-of-truth#verification#honesty#shipping
Two-panel diagram. On the left, two app screens read 'check your connection' and 'something went wrong', with a wifi icon marked by a green check below them to show the network was fine. On the right, the actual causes: a build config field holding an empty string, and a signing fingerprint absent from a console list, each linked back to a screen by a dotted arrow.
Shipping & Infra6 min read

Two Error Messages Blamed the Network. Neither Was the Network

One production app's paywall said to check the connection. Another app's sign-in said something went wrong. The phone was fine both times. The real causes were a secret that compiled to an empty string and a signing fingerprint that was never registered in the console.

#android#gotchas#build#auth
Two horizontal waveforms. The top is the app's voice leaving the speaker; the bottom is the microphone input containing a shrunken copy of the same waveform. A dashed threshold line crosses the lower waveform, with one tall peak above it labelled real interruption and the ripples below labelled residual echo.
Shipping & Infra6 min read

The App Interrupted Itself and Repeated the Same Answer

Three voice apps got reports of occasional interruptions and repeats. Echo cancellation was on and working — just not perfectly, and the residual was crossing the server's speech-detection threshold, so the app was cutting itself off. Here's how I tuned an adaptive gate without a physical device.

#ios#audio#realtime#debugging
Left: ios_release.py appends MARKETING_VERSION=1.9.4 to the xcodebuild command. Right: the committed project file still reads MARKETING_VERSION = 1.0, for all 35 compared apps.
Shipping & Infra5 min read

The Version Number in All 40 of My App Repos Was a Lie

The release tool passes the version to xcodebuild on the command line and never writes it back to the repo. The store says 1.9.4; the project file still says 1.0, after 40 releases. Why it never broke, and the one place it did.

#shipping-infra#ios#reality-check#gotchas
Two screens pointing at the same app. Left, the app-list chip reads a grey 'Not yet sent for review'; right, the Publishing overview banner reads a blue 'Changes in review.' An arrow between them points right.
Shipping & Infra4 min read

I Almost Rebuilt a Release That Was Already in Review

Two apps sat at 'Not yet sent for review' in the console list for days. The other seven I uploaded the same day with the same script had all flipped to 'In review.' So I wrote down a plan to rebuild the release and resubmit. Two screens were saying different things, and the one I believed was the wrong one.

#android#google-play#gotchas#reality-check
Left: a grep counting 6 headers per file. Right: only the innermost of six shells holds the real legal text
Shipping & Infra4 min read

My legal page had swallowed itself six layers deep

One app's privacy.html had six headers and one closing </html>. Checking further, all 41 apps and 123 files had the exact same shape. A deploy loop of 'pull live, edit, push back' had been stacking one shell per cycle.

#gotchas#reality-check#deployment#verification
25.8s at 1080x1920, still 25.4s at 720x1280. Pixels cut to 44%, time cut by 1.5%. Switching backpressure from setTimeout to encoder.ondequeue gave 1.17s at the original 1080x1920.
Shipping & Infra4 min read

I Halved the Pixels and Nothing Got Faster

Making one eight-second video took 25.8 seconds. I dropped the resolution from 1080x1920 to 720x1280 and got 25.4 seconds. That 0.4 seconds told me where the bottleneck actually was.

#performance#webview#debugging#first-principles
Left: what the console showed — a greyed-out Change account type control with no reason anywhere on the page, the condition hidden in a question-mark tooltip that asks you to provide and verify a website. Right: what actually held the lock — once the registered Search Console owner accepts the request, an association appears and the grey text turns into a blue link. The real gate was a D-U-N-S number that takes up to 30 days
Shipping & Infra7 min read

The Greyed-Out Button Wanted a Website. Only the Tooltip Said So

I set out to replace my personal name with my business name on my store listings. The field I wanted was editable all along; the thing that was locked sat next to it. Nothing on the page explained why — the condition lived in a hover tooltip, and it was a domain I had already owned for years.

#gotchas#reality-check#google-play#android
Engine verified: 200 unit tests green, iOS produced a 1,409,864-byte mp4, Android 2,353,253 bytes. Path unverified: the share sheet never appeared, and zero lines of code generated a link to the new landing page.
Shipping & Infra4 min read

200 Tests Passed and the Button Did Nothing

The video really was produced. I counted the mp4 bytes on both platforms and the web build was already in production. Then you tapped Share on iOS and nothing happened at all.

#testing#verification#ios#reality-check
Two panels. Left: what the proposal promised — hourly crowd levels per attraction from telecom, credit card and navigation big data. Right: what the open API actually returns — monthly visitors per district, a daily concentration percentage per attraction, no hourly data at any level. Below: the line the shipped build used to fill the gap, Math.random().
Shipping & Infra15 min read

The data I promised in the proposal did not exist

I entered an app in a public tourism data contest. The hourly crowd data I promised in the proposal is not in the open API — and the shipped app was already drawing that chart from Math.random(). Every trap from proposal to submission, over four and a half months.

#public-data#api#locale#deployment
Concept diagram: a funnel whose top row of person icons includes two magnifying-glass icons; lower rows shrink while the magnifiers stay stuck at the top.
Shipping & Infra4 min read

App Review showed up in my funnel as new users

Five minutes after submitting, two devices opened a version that wasn't public yet. Review devices are indistinguishable from real new users, and they only ever push the metric I was measuring downward. I chose to expose them rather than filter them.

#app-store#analytics#ios#android
Concept diagram: a terminal on the left printing total 0 five times, a browser on the right showing three intact review cards, 'same reviews' between them.
Shipping & Infra5 min read

I reported the reviews were gone. Twice. Both were wrong

My review DB had been frozen for seven weeks. I reported a broken collector — wrong. Then I reported Apple had removed 27 reviews — wrong again. Someone opened a browser and the reviews were all still there. The API simply wasn't returning its own data.

#app-store#api#supabase#reality-check
Concept diagram: a test runner full of green checks with an arrow leaving it into a production database, labeled '6 users (do not exist)'.
Shipping & Infra5 min read

I made the tests pass and production gained users

I wired first-session funnel instrumentation and got 184 tests green. Then I queried the server table: instrumentation I hadn't shipped had written 6 rows to production. Passing the test suite was changing the thing I was trying to measure.

#ios#android#testing#analytics
Concept diagram: on the left the goal of removing duplicates, on the right a freshly created 1,086-byte copy sitting next to the 27,531-byte canonical
Shipping & Infra5 min read

I Set Out to Delete Duplicates and Shipped One More

A 30-minute cleanup: consolidate seven subdomains onto one canonical path. Along the way I restored a site from backup — only to find the real version already live at another path, 25 times larger than the copy I had just deployed.

#gotchas#seo#deployment#reality-check
Concept diagram: on the left the cleanup consolidating seven subdomains onto one path, on the right the three site-ownership verifications it silently broke
Shipping & Infra5 min read

One Redirect Erased My Proof of Ownership to Google

I consolidated seven sites onto one path and left 301s behind on the subdomains. The deploy verified clean. Days later Search Console said verification failed — the meta tag lived on the very page I had redirected away.

#gotchas#seo#search-console#dns
Concept diagram: a Korean phone request sends an Accept-Language header through a locale detector, redirects to /ko, then verifies html lang=ko and a visible mobile KO/EN switch
Shipping & Infra4 min read

A Korean phone opened devlog in English first

A phone configured for Korean opened dev.ootssu.com in English. The translations existed; the bug lived in the entry route, the document lang attribute, and a mobile-only escape hatch that was hidden. I fixed the Accept-Language parser, rendered lang=ko for Korean pages, and made the language switch visible on mobile.

#i18n#nextjs#locale#mobile
Concept diagram: three green checks stacked — completed 100%, listing saved, commit ok — while the app status is REJECTED and the user's device shows the previous version. completed is a track assignment, not serving.
Shipping & Infra5 min read

`completed 100%` is not published

I automated Android releases via API and logged 'completed 100%' as deployed. One day a rejection email arrived while the track still read completed. The API says 'assigned to a track', not 'people are receiving it'. And the data safety form can't even be touched via API.

#android#google-play#api#gotchas
Concept diagram: the diagnostic build shows the banner 100% of the time, the release build 0%. Empty body -> no row -> .task never runs -> stays empty forever, a loop.
Shipping & Infra6 min read

Adding diagnostic code made the bug disappear

I chased a banner that never showed on TestFlight for four hours. Debug/Release, signing, real device — none was the culprit. The culprit was the diagnostic code I added to see the cause. The instrument was changing what it measured.

#swiftui#ios#debugging#gotchas
Concept diagram: server 200 → JSON "2026-07-01" → ISO8601 decoder throws for the whole row → catch returns an empty array → blank screen. Every earlier stage is a green check, only the last is an empty box.
Shipping & Infra3 min read

One date column erased the whole screen

A region-comparison card vanished entirely. No error, the RPC returned 200, the data was there — the screen was just blank. A decoding failure on one date field I don't even display threw the whole decode, and the catch fell back to an empty array. No red light at any stage.

#ios#supabase#swift#gotchas
Concept diagram: the dashboard lists entitlement IDs with mixed conventions while the code queries only 'pro'. Exactly one line connects; the rest are cut, each labeled purchase OK, receipt valid, no error.
Shipping & Infra4 min read

Payment went through, but Pro didn't turn on

The receipt was valid, the server was fine, there was no error — the app just stayed free. The culprit was a name. RevenueCat auto-generates entitlements from the app name, and the code looked up only 'pro'. Worse, this bug fails silently, with no exception and no log.

#ios#revenuecat#iap#gotchas
Concept diagram: with a sealed class + data object structure, keep-rule arrows land on the children while R8's scissors cut route on the base class. Switching to an enum makes R8 preserve the field automatically, so no keep rule is needed.
Shipping & Infra3 min read

R8 kept stripping the route field

An Android release build died on the same NPE across three versions. I kept widening keep rules; once it went quiet, then it regressed. The keep rules weren't failing — they targeted a different class than R8 was cutting. The answer wasn't a better keep rule; it was changing the pattern.

#android#r8#kotlin#gotchas
Concept diagram: a reviewSubmission shell holds 6 REJECTED products; an arrow from outside setting appStoreVersion to PREPARE_FOR_SUBMISSION bounces off. Only one arrow — canceling the shell — reaches inside, and the products auto-restore.
Shipping & Infra4 min read

The subject of `version not editable` wasn't the version

After a review rejection I tried to fix product localizations and got 409 'version is not editable'. So I made the version editable — same 409. The 'version' in that error isn't the appStoreVersion; it's a still-open reviewSubmission shell. Part 1 of an App Store Connect API gotchas series.

#app-store-connect#api#ios#gotchas
Concept diagram: the screen quadruples per zoom-out while the query box stays fixed at +/-0.05 degrees. At z11 the box is fully trapped inside the screen. Outside the box is 'never requested', not 'still loading'.
Shipping & Infra4 min read

Zoom out and other people's territories vanished

Zoom out on the map and other users' territories disappeared, with jank. It looked like slow loading, but waiting never filled it in. The query range was a fixed bbox — the screen quadruples per zoom while the request range stays put, so below a certain zoom the screen edges were never even requested from the server.

#android#supabase#postgis#gotchas