I Searched for My Own App and It Wasn't There — Being Findable on the App Store
When the approval mail arrived, I thought the job was done. I opened my phone and searched for "tessera" — nothing. That was the moment it landed: being approved and being findable are two different jobs. This is the story of shipping a product I built with care for six months, and leaving its store page to guesswork — and what I learned rebuilding it from data.

Approved. Live. Invisible.
Tessera went live on the App Store on 20 August. The approval mail arrived, all three products were APPROVED, the version was READY_FOR_SALE. One thing left to do: open my phone and search for my own app.
I typed "tessera". It wasn't there.
Twenty-two results came back — a passport photo app, a burger chain, a Latin devotional card app. Mine wasn't among them. I later learned this was the indexing lag every new app goes through; it resolved a few days later. But in that moment it made something clear: being approved and being findable are two different jobs. I had finished the first. I had never started the second.
On top of that: zero ratings, zero reviews.
The two phases of search
App Store search runs in two stages, and that split decides everything.
Phase 1 — indexing. Apple works out which apps are candidates for a query from your text fields: title, subtitle, keyword field. For a word that appears in none of them, you don't compete at all.
Phase 2 — ranking. Among the candidates, download volume and ratings decide the order.
There is nothing a new app can do about phase 2. I had zero downloads and zero ratings; on a category-defining term like "habit tracker" I was never going to outrank Productive and its 91,000 ratings. But phase 1 is entirely mine to control: I decide which queries I'm a candidate for. And until that day, I had been making that decision by guessing.
Giving up on guessing
When I first wrote the metadata, I picked words because they sounded right. This time I ran three research tracks in parallel: a competitor sweep, English keyword and algorithm mechanics, and the Turkish market. All of it against live data — the iTunes Search API is public and free:
curl -s "https://itunes.apple.com/search?term=habit+tracker&country=us&entity=software&limit=15"
Three findings changed the work.
The "Tessera" brand term is uncontested. Not one habit app among those twenty-two results, and every app actually named "Tessera" sits between 0 and 23 ratings. On my own brand term I compete with nobody.
Every mosaic rival is dead. "Habit Mosaic", "Mosaic: Habit Tracker", "HabitPuzzle Mosaic" — all of them at zero ratings. More interesting: all of them frame a missed day as blank or broken. Tessera's kintsugi angle — a missed day mended in gold rather than erased — is completely unclaimed in the category. Its nearest neighbour is (Not Boring) Habits at 6,000 ratings, and even that one says it only in the description, never in the title.
I had missed the title formula. HabitKit's developer published his ASO strategy: ~98% of his installs come from store search, and the formula is putting the "habit tracker" bigram in the title. My title had "Habit" but not "Tracker". I was carrying half the category term in the heaviest field there is.
Three fields, one query

Here is the conceptual jump: the fields are not indexed separately, they combine with each other.
If "daily" is in your subtitle and "habit tracker" is in your title, you're indexed for "daily habit tracker" — even though that exact phrase is written nowhere. That turns a tight budget of 30 + 30 + 100 characters into something else entirely: you stop planning words and start planning combinations.
The practical rules that fall out of it:
- Write single words, not phrases, in the keyword field. Spending characters on "habit tracker" is waste; "habit" and "tracker" separately combine anyway.
- 100 characters, comma-separated, no spaces:
routine,goal,widget. - Singular only. Apple covers the plural itself.
- Never repeat a word from your title or subtitle in the keyword field. Zero gain, pure waste.
That last rule hit my old metadata directly. My Turkish subtitle was "Sanata dönüşen alışkanlık" — but "Alışkanlık" was already in the title, so my second most valuable field was earning me exactly one new word. It became "Günlük seri ve rutin takibi": four new words, and the head query "alışkanlık takibi" now assembles from the title and subtitle together.
The storefronts' hidden language map

