React Native is not a browser

By
dracoblue
Jun 5, 2026

My next #notai generated post is about another day of developing the kiesel app. The post composer for kiesel.app was nearly finished and I was going to upload my very first image. This post is about 3 bugs, which appeared in React Native, but would have worked flawlessly in the browser.

Redirect URIs in Expo

To get my Mastodon OAuth working and the POST to /api/v1/apps happy, it requires a redirect_uri. Since I am using expo.dev, the Linking.createURL will give me a exp:// prefixed url.

That worked for a while. But as soon as I added TestFlight builds and tested there: the uri scheme changed to kiesel:// - but the redirect uri didn't. The error:

invalid redirect URI

complained.

My new check includes:

async function getOrCreateApp(instanceUrl: string): Promise<MastodonApp> {
  const key = `${MASTODON_APP_KEY_PREFIX}${new URL(instanceUrl).hostname}`;
  const redirectUri = Linking.createURL("mastodon-callback");

  // Check if we have a stored app with matching redirect URI
  const stored = await SecureStore.getItemAsync(key);
  if (stored) {
    const app = JSON.parse(stored) as MastodonApp;
    if (app.redirect_uri === redirectUri) return app;
    // Redirect URI changed (e.g. Expo Go → EAS build), re-register
  }

  const response = await fetch(`${instanceUrl}/api/v1/apps`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      client_name: "Example",
      redirect_uris: redirectUri,
      scopes: "read write",
      website: "https://example.org",
    }),
  });

  if (!response.ok) {
    throw new Error(`Failed to register app: ${response.status}`);
  }

  const data = await response.json();
  const app: MastodonApp = {
    client_id: data.client_id,
    client_secret: data.client_secret,
    redirect_uri: redirectUri,
  };

  await SecureStore.setItemAsync(key, JSON.stringify(app));
  return app;
}

and if it changes - we will get a fresh client_id and client_secret - otherwise the SecureStore has an easy copy of it. For the sake of copy n' paste I replaced Kiesel with Example and kiesel.app with example.org to make sure nobody adds kiesel's client_name or website to their own app.

blob.arrayBuffer is undefined

My first attempt in the atproto-adapter to fetch the local file was:

const response = await fetch(localUri);
const blob = await response.blob();
const uint8 = new Uint8Array(await blob.arrayBuffer());
const uploaded = await this.agent.uploadBlob(uint8, {
  encoding: blob.type || "image/jpeg",
});

and it worked well in Expo Go - but failed in React Native - because arrayBuffer did not exist.

So I worked around this, by reading the local file as base64 with plain old XMLHttpRequest (will give fetch another try in a next iteration, though!):

const fileData = await new Promise<Uint8Array>((resolve, reject) => {
  const xhr = new XMLHttpRequest();
  xhr.onload = () => {
    const reader = new FileReader();
    reader.onloadend = () => {
      const base64 = (reader.result as string).split(",")[1];
      const binary = atob(base64);
      const bytes = new Uint8Array(binary.length);
      for (let i = 0; i < binary.length; i++) {
        bytes[i] = binary.charCodeAt(i);
      }
      resolve(bytes);
    };
    reader.onerror = reject;
    reader.readAsDataURL(xhr.response);
  };
  xhr.onerror = reject;
  xhr.open("GET", localUri);
  xhr.responseType = "blob";
  xhr.send();
});

const uploaded = await this.agent.uploadBlob(fileData, {
  encoding: "image/jpeg",
});

And sending FormData also failed with this:

const response = await fetch(localUri);
const blob = await response.blob();
const formData = new FormData();
formData.append("file", blob, "image.jpg");

because React Native's FormData expects another structure, so I changed it to a way where the local file is directly used as source:

const formData = new FormData();
formData.append("file", {
  uri: localUri,
  type: "image/jpeg",
  name: "image.jpg",
} as unknown as Blob);

Don't guess image size

I learned that ATproto has a filesize upload limit of 976.56 KB. My silly attempt at the beginning: try to upload the file with 70% quality + same width and that's it.

But it failed, since the file might still be too big. My approach: reduce target width and run manipulateAsync function from expo-image-manipulator and see if it fits into the maximum size. If not, try again. But don't fall below 256px width.

// Compress images to stay under ATproto's 976KB limit
const MAX_SIZE = 950_000; // 950KB to leave margin
let width = 2048;
let compress = 0.8;

while (width >= 256) {
  const result = await manipulateAsync(
    uri,
    [{ resize: { width } }],
    { compress, format: SaveFormat.JPEG }
  );

  // Check file size via fetch
  const response = await fetch(result.uri);
  const blob = await response.blob();
  if (blob.size <= MAX_SIZE) {
    return result.uri;
  }

  // Reduce quality first, then size
  if (compress > 0.3) {
    compress -= 0.15;
  } else {
    width = Math.floor(width * 0.7);
    compress = 0.7;
  }
}

// Last resort: smallest possible
const result = await manipulateAsync(
  uri,
  [{ resize: { width: 256 } }],
  { compress: 0.3, format: SaveFormat.JPEG }
);
return result.uri;

Now the uploaded image is always in "best" quality for the respective ATproto limits.

Three bugs in one evening, three places where React Native looked like a browser just long enough to compile.

See you in the next dev day post!