---
title: 'From skeptic to convert: how Fieldy adopted Expo for their AI wearable'
authors: Adomas Valiukevičius
published: March 24, 2026
categories: Users, React Native
tags: Expo Modules, CNG, Workflows
---

_This is a guest post from [Adomas Valiukevičius](https://www.linkedin.com/in/adomasv/) - co-founder and engineering lead at Fieldy, building an AI wearable assistant that helps people remember, organize, and act in the real world._

…

At [Fieldy](https://fieldy.ai), we are building an AI wearable assistant. The app connects to a physical Bluetooth device, handles audio transfers, generates summaries, and performs actions on your behalf.

Because we rely heavily on Bluetooth Low Energy (BLE) and background processes, we are very protective of our performance. We constantly monitor RAM usage and app size because the OS is always looking for a reason to kill background tasks.

To be honest, we never really considered Expo before. None of us had tried it. We weren't "anti-Expo," we were just uneducated about it. We had a fear that adding a "managed" layer on top of our heavy native requirements would introduce bloat or block us from accessing the metal when we needed to.

![Intro to Expo](https://cdn.sanity.io/images/9r24npb8/production/dba2aed44924a074b7b7bf1e3193b8505b243231-2400x1413.jpg)

Then we hired [Tomasz Sapeta](https://x.com/tsapeta). Our investors at [**Inovo.vc**](http://Inovo.vc) introduced us, and since he was on a long vacation, he came on part-time to help us improve performance.

He suggested Expo. We were skeptical about the overhead initially, but we trusted him. We gave him the repo and said, "Let him cook."

Here is exactly what happened, the numbers we achieved, and the mess we made along the way.

## The audit

There is a difference between adding measures to save things in the future and doing a deep audit to fix what is broken right now. We realized that our “bare” app had accumulated a lot of junk over time, and we needed to manually inspect every line of our `package.json`.

We inspected every package, checked its real usage, researched alternatives, and flagged known issues. To visualize the impact, we modified our start command to run [Expo Atlas](https://github.com/expo/expo-atlas) by default. Atlas is a bundle analysis tool that ships with Expo — it gives you a visual breakdown of exactly what's contributing to your JS bundle size.

```javascript
"scripts": {
  "start": "EXPO_ATLAS=1 APP_VARIANT=development expo start",
}
```

![Expo Atlas](https://cdn.sanity.io/images/9r24npb8/production/57eca00f4c5ac797143e56227e41730da6a67393-2400x1806.jpg)

We found three major areas where we were burning space for no reason.

### Cleaning Up the JS Bundle

Atlas helped us find a few large packages immediately. In a single PR, we identified three redundant packages:

1. **`react-native-calendar-events`**: We were only using this to check permissions that we didn't even require anymore.
2. **`react-native-calendars`**: This is a heavy library (by Wix). We were using it _only_ for a date picker. It was pulling in `lodash`, which alone accounted for **4% of our entire bundle**.
3. **`react-native-date-picker`**: We used this just for a time picker.

I deleted all three, replaced them with a simple, custom date picker component. We saved 10% of the whole JS bundle size and removed three native dependencies in one go.

![Clean up your JS Bundle](https://cdn.sanity.io/images/9r24npb8/production/1d2541e29f518b96476b54f4d1cfb7c148f5f33c-2400x615.jpg)

### Fixing tree shaking

Speaking of `lodash`, removing `react-native-calendars` was only half the battle. Atlas revealed that we were still carrying ~700KB of Lodash in our own code because we were importing the entire library (`import { debounce } from "lodash"`) instead of specific paths.

We saw the exact same pattern with `date-fns`. We switched both to direct imports (e.g., `import debounce from "lodash/debounce"`), and the bundle size plummeted. Between the deleted libraries and these fixed imports, we shed over 2MB of JS bundle.

Read more about tree shaking - [https://docs.expo.dev/guides/tree-shaking/](https://docs.expo.dev/guides/tree-shaking/)

The app went from 19.8MB to 17.8MB:

![Tree shaking results](https://cdn.sanity.io/images/9r24npb8/production/fe9a60f9fae6a214da9875eb1f7d3a51aea80f58-2400x1275.jpg)

![more tree shaking](https://cdn.sanity.io/images/9r24npb8/production/a2aee46827e15d406ac280875896fc8717202c64-2400x1275.jpg)

### Removing unused fonts

While Atlas cleaned up our JS bundle, manual inspection of our native build assets revealed a bigger monster: `SF-Pro.ttf` and other fonts weighing **18.6 MB.**

SF Pro is the system font on iOS — you don't need to bundle it. And on Android, bundling Apple's system font makes no sense at all. We deleted them. Just like that, the app binary dropped by nearly 5 MB.

![remove unused fonts](https://cdn.sanity.io/images/9r24npb8/production/bd05fe7c4921fdf664e737ebaf0792eaf6322de0-2400x1749.jpg)

## Proper fetch on React Native

On the web, uploading files or streaming data is a breeze. In React Native, this has historically been painful, requiring a mix of proprietary libraries.

[Expo SDK 54](https://expo.dev/changelog/sdk-54) brought [WinterCG](https://wintercg.org/) compliance to native apps. In plain English: it makes native `fetch` work like web `fetch`. We finally stopped using native workarounds and started using platform standards.

### File Uploads

We used to rely on `react-native-fs` and `RNBlobUtil` just to handle multipart audio uploads. We had to write files to disk, wrap them in proprietary blob utilities, and manage the upload manually. Now, we simply use the standard `File` object (from [`expo-file-system`](https://docs.expo.dev/versions/latest/sdk/filesystem/)) and pass it to `fetch`.



<!-- Before -->
```javascript
// Write base64 string to disk first...
await RNFS.writeFile(path, data, "base64");

// Then wrap it with a specific library
requestData.push({
  name: "audio",
  data: RNBlobUtil.wrap(path),
});

// Use a proprietary fetch method
await RNBlobUtil.fetch("POST", url, ...);
```

<!-- After -->
```javascript
// Standard File API
const file = new File(Paths.cache, "audio.raw");
file.write(new Uint8Array(data));

// Standard FormData
const formData = new FormData();
formData.append("audio", file);

// Standard Fetch
await fetch(url, { method: "POST", body: formData });
```

### Streaming

Same story with streaming responses. We were manually tracking string indices with XHR. Now we use a standard `ReadableStream`.

<!-- Before -->
```javascript
const xhr = new XMLHttpRequest();
xhr.open("POST", url);
xhr.setRequestHeader("Content-Type", "application/json");

// Manually tracking string indices
let lastIndex = 0;

xhr.onprogress = () => {
  const currIndex = xhr.responseText.length;
  if (lastIndex === currIndex) return;
  const chunk = xhr.responseText.substring(lastIndex, currIndex);
  processChunk(chunk);
  lastIndex = currIndex;
};

xhr.onerror = (err) => handleError(err);
xhr.onload = () => console.log("Done");

xhr.send(JSON.stringify(body));
```

<!-- After -->
```javascript
import { fetch } from 'expo/fetch';

const resp = await fetch(url, { 
  method: "POST",
  headers: { Accept: 'text/event-stream' },
  body: JSON.stringify(body)
});

const reader = resp.body.getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  processChunk(value); // Clean binary chunk
}
```

## Automated deployment

Our branching strategy is simple: `main` reflects TestFlight. It's considered stable but not necessarily ready for release — we're still waiting for features to land and collecting feedback from the testers or engineering team. When `main` has a new fingerprint and everything is ready for production, we create a `release/x` branch and submit the native build to the stores. From there, any push to the release branch publishes an OTA update to users on that version. If the fingerprint has changed, the workflow fails and notifies us on Slack.

![Automating deployments](https://cdn.sanity.io/images/9r24npb8/production/da737143843e752c3b2243ce3296225965a76784-2400x858.jpg)

### The value of continuous native generation (CNG)

I did not understand [**Continuous Native Generation (CNG)**](https://docs.expo.dev/workflow/continuous-native-generation/) initially, and I probably don’t fully understand it now either. But we certainly benefited from using it.

The idea is simple: instead of committing your `ios/` and `android/` folders to git, CNG regenerates them from your `app.config.js` every time you build. Your native code becomes a build artifact, not a source of truth. Think of it like a `.gitignore` for your entire native layer.

We asked around: _"Why is regenerating the native folders every time beneficial?"_ We didn't get many convincing answers other than "upgrades are easier." But **EAS Workflows** required it. We wanted to automate our deployments, so we were forced to adopt it.

It turned out to be more useful than we expected. Whenever something breaks in the native layer, the fix is usually one command: `bun expo prebuild --clean`. No more debugging stale native state.

Since CNG regenerates native code every build, you use [Config Plugins](https://docs.expo.dev/config-plugins/introduction/) to modify the generated output — injecting code, adjusting settings, or removing things you previously did by hand. For example, here's a plugin we use to adjust `gradle.properties`. It strips x86 architectures from the build, which sped up our builds by 40%:

```javascript
const { withGradleProperties } = require("@expo/config-plugins");

module.exports = (config) => {
  return withGradleProperties(config, (config) => {
    const set = (key, value) => {
      const existing = config.modResults.find((p) => p.key === key);
      if (existing) {
        existing.value = value;
      } else {
        config.modResults.push({ type: "property", key, value });
      }
    };

    // Only build ARM architectures (skip x86/x86_64)
    set("reactNativeArchitectures", "armeabi-v7a,arm64-v8a");

    return config;
  });
};
```

![Adopting CNG](https://cdn.sanity.io/images/9r24npb8/production/0b75a2a065fb5696453131981175cf4667195578-2400x1893.jpg)

### EAS Workflows for automating the CI pipeline

[EAS Workflows](https://docs.expo.dev/eas/workflows/get-started/) is Expo's CI/CD service. It handles building, submitting to app stores, and pushing OTA updates — all triggered by git pushes. This means we can ship JS-only changes to users instantly without going through app store review. EAS also uses [**fingerprinting**](https://docs.expo.dev/versions/latest/sdk/fingerprint/) to compute a hash of your native dependencies — if nothing native has changed, it skips the full build and pushes an OTA update instead. Most of our deployments take minutes, not 30-minute builds followed by days of app store review.

Our configuration was implemented following the [Deploy to Production](https://docs.expo.dev/eas/workflows/examples/introduction/#deploy-to-production-workflow) example from the Expo docs:

```yaml
name: Deploy to production

defaults:
  tools:
    corepack: true

on:
  push:
    branches:
      - main
      - "release/**"
    # We don't need to run it on every change, so we list paths
    # that can potentially affect the build or update.
    paths:
      # - ".eas/workflows/**"
      - "assets/**"
      - "modules/**"
      - "src/**"
      - "patches/**"
      - "plugins/**"
      - "app.config.js"
      - "eas.json"
      - "index.js"
      - "metro.config.js"
      - "package.json"

concurrency:
  cancel_in_progress: true
  group: ${{ workflow.filename }}-${{ github.ref }}

jobs:
  fingerprint:
    name: 🫆 Fingerprint
    type: fingerprint

  get_ios_build:
    name: 🔍 Check for existing iOS build
    needs: [fingerprint]
    type: get-build
    params:
      fingerprint_hash: ${{ needs.fingerprint.outputs.ios_fingerprint_hash }}
      profile: production


  submit_ios_build:
    name: 🚀 Submit iOS Build
    needs: [build_ios]
    type: submit
    params:
      build_id: ${{ needs.build_ios.outputs.build_id }}

  publish_ios_update:
    name: 📤 Publish iOS update
    needs: [get_ios_build]
    if: ${{ needs.get_ios_build.outputs.build_id }}
    type: update
    params:
      branch: production
      platform: ios
      
  send_slack_message_ios:
    name: 📣 Send Slack message - iOS
    after: [fingerprint, get_ios_build, build_ios, submit_ios_build, publish_ios_update]
    type: slack
    params:
      webhook_url: XXXXXXX
      payload:
        blocks:
          - type: header
            text:
              type: plain_text
              text: "Finished deploying iOS "
     ...
```

We also get a Slack message with the status of the deployment.

![Slack workflows](https://cdn.sanity.io/images/9r24npb8/production/0b25859ff8847b7de539c5df2deef44bb2cb5005-2400x894.jpg)

## Expo Modules (The power of Native)

A common concern with Expo is that it locks you out of native code. It doesn't. [Expo Modules](https://docs.expo.dev/modules/overview/) let you write Swift and Kotlin alongside your JS — and the two layers talk to each other cleanly.

[Fieldy](https://fieldy.ai) relies on a persistent Bluetooth connection to a physical device. The JS thread is not reliable in the background — the OS loves to kill it to save battery, and [Headless JS](https://github.com/facebook/react-native/issues/45731) is effectively abandoned. We tried [`react-native-ble-plx`](https://github.com/dotintent/react-native-ble-plx) and [`react-native-ble-manager`](https://github.com/innoveit/react-native-ble-manager), but neither was reliable enough for our use case. So we wrote our entire BLE layer in native Swift and Kotlin using Expo Modules.

### Expo Modules and life cycle listeners

Another place we reached for Expo Modules was app termination notifications. When the user force-quits [Fieldy](https://fieldy.ai), we need to warn them that recordings will stop — but JavaScript is already dead at that point. With an [AppDelegate subscriber](https://docs.expo.dev/modules/appdelegate-subscribers/), it's straightforward:

```javascript
{
  "platforms": [
    "apple"
  ],
  "apple": {
    "appDelegateSubscribers": [
      "FieldyAppDelegateSubscriber"
    ]
  }
}
```

```javascript
import UserNotifications
import ExpoModulesCore


public class FieldyAppDelegateSubscriber: ExpoAppDelegateSubscriber {
...
	public func applicationWillTerminate(_ application: UIApplication) {
	    let content = UNMutableNotificationContent()
	    content.title = "Open Fieldy!"
	    content.body = "Don't close Fieldy - new recordings may not be captured!"
	    content.sound = UNNotificationSound.default
	    content.interruptionLevel = .timeSensitive
	
	    let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1.0, repeats: false)
	    let request = UNNotificationRequest(identifier: "app-termination-warning", content: content, trigger: trigger)
	
	    UNUserNotificationCenter.current().add(request) { error in
	      // Handle error if needed
	    }
	  }
...
}
```

![Widget](https://cdn.sanity.io/images/9r24npb8/production/39f284e8cbd9014a8c306e09fbbd5ce3a8789467-2400x1113.jpg)

## Mistakes we made

### Multiple App Variants

Of course, it wasn't all wins. We made a critical mistake with **OTA (Over-The-Air) Updates**.

We use multiple app variants (`development` and `production`) with different Bundle IDs. **RevenueCat** uses these IDs to verify subscriptions.

We ran a deployment from local machine using `eas update`. The issue? EAS read my local `.env` file, which was set to `APP_VARIANT=development`. I accidentally pushed a development configuration to the production channel.

Users updated, and suddenly their production app was trying to talk to RevenueCat using development keys. **The paywall went down.** We lost revenue and users were confused.

The fix was simple — no more local deployments. Now, all releases go through EAS Workflows, which handles environment variables automatically based on the build profile.

### Conclusion

At the time of writing this, it is hard to imagine building a React Native app without Expo — despite having used it in production for only nine months. A lot of credit goes to [Tomasz Sapeta](https://x.com/tsapeta) for pushing us in this direction.

My goal with this post is to show how even a very skeptical team of engineers can migrate to Expo and end up genuinely enjoying the benefits.

If you want to try what we've built, [Fieldy](https://fieldy.ai) is available on [iOS](https://apps.apple.com/us/app/fieldy-ai-note-taker/id6480278540) and [Android](https://play.google.com/store/apps/details?id=com.compasswearable).