This was the most surprising part of the research. An App Store storefront does not index only its own language — it reads a second one too.
The US storefront indexes es-MX alongside en-US. Which means the keyword field of your Mexican Spanish localization is an extra 100 characters in the United States. Apple does not validate that field's language; put different (non-repeating) words there and they count in US search too.
The Türkiye storefront reads tr plus en-GB — not en-US. That one was a straight error on my part: if I want to reach someone searching "habit tracker" on the Turkish storefront, having those English words in en-US does nothing at all. Without opening an en-GB localization, that traffic was simply unreachable.
There's also a trap specific to Turkish: App Store search does not normalize Turkish diacritics. "alışkanlık takip" and "aliskanlik takip" return different result sets. You need to cover both, but you can't write both in the title — so the accented form lives in the title and the bare aliskanlik sits in the keyword field.
Building the package, and verifying it
What came out was a four-locale package: en-US and tr updated, with en-GB (for English storefronts outside the US, and for Türkiye) and es-MX (both a real Spanish listing and extra characters in the US) opened from scratch.
| Field | Before | After |
|---|---|---|
| en-US title | Tessera: Habit Mosaic | Tessera: Habit Mosaic Tracker |
| en-US subtitle | Habit tracker that makes art | Daily streaks that become art |
| tr subtitle | Sanata dönüşen alışkanlık | Günlük seri ve rutin takibi |
| Locales | 2 | 4 |
But while hand-managing that many fields, how do you stay sure no word repeats across two of them? You don't — so I wrote a script. The critical check is the intersection between sets that are co-indexed on the same storefront:
PAIRS = [("US", "en-US", "es-MX"), ("MX", "es-MX", "en-GB"), ("TR", "tr", "en-GB")]
for store, a, b in PAIRS:
dup = keywords(a) & keywords(b)
print(f"{store}: {a} ∩ {b} = {dup if dup else 'clean'}")
The same script checks character limits and each locale's own title/subtitle overlap. The output reads in one line: ALL OK. Because I manage metadata as files through fastlane (I wrote about that earlier), this verification is part of the commit rather than an eyeball check in a web panel.
The real work starts where metadata ends
Keywords solve phase 1. Getting the person who lands on the page to actually install is a separate job, and I had two gaps there.
The screenshots didn't say a word

My six store frames were bare simulator captures. Meanwhile roughly 70% of visitors never scroll past the first two, and captioned frames lift conversion by 20-35%.
I reordered them — the artwork leads now ("Your habits become art"), the gold mend follows ("Missed days heal in gold") — and set a line above each one in the app's own typeface. Rather than doing that by hand in Figma, I wired it into the pipeline: capture.sh writes the raw frames, compose.py sets the headlines and produces the store set. When a screen changes in the next release, the whole set regenerates with one command.
The app never asked for a rating
You can't do anything in phase 2 with zero ratings, and Tessera had never asked for one. But building that feature, the real question wasn't "how" — it was "when".
Tessera has a ceremony: when a piece is finished, the mosaic re-weaves itself and takes its name. That is the moment the user is proudest of what they made. The rating ask went there.
One problem: the paywall offer was wired to that same moment. Two sheets back to back devalue each other. The fix was making the offer report its own outcome:
final offered = await getIt<PaywallPresenter>().offer(context, request);
// The rating ask takes the same beat only if the offer stayed quiet — one sheet per moment.
if (!offered && mounted) {
await getIt<ReviewPromptService>().onCeremonyShown();
}
The service itself follows three rules: one ask per install, a fallback trigger for users who haven't finished a piece yet (the 7th completion), and the guard written before the request — so that a crash while the sheet is up can't buy the app a second attempt.
Future<void> _maybeAsk() async {
if (await _settings.read(AppStateKeys.reviewAskedOn) != null) return;
try {
if (!await _review.isAvailable()) return;
await _settings.write(AppStateKeys.reviewAskedOn, formatOffsetTimestamp(_clock.now()));
await _review.requestReview();
} on Exception {
// A rating ask must never surface as an error.
}
}
Widget completions deliberately don't feed that counter: the sheet can only render while the app is in the foreground, so only in-app moments ask.
What can change when
A practical distinction, because it shapes the plan:
| Field | What it needs |
|---|---|
| Promotional text | Nothing — changes on the live version instantly |
| Title, subtitle, keywords | A new version and a review |
| Screenshots, description | A new version |
So an ASO change isn't a metadata edit, it's a release. And if you need a new build anyway, it makes sense to put code work like the rating flow in the same one — which is what I did. 1.0.1 (build 5) shipped as a single package: four-locale metadata, captioned screenshots, the rating ask.
What's left
I don't know the results yet. The version is in review, rankings will settle over a few weeks, and this post may never get a sequel that says "it worked". But what I took from the process was independent of the outcome anyway.
I built the product with care for six months: the constants of the mosaic geometry, where the gold seam falls, which moment is deliberately not a sales moment. Then I dropped that product into the store with twelve words that sounded nice. Every decision inside the app rested on data and intent; the path by which the app gets found rested on a guess.
A store page isn't a marketing surface sitting outside the product — it's the first screen the user sees. You owe it as much engineering as the rest.