skip to content

With Expo's fingerprint runtimeVersion policy, what does @expo/fingerprint hash, and why can the runtime version change unexpectedly or fail to change when it should?

level: seniorimportance: nice to knowfreq 16%

answer

  1. hash of native-affecting inputs
  2. config, autolinked modules, plugins
  3. JS source is not an input
  4. deterministic config, sourceSkips, ignore file

basics

~20 s

@expo/fingerprint hashes, per platform, the app config, config plugins, autolinked native packages and any committed native folders, not app JavaScript. Version bumps or dynamic config change it unexpectedly; edits inside inline plugin mods and ignored paths escape it.

solid answer

~40 s

The `fingerprint` policy hashes native-affecting inputs with `@expo/fingerprint`, per platform and SHA-1 by default: the evaluated app config, files it references such as icons and splash images, config plugin modules, the native folders of every autolinked package, `patch-package` patches, and `android/` and `ios/` only when committed. App JavaScript is not hashed. The hash is computed at build time and again when `eas update` publishes, and the two must agree. It changes unexpectedly because `version` and build numbers are hashed by default, or because `app.config.ts` reads values that differ between build and publish. It fails to change when the mod body of an inline config plugin is edited (plugins are hashed by function name) or a path is in `.fingerprintignore`. Diagnose with `eas fingerprint:compare` or `npx @expo/fingerprint fingerprint:diff`.

code

javascript · 9 lines
javascript
/** @type {import('expo/fingerprint').Config} */
const config = {
  sourceSkips: [
    'ExpoConfigVersions',
    'PackageJsonAndroidAndIosScriptsIfNotContainRun',
  ],
};

module.exports = config;

go deeper

for a junior

Recall that the fingerprint policy hashes native-affecting inputs, not the app's JavaScript, and changes when a native dependency is added.

for a middle

Explain the main hash sources, that it is computed per platform at both build and publish time, and how CNG projects ignore generated native folders.

for a senior

Diagnose hashes that drift or stick using fingerprint:diff and eas fingerprint:compare, and fix them with deterministic config, sourceSkips, file-based plugins and a narrow .fingerprintignore.

for a principal

Judge whether automatic runtime versions justify more native builds for the team, and set the rules for which inputs may be skipped.

## What the fingerprint is With `"runtimeVersion": { "policy": "fingerprint" }`, the runtime version is a hash computed by **`@expo/fingerprint`**, a package bundled with `expo` and `expo-updates`. The hash is computed **per platform** (by default with SHA-1), so an iOS build and an Android build normally carry different runtime versions. It is calculated at two moments that must agree: 1. **During the build.** The native config receives the reserved placeholder `file:fingerprint`, and the build writes the computed hash into a bundled `fingerprint` file that `expo-updates` reads at runtime. 2. **At publish.** `eas update` asks `expo-updates` (its `runtimeversion:resolve` command) for the runtime version, which recomputes the hash from the project as it is now. If the two hashes are equal, the update reaches the build. If anything the hash covers differs, the update targets a runtime that no installed build reports. ## What goes into the hash The hash is built from a list of sources: - the **evaluated app config**, normalised and serialised, including `version`, `ios.buildNumber` and `android.versionCode` unless you skip them; - files the config references that end up native: icons, splash images, fonts passed to the `expo-font` plugin, Google services files; - the source files of **config plugins** loaded from their own modules; - the native directories of every **autolinked** package (Expo modules and React Native community modules), found through autolinking; - the `react-native` package, `patch-package` patches, `.gitignore` files and `package.json` scripts (by default the scripts are skipped when the `android` and `ios` scripts do not contain `run`); - the **committed `android/` and `ios/` folders**, but only in a bare project. ## What stays out - Your app's JavaScript and TypeScript source: it belongs to the update layer. - Build output such as `ios/Pods`, `android/build` and `.gradle` folders. - With Continuous Native Generation (native folders gitignored), `expo-updates` ignores `android/**` and `ios/**` entirely, so the hash is the same before and after running prebuild. ## Surprises in both directions | Symptom | Cause | Remedy | |---|---|---| | New runtime after bumping only `version` | App config version fields are hashed by default | Add `ExpoConfigVersions` to `sourceSkips` | | Publish hash matches no build, native code untouched | `app.config.ts` puts an environment variable or a timestamp into a config field, and it differs between build and publish | Make the config deterministic; publish with the same environment as the build | | Hash unchanged after changing what a mod inside an inline plugin in `app.config.js` writes | An inline raw config plugin is hashed by its function name (`withAnonymous` when it has none), not its body | Move the plugin into its own file, which is hashed as a file | | Hash unchanged after changing a file you ignored | The path matches `.fingerprintignore` | Narrow the ignore pattern | The first two make an update reach nobody (safe but confusing). The last two are dangerous: native behaviour changed and the runtime version did not, which is exactly the mismatch the policy was chosen to prevent. ## Configuring and inspecting it Two files in the project root tune the calculation: - **`.fingerprintignore`**: `.gitignore`-style patterns (matched with `minimatch`) for paths to leave out. - **`fingerprint.config.js`**: a `Config` object with `sourceSkips` (named flags such as `ExpoConfigVersions` or `ExpoConfigRuntimeVersionIfString`), `ignorePaths` and a `fileHookTransform` hook for stabilising dynamic values before hashing. To see why two hashes differ: 1. `npx expo-updates fingerprint:generate --platform ios` prints the hash and its sources as JSON. 2. `npx @expo/fingerprint fingerprint:diff a.json b.json` compares two saved outputs. 3. `eas fingerprint:compare` compares the local project, a build (`--build-id`) or an update (`--update-id`). ## How it compares with the version-based policies - With `appVersion` or `nativeVersion`, a forgotten bump makes an incompatible update **reach old builds**, which then crash. - With `fingerprint`, the equivalent mistake makes an update **reach nobody**, because its hash matches no installed build. - The first failure is loud and harmful; the second is quiet and safe, but it can leave a team wondering why a fix never arrived. Comparing the project's fingerprint with the target build's (`eas fingerprint:compare`) before publishing catches both, whichever policy is in use. ## Why teams still weigh it Fingerprint removes the "remember to bump" step, but it trades it for more builds and for a hash whose inputs must be deterministic. Skipping sources makes it cheaper and more dangerous at the same time: every skip is a promise that the skipped input cannot change the native layer.

  • Why is the hash the same before and after running prebuild in a Continuous Native Generation project?
    When `android/` and `ios/` are gitignored, `expo-updates` treats the project as managed and adds `android/**` and `ios/**` to the ignore paths. The hash then comes from the inputs prebuild uses (app config, plugins, autolinked packages), not from generated native files, so a local prebuild cannot change the runtime version.
  • Is skipping ExpoConfigVersions always safe?
    Only if a version or build-number change never alters native behaviour on its own, which is usually true. The skip keeps runtime versions stable across store releases whose native inputs are otherwise identical. Every skip is a promise that the skipped input cannot change the native layer; skipping more than you understand reintroduces the mismatch the policy exists to prevent.

The fingerprint is like a parts list stapled to a machine: the factory checks new attachments against it and refuses any whose list differs. It only protects you if everything that matters is on the list and nothing on it changes by chance, such as a date stamp.

saying these in an interview costs you the question

  • The fingerprint hashes the app's JavaScript source, so every JS change starts a new runtime.
  • iOS and Android always share one fingerprint runtime version.
  • Editing the mod inside an inline config plugin in app.config.js always changes the fingerprint.
  • Bumping only the version field never changes the fingerprint with default settings.
  • The hash is computed once at build time and the publish reuses it without recomputing.