expo/expo · error · Error

Failed to download bundle: ${bundleRes.status}

Error message

Failed to download bundle: ${bundleRes.status}

What it means

Thrown when the HTTP download of the JS bundle (launchAsset.url from the manifest) returns a non-200 status. The bundle is the Hermes bytecode (.hbc) file that Expo Go embeds as the Snack runtime. A non-200 means the CDN URL was unreachable, expired, or the auth headers were rejected.

Source

Thrown at apps/expo-go/scripts/download-snack-runtime.js:325

  // Ensure output directories exist
  fs.mkdirSync(OUTPUT_DIR, { recursive: true });
  fs.mkdirSync(ASSETS_DIR, { recursive: true });

  // Download the bundle
  console.log(`\nDownloading JS bundle...`);
  const bundlePath = path.join(OUTPUT_DIR, BUNDLE_NAME);
  let bundleSize;

  if (fs.existsSync(bundlePath)) {
    bundleSize = fs.statSync(bundlePath).size;
    console.log(`  Skipping ${BUNDLE_NAME} (already exists, ${formatSize(bundleSize)})`);
  } else {
    const bundleHeaders = authHeaders?.[launchAsset.key] || {};
    const bundleRes = await fetch(launchAsset.url, { headers: bundleHeaders });

    if (bundleRes.status !== 200) {
      throw new Error(`Failed to download bundle: ${bundleRes.status}`);
    }

    fs.writeFileSync(bundlePath, bundleRes.body);
    bundleSize = bundleRes.body.length;
    console.log(`  Downloaded ${BUNDLE_NAME} (${formatSize(bundleSize)})`);
  }

  // Download all assets
  console.log(`\nDownloading ${assets.length} assets...`);

  let totalAssetsSize = 0;
  let downloadedCount = 0;
  let skippedCount = 0;

  for (let i = 0; i < assets.length; i++) {
    const result = await downloadAsset(assets[i], authHeaders, i, assets.length);
    totalAssetsSize += result.size;
    if (result.skipped) {

View on GitHub (pinned to b09195aac2)

Solutions

  1. Re-run the script — EAS Updates signs fresh CDN URLs on each manifest request, so an expired URL self-heals.
  2. If 403: verify the launchAsset.key is present in extensions.assetRequestHeaders and the headers are being applied (check authHeaders?.[launchAsset.key]).
  3. If 404: the published update's bundle was likely deleted from storage; republish the update.
  4. If 429/503: transient CDN issue, retry with backoff.

Example fix

// before
const bundleRes = await fetch(launchAsset.url, { headers: bundleHeaders });
if (bundleRes.status !== 200) {
  throw new Error(`Failed to download bundle: ${bundleRes.status}`);
}

// after — retry with backoff for transient failures
async function fetchWithRetry(url, headers, retries = 3) {
  for (let i = 0; i < retries; i++) {
    const res = await fetch(url, { headers });
    if (res.status === 200) return res;
    if (res.status >= 500 || res.status === 429) {
      await new Promise(r => setTimeout(r, 1000 * (i + 1)));
      continue;
    }
    throw new Error(`Failed to download bundle: ${res.status}`);
  }
  throw new Error('Failed to download bundle after retries');
}
const bundleRes = await fetchWithRetry(launchAsset.url, bundleHeaders);
Defensive patterns

Strategy: retry

Validate before calling

// Check that the bundle URL is reachable before writing to disk
async function checkBundleUrl(url, headers) {
  const res = await fetch(url, { headers, method: 'HEAD' });
  return res.status === 200;
}

Try / catch

const bundleRes = await fetch(launchAsset.url, { headers: bundleHeaders });
if (bundleRes.status !== 200) {
  if (bundleRes.status === 403 || bundleRes.status === 404) {
    throw new Error(`Bundle URL invalid/expired (${bundleRes.status}). Re-run to get a fresh manifest.`);
  }
  // Retry transient failures
  for (let i = 0; i < 3; i++) {
    await new Promise(r => setTimeout(r, 1000 * (i + 1)));
    const retry = await fetch(launchAsset.url, { headers: bundleHeaders });
    if (retry.status === 200) { /* success */ break; }
  }
}

Prevention

When it happens

Trigger: After parsing the manifest successfully, fetch(launchAsset.url, { headers: bundleHeaders }) returns a status code other than 200. bundleHeaders come from extensions.assetRequestHeaders[launchAsset.key], which may be undefined if no auth is required.

Common situations: The signed CDN URL in the manifest has expired (EAS Updates URLs are time-limited); the asset requires authentication headers that weren't included; transient CDN outage or rate limiting (429/503); the bundle file was deleted from storage after the manifest was published.

Related errors


AI-assisted analysis of expo/expo@b09195aac2 (2026-08-12). Data as JSON: /api/errors/a06a30fa155266b7. Report an issue: GitHub.