●DEADLINE — Four days remain until Google Play requires Android 16 (API level 36). From August 31 it applies to new apps and to updates of existing ones alike●RULES — The submission rule and the visibility rule are separate. An app you have stopped updating still disappears for new users on newer devices if it targets below API 35●EXTENSION — An extension keeps you shipping to all users until November 1, but the form lives in Play Console and has to be filed before the deadline passes●EXPO — Expo SDK 57 moves React Native from 0.85 to 0.86 while React stays at 19.2, and 0.86 is intended to land without breaking changes●HERMES — 57.0.9 updates React Native to 0.86.2 and clears the Hermes V1 memory regression from SDK 56, which shows up in apps importing reanimated or worklets●PREBUILD — expo prebuild now clears and regenerates the native android and ios directories by default, so hand-edited native changes vanish unless you audit for them first●DEADLINE — Four days remain until Google Play requires Android 16 (API level 36). From August 31 it applies to new apps and to updates of existing ones alike●RULES — The submission rule and the visibility rule are separate. An app you have stopped updating still disappears for new users on newer devices if it targets below API 35●EXTENSION — An extension keeps you shipping to all users until November 1, but the form lives in Play Console and has to be filed before the deadline passes●EXPO — Expo SDK 57 moves React Native from 0.85 to 0.86 while React stays at 19.2, and 0.86 is intended to land without breaking changes●HERMES — 57.0.9 updates React Native to 0.86.2 and clears the Hermes V1 memory regression from SDK 56, which shows up in apps importing reanimated or worklets●PREBUILD — expo prebuild now clears and regenerates the native android and ios directories by default, so hand-edited native changes vanish unless you audit for them first
The Node linter I run against name, subtitle and keywords in every locale to protect Apple's 30/30/100 character budget — with real output and what it caught.
One spring I opened the Japanese keyword field for a wallpaper app after a long gap and stopped short. Out of the 100 characters available, I was using 29. Two of the terms inside were words already sitting in the app name.
Nobody made a single bad decision. The field had been edited a little at a time over several years, and as locales piled up, there was no longer any way to see the whole picture at once. Checking sixteen keyword fields by eye was never a workable process to begin with.
From that day I stopped treating the keyword field as something you think about and started treating it as something you protect with a script. What follows is that script, and the reasoning that had accumulated around it beforehand.
The App Store gives you a 160-character search budget
Start with the constraint. The text that iOS actually indexes for search is narrower than most people assume.
Field
Limit
Indexed for search
App name
30 characters
Yes
Subtitle
30 characters
Yes
Keywords
100 characters
Yes
Description
4,000 characters
Not on iOS
One hundred and sixty characters, total. A term that does not appear in that budget will not surface in search no matter how many times it appears in the description. That single fact is why keyword-stuffing an iOS description accomplishes nothing.
Google Play works differently and does draw on description text, so reusing one piece of copy across both stores means one of them comes up empty.
The part that costs the most is this: words in the name and subtitle are indexed separately. Repeating them in the keyword field spends your 100 characters on coverage you already had.
There is a smaller tax too. A space after a comma counts as a character. Separate seven keywords with ", " instead of "," and six characters are gone — enough for another short term.
All three rules are stable enough to be worth enforcing mechanically.
A script that lints the budget
I keep localized metadata in a plain JSON file and check it against those rules with a small Node script. No dependencies.
#!/usr/bin/env node// aso-keyword-lint.mjs — lint App Store Connect localized metadata// against the name 30 / subtitle 30 / keywords 100 budget.import { readFileSync } from 'node:fs';const LIMITS = { name: 30, subtitle: 30, keywords: 100 };// Match Apple's counting: treat surrogate pairs as one characterconst len = (s) => [...(s ?? '')].length;// Normalize for comparison: width, case, surrounding whitespaceconst norm = (s) => (s ?? '').normalize('NFKC').toLowerCase().trim();// Pull the already-indexed words out of name / subtitleconst wordsOf = (s) => norm(s) .split(/[\s,、・/|+\-–—::()()]+/u) .filter((w) => w.length > 1);function lintLocale(locale, meta) { const errors = []; const warnings = []; for (const field of ['name', 'subtitle', 'keywords']) { const n = len(meta[field]); if (n > LIMITS[field]) { errors.push(`${field}: ${n} chars (limit ${LIMITS[field]}, over by ${n - LIMITS[field]})`); } } const raw = meta.keywords ?? ''; // 1) A space after a comma is billed one character at a time const spaceWaste = (raw.match(/,\s/g) ?? []).length; if (spaceWaste > 0) { errors.push(`keywords: ${spaceWaste} space(s) after commas (${spaceWaste} chars wasted)`); } // 2) Empty tokens, trailing comma const tokens = raw.split(',').map((t) => t.trim()); const empties = tokens.filter((t) => t === '').length; if (empties > 0) errors.push(`keywords: ${empties} empty token(s) (double or trailing comma)`); const live = tokens.filter((t) => t !== ''); // 3) Duplicates inside the keyword field const seen = new Map(); for (const t of live) { const k = norm(t); seen.set(k, (seen.get(k) ?? 0) + 1); } const dupes = [...seen.entries()].filter(([, c]) => c > 1).map(([k]) => k); if (dupes.length) errors.push(`keywords: duplicate token(s) ${dupes.join(' / ')}`); // 4) Terms already in name / subtitle — paying twice for the same coverage const indexed = new Set([...wordsOf(meta.name), ...wordsOf(meta.subtitle)]); const redundant = live.filter((t) => indexed.has(norm(t))); if (redundant.length) { const saved = redundant.reduce((a, t) => a + len(t) + 1, 0); errors.push(`keywords: already in name/subtitle ${redundant.join(' / ')} (~${saved} chars recoverable)`); } // 5) Unused budget const used = len(raw); const left = LIMITS.keywords - used; if (left >= 15) { warnings.push(`keywords: ${left} chars unused (${Math.round((left / LIMITS.keywords) * 100)}% of budget)`); } const note = `indexed ${len(meta.name)}+${len(meta.subtitle)}+${used} = ${len(meta.name) + len(meta.subtitle) + used} / 160 chars`; return { locale, errors, warnings, note };}const path = process.argv[2];if (!path) { console.error('usage: node aso-keyword-lint.mjs <metadata.json>'); process.exit(2);}const data = JSON.parse(readFileSync(path, 'utf8'));let failed = 0;for (const [locale, meta] of Object.entries(data)) { const r = lintLocale(locale, meta); const mark = r.errors.length ? 'NG' : r.warnings.length ? '--' : 'OK'; console.log(`[${mark}] ${locale} ${r.note}`); for (const e of r.errors) console.log(` ERROR ${e}`); for (const w of r.warnings) console.log(` WARN ${w}`); if (r.errors.length) failed += 1;}console.log(`\n${Object.keys(data).length} locales checked / ${failed} with errors`);process.exit(failed > 0 ? 1 : 0);
The input is a flat JSON object keyed by locale. Whether you copy it out of the App Store Connect UI or pull it from the API, it lands in the same shape.
That sample contains four mistakes I have actually shipped: spaces after commas plus double-indexing in English, a repeated token in Japanese, a trailing comma in Arabic. Only the Chinese locale is clean.
In the English locale, six characters of whitespace plus fourteen characters of double-indexing means twenty characters are recoverable. Against a hundred-character field, that is not a rounding error.
Because it exits non-zero, dropping it in front of a metadata deployment job turns it into a gate rather than a report.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A dependency-free Node linter that checks the 30/30/100 character budget across every locale, catching post-comma spaces, duplicate tokens, and terms already indexed in the app name
✦Why the first implementation silently passed Japanese and Chinese locales, and the containment check that fixed it
✦How direct translation of English keywords failed, and how to split the keyword budget into evergreen and seasonal slots
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Look closely at that output, though, and one detection is missing.
In the Japanese sample the name is 壁紙アプリ HD and the subtitle is 高画質の背景画像を毎日お届け. The keyword field contains 壁紙 and 高画質, so both are double-indexed. Yet the only thing reported was the repeat of 壁紙 inside the field itself.
The cause is wordsOf(). It splits on whitespace and punctuation, so 壁紙アプリ is treated as a single word and the 壁紙 inside it is never reached. The check works correctly for languages with whitespace word boundaries, and silently passes Japanese and Chinese.
Bugs that only manifest in some languages are among the harder ones to notice, precisely because nothing fails. You come away believing the locale is clean.
The fix was to add a containment check against the concatenated name and subtitle, limited to tokens that contain CJK characters.
// Japanese and Chinese have no whitespace word boundaries, so token // equality misses matches. For those tokens, also test containment // against the raw name + subtitle string. const CJK = /[\u3040-\u30ff\u3400-\u9fff]/u; const indexed = new Set([...wordsOf(meta.name), ...wordsOf(meta.subtitle)]); const surface = norm(`${meta.name ?? ''} ${meta.subtitle ?? ''}`); const redundant = live.filter((t) => { const k = norm(t); if (indexed.has(k)) return true; return CJK.test(k) && k.length >= 2 && surface.includes(k); });
Single-character tokens are excluded to avoid false positives when a common character like 花 happens to appear somewhere in the name. For the same reason containment is restricted to CJK tokens — apply it to English and art starts matching smart.
高画質 now surfaces, and ten characters become recoverable in that locale. Out of the twenty-nine characters actually in use, roughly a third was wasted.
This is not a substitute for morphological analysis. Apple has not published how partial matches like 壁紙 versus 壁紙集 are treated in its index, so I treat the check as a way to put suspicious entries in front of a human — not as something that should auto-correct anything.
A linter protects how the budget is spent. What goes into it is a different problem.
When I first took apps multilingual, I translated the English terms that were working and dropped them in. "Live Wallpaper" performed in English, so 动画壁纸 went into Chinese, ライブ壁紙 into Japanese, 라이브 배경화면 into Korean.
Nothing happened. A month later, traffic from those terms had barely moved, and the apps were nowhere in the rankings for them.
The reason became clear later. Users in those markets were not searching for the concept "live wallpaper" at all.
What Chinese users actually searched was 免费壁纸 (free wallpaper) and 高清壁纸 (high-definition wallpaper) — the outcome they wanted, not the technology delivering it. Japanese was the same: 壁紙 無料 and おしゃれ 壁紙 sat far ahead of the feature name ライブ壁紙.
That is when the distinction landed for me. A translation is the name of what we ship. It is not necessarily the name of what someone is looking for. While those two diverge, a perfectly filled keyword field still returns nothing.
Since then I never ship a translation directly. There is always a step in between that asks whether anyone searches that way. Machine translation has improved enormously since, but what improved is fluency — no version of it tells you whether demand exists.
Translation and LLMs are candidate generators, not decision makers
None of which means abandoning translation. It means moving where it sits in the process.
My workflow has three stages.
Stage one widens the pool. I run the working English terms through both DeepL and Google Translate and keep the places where the two disagree. A disagreement usually marks a concept with no settled phrasing in that language, which makes it worth examining. The divergences carry more information than the agreements.
Stage two adds volume. This is where an LLM helps. The prompt does not need to be elaborate.
Suggest 20 long-tail keyword candidates for the Japanese locale of a wallpaper app.Constraints:- exclude broad single terms like "wallpaper" on its own- favor two- and three-word combinations- separate seasonal candidates from evergreen ones- for each, add one line describing the situation someone is in when they search it
That last line is the important one. Ask only for terms and you get plausible-looking vocabulary. Ask for the situation behind each term and you can discard the ones where you cannot picture the moment. Adding that single constraint noticeably raised how many suggestions I ended up using.
Stage three throws candidates away. This is the real work. I search each candidate in that locale's store and look at what ranks.
Apps from an unrelated category rank → the term means something else there. Discard.
Large publishers own the top → volume exists but there is no way in. Hold.
Apps of comparable size rank → keep as a candidate.
Neither translation nor an LLM can perform stage three. Both produce phrasings that could be said; neither knows which phrasings are being searched. Confuse the two and you end up with sixteen locales of beautifully translated terms that nobody types. That is precisely what I shipped the first time.
Patterns I have observed by market
After a few years across a dozen-plus locales, some tendencies show up. These come from my own categories — wallpaper and wellness apps — and are observations, not laws. Read them with that caveat.
Price-signaling terms carry weight in some markets. 免费 in Chinese, 無料 in Japanese, 무료 in Korean produced clear movement in my records. "free" works in English too, but the density of competition on that word is different.
Spec-forward terms carry weight in others. In the German, French, and Spanish locales, adding resolution markers like 4K or Ultra HD moved more than descriptive adjectives did.
Some markets are simply less crowded. Arabic and Turkish had far fewer competing apps relative to their speaker base, and short terms could rank. That one caught me off guard.
The risk is freezing these into a per-language answer key and then missing the market shifting underneath. I hold them as hypotheses and still check the current rankings for that locale before deciding anything.
Verifying that RTL locales render the way you intended
Right-to-left languages such as Arabic and Urdu carry a category of problem unrelated to word choice.
Paste machine-translated text into App Store Connect, then look at the store page on a device, and punctuation can sit in the wrong place or mixed alphanumeric runs can order differently than intended. The editing field can look correct while the rendered surface does not.
The trailing comma the linter caught arrived through exactly this path. Working in RTL text, it is genuinely hard to see which side a comma attached to, so a stray separator goes unnoticed. Counting it mechanically is more reliable than looking.
For the rendering itself I check in this order.
Where
What to look at
App Store Connect editor
Character counter, plus stray leading or trailing punctuation
Device store page, language set to that locale
Punctuation placement and ordering of alphanumeric runs
Search results list
Where the text truncates
The third is easy to skip. Name and subtitle truncate in list view, and anything you needed people to read that falls on the truncated side is effectively unread.
Keyword changes do not produce results the moment you ship them. Missing that turns every subsequent judgment sloppy.
I use one spreadsheet. Nothing elaborate.
Column
What goes in it
Live date
The date it went live on the store, not the submission date
Version
App version number
Locales
Only the locales actually changed
Change
Both terms added and terms removed
Review date
Two weeks after the live date, filled in ahead of time
Findings
Ranking and traffic movement observed on the review date
Filling in the review date in advance is the part that matters. You will check daily for the first few days regardless, and in my experience that early movement is variance rather than signal. Deciding the review date up front keeps those numbers from driving anything.
Always record what you removed. If you only track additions, then when rankings drop and the cause sits on the removal side, there is no path back to it. That cost me about half a year once.
Running parallel versions to compare is possible, but at indie scale the sample is usually too small to read a difference. I settled on changing one thing at a time and waiting two weeks. It feels slower, and it compounds better, because the history stays legible afterward.
Keeping seasonal slots separate from evergreen ones
Categories like wallpaper have pronounced seasonal demand. Nobody looks for cherry blossom wallpapers outside spring — and in spring, reliably, they do.
I split the keyword budget into two kinds of slot ahead of time.
Evergreen slots: terms with steady year-round traffic — free, high definition, aesthetic
Seasonal and event slots: terms that stop working once the window closes — cherry blossom, autumn leaves, Christmas, New Year
Deciding in advance how much of the 100 characters is swappable turns each seasonal change into a substitution rather than a redesign. Without that decision you rebuild the field from scratch every time, and eventually stop updating it at all.
Timing runs earlier than feels natural. Review time plus the lag before rankings respond means changing the field during the month in question is already too late. Cherry blossom terms need to be in place during February.
Event slots are shorter still. For a one- or two-week window like Halloween, it is worth asking whether to participate at all. Whether keeping a slot free is worth the overhead depends on how strong the evergreen slots already are. While the evergreen side is still developing, I have had better results leaving seasonal terms alone entirely.
Choosing how broad a term to chase
The other decision worth making early is which weight class of term to target.
Starting out, I went after the highest-volume terms directly — single words like "wallpaper". The top of that field was occupied by large apps, and months passed without appearing in the rankings at all.
What changed things was moving to two- and three-word combinations. "Live Wallpaper Aesthetic" has a fraction of the search volume, and a fraction of the competition with it. Ranking well on a small term produced more traffic than being invisible on a large one.
Put plainly:
What matters is not search volume by itself, but volume × the realistic chance of ranking for it.
The second factor resists quantification, so I judge it by looking at the top ten for that term and asking whether my app would look out of place beside them. Imprecise, but it produces fewer misses than choosing on volume alone.
Once the terms are chosen, placing them within the 160-character budget is a separate task. Name and subtitle are indexed as part of the combination space, so the keyword field only needs the terms that are not already there. Placement errors are exactly what the linter above is detecting.
Where the tooling actually stands
Tooling is the part of ASO writing that goes stale fastest, so it is worth stating the current position.
App Annie, long the default reference, was renamed data.ai around 2021–2022 and was acquired by Sensor Tower in March 2024. The data.ai brand was retired as the platforms merged, and its capabilities now live inside Sensor Tower. Older articles and books that say "look it up in App Annie" are pointing at a name that no longer resolves.
Purpose
Tool
Note
Market research and competitive view
Sensor Tower (formerly data.ai / App Annie)
Expensive at indie scale; starting from what is visible for free is realistic
Tracking your own rankings
App Figures and similar trackers
The main instrument for after-the-fact verification
Confirming actual traffic
App Analytics in App Store Connect
First-party source for search impressions and conversion
Managing metadata at scale
App Store Connect API
Once locale count grows, UI editing stops keeping up
Vendor pricing and plan structures change often enough that any figure printed here would mislead; check the current pages directly. I have walked into a conversation carrying an outdated price in my head, and it did not go well.
Estimated rankings and App Store Connect measurements are different kinds of number. One is modeled, the other is observed. Decisions should rest on the observed side, with estimates reserved for understanding what other publishers are doing. Keeping that separation makes most tooling questions easy.
Once locale count grows, moving metadata into a repository and pushing it through the API is faster in the end. That path is covered in managing store metadata as code with the App Store Connect API. The linter above slots naturally into that pipeline as the verification step.
Deliberately narrowing what you take on first
Everything above assumes sixteen locales, but there is no reason to begin anywhere near that number.
For my first few years I ran three: English, Japanese, and Chinese. With three, I could follow a change through to its outcome and actually read the differences between markets. I added locales only after that reading became reliable.
Starting today, I would pick one of these.
Focus
First three locales
Why
English-speaking
en-US / es-ES / pt-BR
Similar word structure, so the research process transfers
Asia
ja / zh-Hans / ko
Price and quality signals behave similarly, making comparison easier
Spread
en-US / ja / es-ES
One market from each distinct pattern, early
And one thing to do before adding any locale at all: confirm that the locales you already have are actually spending the full 160-character budget.
The Japanese field I opened that spring was using 29 of 100 characters, and 10 of those were duplicates. Scaling that to sixteen locales would have produced sixteen copies of the same hole. I had the order backwards.
What to do next
The most actionable piece here is the linter. The sequence looks like this.
Copy name, subtitle, and keywords for every locale of every shipping app into a JSON file
Run node aso-keyword-lint.mjs metadata.json and list the locales reporting ERROR
Spend the recovered characters — but only on terms already on your candidate list, not new ideas
Record the live date and review date, then leave the numbers alone for two weeks
Step three is deliberate. Reorganizing the budget and swapping terms at the same time means that when numbers move, you cannot tell which change did it. Close the holes first, then reconsider the contents. Since adopting that order, my decisions got visibly faster.
Multilingual ASO turned out not to be a language-skill problem. It is a record-keeping and verification problem — at least, framing it that way is what gets me moving.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.