In a React Native fashion store app bundling a brand typeface in four weights, why do headings styled fontWeight '700' render in the system font on Android but correctly on iOS?
answer
- iOS picks the closest face in the family
- Android assets: four slots per family
- 700 and up looks for _bold
- missing file falls back to a system typeface
- register an XML family with weights
basics
~20 siOS picks the nearest weight among the family's registered faces. Android's asset lookup only knows regular, _bold, _italic and _bold_italic files named after fontFamily, so fontWeight '700' on 'BrandSans-Regular' finds no file and falls back to the system font.
solid answer
~40 sOn iOS, React Native resolves `fontFamily` to a family, even from a PostScript name like `BrandSans-Regular`, and then chooses the registered face whose weight is closest to `fontWeight`, so `'700'` lands on `BrandSans-Bold`. On Android, an asset font family has only four slots: weights below 700 map to the plain file and 700 or more to a file suffixed `_bold`, with `_italic` and `_bold_italic` beside them. `fontFamily: 'BrandSans-Regular'` plus `'700'` looks for `BrandSans-Regular_bold.ttf`, finds nothing and falls back to a bold system typeface; `'500'` or `'600'` just return the regular file. The fixes are to address each weight by its own family name with no `fontWeight`, or to register one XML font family with all four weights, for example through `expo-font`'s `android.fonts` object syntax, so `fontWeight` selects faces on both platforms.
code
json · 32 lines{
"expo": {
"plugins": [
[
"expo-font",
{
"android": {
"fonts": [
{
"fontFamily": "BrandSans",
"fontDefinitions": [
{ "path": "./assets/fonts/BrandSans-Regular.ttf", "weight": 400 },
{ "path": "./assets/fonts/BrandSans-Medium.ttf", "weight": 500 },
{ "path": "./assets/fonts/BrandSans-SemiBold.ttf", "weight": 600 },
{ "path": "./assets/fonts/BrandSans-Bold.ttf", "weight": 700 }
]
}
]
},
"ios": {
"fonts": [
"./assets/fonts/BrandSans-Regular.ttf",
"./assets/fonts/BrandSans-Medium.ttf",
"./assets/fonts/BrandSans-SemiBold.ttf",
"./assets/fonts/BrandSans-Bold.ttf"
]
}
}
]
]
}
}go deeper
Recall that fontWeight with a custom font behaves differently per platform, and that a missing Android variant falls back silently to the system font.
Explain Android's asset lookup: two weight buckets, suffixed _bold, _italic and _bold_italic files, and a system fallback, against iOS choosing the closest registered face.
Diagnose it from the symptom, then choose a fix: per-face family names with no fontWeight, or a registered weighted XML family, plus tokens and screenshot checks on old Android.
Decide how typography is owned: a token module the design system exports, with the platform font mechanics hidden behind it so product teams cannot reintroduce the fallback.
## The symptom A fashion store app ships its brand typeface as four static files: `BrandSans-Regular`, `BrandSans-Medium`, `BrandSans-SemiBold` and `BrandSans-Bold`. The body style uses `fontFamily: 'BrandSans-Regular'`, and a heading style adds `fontWeight: '700'`. On iOS the headings are in the brand's bold face. On Android they are bold, but in the **system font**, and the medium and semibold labels that set `fontWeight: '500'` or `'600'` look identical to regular body text. Nothing is logged, nothing throws. ## Why it slips through Most of the team tests on an iOS simulator, where every weight looks right. On Android the medium and semibold labels still render the brand face, just at regular weight, so nothing looks broken at a glance; only the bold headings visibly switch to the system font. Code review sees plausible styles, and React Native reports no warning for either case. The bug is found by a designer comparing screenshots, not by a crash. ## iOS: the closest face within the family React Native's iOS font code treats the string first as a family and, if none is found, as a font name such as a PostScript name, recovering its family, so `'BrandSans-Regular'` resolves to the `BrandSans` family. It then walks every registered face in that family with the requested style and **picks the one whose weight is closest** to `fontWeight`. Because all four files are registered through `UIAppFonts`, `'700'` finds Bold and `'600'` finds SemiBold. iOS hides the problem. ## Android: four slots per asset family React Native's Android `ReactFontManager` works differently for fonts copied into `assets/fonts`: 1. It reduces `fontWeight` to one of **two** buckets: below 700 is normal, 700 or more is bold. Combined with `fontStyle`, that gives four variants. 2. It builds a file name from the `fontFamily` string plus a suffix: none, `_bold`, `_italic` or `_bold_italic`, and tries `.ttf` then `.otf` in `assets/fonts`. 3. If no such file exists, it asks Android for a system typeface with that name and style, which for an unknown name is the **default system font**, in bold. So `'BrandSans-Regular'` plus `'700'` asks for `BrandSans-Regular_bold.ttf`, which does not exist, and falls back to the system font. `'500'` and `'600'` fall into the normal bucket and return `BrandSans-Regular.ttf` unchanged. Two different bugs, one cause. | Style | iOS result | Android asset result | |---|---|---| | `'BrandSans-Regular'`, `'400'` | Regular | Regular | | `'BrandSans-Regular'`, `'600'` | SemiBold | Regular, weight ignored | | `'BrandSans-Regular'`, `'700'` | Bold | system font, bold | | `'BrandSans-Bold'`, no weight | Bold | Bold | ## Fixes 1. **One family name per face, no `fontWeight`.** Address each file by its own name, `'BrandSans-SemiBold'`, and never add `fontWeight` on top. Simple and identical on both platforms once files carry their PostScript names. The risk is a stray `fontWeight: 'bold'` added later, which silently brings the fallback back on Android. 2. **Register a real weighted family on Android.** Android supports XML font families in `res/font` that declare each file's weight and style. In Expo, the `expo-font` config plugin's `android.fonts` entry takes `fontFamily: 'BrandSans'` with `fontDefinitions` of `path`, `weight` and optional `style`, generates that XML and registers it with `ReactFontManager` at app start. In a bare app you write the XML yourself and call `ReactFontManager.getInstance().addCustomFont(this, "BrandSans", R.font.brandsans)` in `MainApplication.onCreate`. Then `fontFamily: 'BrandSans'` plus any `fontWeight` works, and on iOS the same `'BrandSans'` resolves too, provided `BrandSans` is the family name embedded in the files. 3. **Hide the choice behind typography tokens.** A `typography.ts` module exports `heading`, `label` and `body` styles, so screens never set `fontFamily` or `fontWeight` directly and the mapping lives in one place. ## Guardrails worth mentioning - React Native 0.87 supports Android 7.0 (API 24) and up. On API 24 to 27, a registered XML family is still reduced to the nearest of normal and bold; exact weights such as 500 or 600 need Android 9 (API 28) or later. Test medium and semibold on an old device, or prefer fix 1 if old devices matter. - A screenshot test of a type specimen screen on both platforms catches the silent fallback that code review misses. - Italics need their own files and entries: an Android asset family has only `_italic` and `_bold_italic` slots, a registered family needs a definition with `style: 'italic'` for each italic file, and iOS only considers faces whose style matches, so a missing italic face cannot be reached by weight at all. - Keep the files static TTF or OTF: variable fonts are not consistently supported across platforms.
- Why is fontWeight '600' on a React Native asset font on Android not a fallback but still wrong?The asset lookup only has a normal and a bold bucket. Weights below 700 go to the normal bucket, so `'600'` resolves to the plain file named after `fontFamily`, the regular face. The brand font renders, so it looks fine at a glance, but the semibold emphasis is lost. Only a registered weighted family, on Android 9 or later, selects a 600 face.
- In a bare React Native app, how do you register a weighted font family on Android without Expo?Put the files under `res/font` with resource-safe names, write an XML font family there that declares each file's weight and style, and in `MainApplication.onCreate` call `ReactFontManager.getInstance().addCustomFont(this, "BrandSans", R.font.brandsans)`. React Native checks registered families before its asset lookup, so `fontFamily: 'BrandSans'` plus `fontWeight` then selects the declared faces.
- If you choose one family name per face instead, what regression should you guard against?Any `fontWeight: 'bold'` or `'700'` layered onto a per-face family, from a shared style or a component default, sends Android's asset lookup to a missing `_bold` file and back to the system font, while iOS still looks right. Ban raw `fontWeight` outside the typography module and keep a screenshot check on Android.
Android's asset lookup is a wardrobe with exactly four hangers: regular, bold, italic and bold italic. Hand it a semibold and it goes on the regular hanger; ask for bold when that hanger is empty and you get the house jacket, the system font. iOS is a tailor who picks the closest size from the whole rack.
saying these in an interview costs you the question
- Android picks the nearest weight from the bundled files just like iOS does.
- fontWeight '600' on Android selects the SemiBold file if it is in assets/fonts.
- If headings look right on iOS the font setup is correct everywhere.
- Renaming files to PostScript names alone lets fontWeight select faces on Android.
- The fix is to add more fontWeight values until Android matches.