← Writing

How to cache Supabase assets in React Native using Cloudflare Workers

Mobile apps live and die by their perceived performance. When users scroll through image feeds or load profile avatars, every millisecond of delay chips away at their patience. If you’re building with React Native and Expo, and storing assets in Supabase Storage, you’ve probably noticed something: direct Supabase URLs work, but they aren’t optimized for mobile performance.

Here’s the problem. Supabase Storage serves files from specific regions. Your users might be on the other side of the world, waiting for images to travel thousands of miles. Plus, every request hits your Supabase project directly, eating into your egress limits and racking up costs. That’s where the problem lies.

The solution? Put a caching layer in between. Cloudflare Workers run at the edge (270+ locations globally) and can cache your assets geographically close to your users. When a user requests an image, your Worker checks its cache first. If it’s there, the image returns instantly from the nearest data center. If not, the Worker fetches from Supabase, caches it, and serves it. Subsequent requests are fast.

Edge caching architecture serving assets from nearest data center

Here’s how to build this pipeline.

What you’ll need

Before we start, make sure you’ve got:

  • A Supabase project with Storage enabled
  • A Cloudflare account with Workers enabled
  • An Expo React Native development environment set up
  • The following packages installed: expo-image, @supabase/supabase-js
  • Basic familiarity with TypeScript and API development

Step 1: Configure Supabase Storage

First, you’ll need a place to store your files. In your Supabase dashboard, navigate to Storage and create a new bucket. Name it something descriptive like app-assets or user-uploads.

Now you’ll configure your bucket’s access policies. Supabase uses Row Level Security (RLS) for storage, which means you can define granular permissions. For public assets like app icons or default avatars, you’ll want a policy that allows anonymous SELECT access. For user-specific content, you’ll want policies that check the user’s authentication status.

-- Allow public access to the 'public' folder
CREATE POLICY "Public Access"
ON storage.objects FOR SELECT
USING (bucket_id = 'app-assets' AND (storage.foldername(name))[1] = 'public');

Don’t forget CORS configuration. Your React Native app needs to make cross-origin requests to your Worker, which in turn talks to Supabase. In your Supabase project settings, add your Worker’s domain to the allowed origins list.

Finally, grab your service role key from the Supabase dashboard under Project Settings > API. This key bypasses RLS and should only be used server-side in your Worker, never in your React Native app.

Step 2: Create the Cloudflare Worker

It’s time to build the caching layer. Initialize a new Worker project using Wrangler, Cloudflare’s CLI tool:

npm create cloudflare@latest supabase-asset-proxy
cd supabase-asset-proxy
npm install @supabase/supabase-js itty-router

Now let’s write the Worker code. Create a proxy endpoint that handles asset requests, checks the cache, and falls back to Supabase on cache misses:

import { Router } from 'itty-router';
import { createClient } from '@supabase/supabase-js';

export interface Env {
  SUPABASE_URL: string;
  SUPABASE_SERVICE_KEY: string;
  CACHE_TTL: number; // in seconds
}

const router = Router();

router.get('/assets/:bucket/*', async (request, env: Env) => {
  const url = new URL(request.url);
  const cacheKey = new Request(url.toString(), request);

  // Try cache first
  const cache = caches.default;
  let response = await cache.match(cacheKey);

  if (response) {
    // Cache hit - add header for debugging
    response = new Response(response.body, response);
    response.headers.set('X-Cache-Status', 'HIT');
    return response;
  }

  // Cache miss - fetch from Supabase
  const bucket = request.params.bucket;
  const path = request.params['*'];

  const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_KEY);

  const { data, error } = await supabase
    .storage
    .from(bucket)
    .download(path);

  if (error || !data) {
    return new Response('Asset not found', { status: 404 });
  }

  // Create response with appropriate headers
  const arrayBuffer = await data.arrayBuffer();
  response = new Response(arrayBuffer, {
    headers: {
      'Content-Type': data.type || 'application/octet-stream',
      'Cache-Control': `public, max-age=${env.CACHE_TTL}`,
      'X-Cache-Status': 'MISS',
    },
  });

  // Store in cache
  await cache.put(cacheKey, response.clone());

  return response;
});

export default {
  async fetch(request: Request, env: Env) {
    return router.handle(request, env);
  },
};

This Worker does several things: It uses the Cache API to check for cached responses. On a cache hit, it returns immediately with an X-Cache-Status: HIT header. On a miss, it fetches from Supabase using the service role key, sets appropriate cache headers, stores the response in the edge cache, and returns the asset.

Configure your wrangler.toml with the necessary environment variables:

name = "supabase-asset-proxy"
main = "src/index.ts"
compatibility_date = "2026-03-04"

[vars]
CACHE_TTL = "86400" # 24 hours

[[env.production.vars]]
SUPABASE_URL = "https://your-project.supabase.co"

Set your service key securely:

wrangler secret put SUPABASE_SERVICE_KEY

Step 3: Implement cache-aware asset fetching

Your cache strategy matters. Different asset types need different cache behaviors. User avatars might change occasionally, so a 1-hour TTL makes sense. Static app assets like icons can be cached for days or even weeks.

Let’s enhance the Worker with smarter cache key generation and TTL configuration:

function getCacheConfig(path: string): { ttl: number; tags: string[] } {
  if (path.startsWith('avatars/')) {
    return { ttl: 3600, tags: ['avatars'] }; // 1 hour
  }
  if (path.startsWith('icons/')) {
    return { ttl: 604800, tags: ['icons'] }; // 1 week
  }
  return { ttl: 86400, tags: ['assets'] }; // 24 hours default
}

// In your route handler:
const config = getCacheConfig(path);

response = new Response(arrayBuffer, {
  headers: {
    'Content-Type': data.type || 'application/octet-stream',
    'Cache-Control': `public, max-age=${config.ttl}`,
    'Cache-Tag': config.tags.join(','),
    'X-Cache-Status': 'MISS',
  },
});

The Cache-Tag header is particularly useful. It lets you purge specific asset categories without clearing your entire cache. When a user updates their avatar, you can purge just the avatars tag rather than invalidating all cached assets.

For stale-while-revalidate behavior (serving stale content while fetching fresh data in the background), you’d need to implement a more sophisticated pattern using Workers KV or external cache stores. The built-in Cache API doesn’t support this natively, but for most mobile app use cases, a simple TTL-based approach works well.

Step 4: Set up React Native Expo client

Now let’s set up the client side. The expo-image library handles image loading and caching in React Native apps. It uses SDWebImage on iOS and Glide on Android under the hood, both of which are reliable, production-ready image loading libraries.

Install it first:

npx expo install expo-image

Create a reusable Image component that points to your Worker:

import { Image } from 'expo-image';
import { ViewStyle } from 'react-native';

interface CachedImageProps {
  bucket: string;
  path: string;
  style?: ViewStyle;
  placeholder?: string;
}

const WORKER_URL = 'https://supabase-asset-proxy.your-subdomain.workers.dev';

export function CachedImage({ bucket, path, style, placeholder }: CachedImageProps) {
  const imageUrl = `${WORKER_URL}/assets/${bucket}/${path}`;

  return (
    <Image
      source={imageUrl}
      style={style}
      placeholder={placeholder}
      cachePolicy="disk"
      transition={200}
    />
  );
}

The cachePolicy="disk" setting tells expo-image to persist downloaded images to device storage. This means even if users close and reopen your app, images load instantly from local cache. The transition prop adds a smooth fade-in animation when images load.

For critical assets that should be ready before the user sees them (like profile photos in a list), use prefetching:

import { Image } from 'expo-image';

async function prefetchUserAvatars(userIds: string[]) {
  const urls = userIds.map(id =>
    `${WORKER_URL}/assets/avatars/${id}.jpg`
  );

  await Image.prefetch(urls, 'disk');
}

// Call this when your component mounts or when data loads
useEffect(() => {
  prefetchUserAvatars(users.map(u => u.id));
}, [users]);

Handle offline scenarios gracefully. The expo-image component has built-in error handling, but you’ll want to provide fallback UI:

<Image
  source={imageUrl}
  style={styles.image}
  placeholder={blurhash}
  onError={(error) => {
    console.warn('Failed to load image:', error);
    // Could set state to show a placeholder image
  }}
/>

Step 5: Connect everything together

With all the pieces in place, let’s verify everything works end-to-end. Deploy your Worker:

wrangler deploy

Test a direct request to your Worker:

curl -I https://supabase-asset-proxy.your-subdomain.workers.dev/assets/public/logo.png

You should see X-Cache-Status: MISS on the first request and X-Cache-Status: HIT on subsequent requests. The CF-Cache-Status header from Cloudflare will also indicate cache behavior.

In your React Native app, render an image using your CachedImage component and check that it loads correctly. Use React Native’s Flipper or Chrome DevTools to inspect network requests. You should see requests going to your Worker URL, not directly to Supabase.

For authenticated assets, you’ll need to modify the Worker to validate JWT tokens before serving private content. Pass the user’s Supabase JWT in the Authorization header from your React Native app, verify it in the Worker using Supabase’s auth helpers, then proceed with the cache logic only if validation passes.

Performance optimization tips

Once your caching pipeline is working, consider these optimizations.

Multi-layer caching reduces asset load times to near-instant retrieval

  • Image format conversion: Serve WebP images to supported clients. They’re typically 25-35% smaller than JPEGs with equivalent quality. You can detect supported formats via the Accept header.

  • Responsive images: Generate multiple sizes of each image (thumbnail, medium, full) and serve the appropriate size based on the device’s screen density. This saves bandwidth on mobile networks.

  • Cache warming: For predictable high-traffic events (like a new feature launch), prefetch and cache assets before users request them. A simple script that hits your Worker endpoints can warm the cache across Cloudflare’s global network.

  • Monitoring: Use Cloudflare Analytics to track cache hit rates. Aim for 80%+ hit rates on static assets. Lower rates might indicate TTL settings that are too short or cache key issues.

Common pitfalls and how to avoid them

CORS misconfigurations are the most common issue. If your React Native app can’t load images, check that your Worker includes appropriate CORS headers:

const corsHeaders = {
  'Access-Control-Allow-Origin': '*', // Or your specific app domain
  'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
};

Cache key collisions happen when different assets share the same cache key. Always include the full path and any version parameters in your cache key. If you support image transformations (like width/height), include those parameters too.

Memory leaks in React Native can occur if you don’t clean up image references properly. The expo-image library handles most of this automatically, but be careful with dynamic source changes. Use the recyclingKey prop when rendering images in lists:

<Image
  source={imageUrl}
  recyclingKey={item.id} // Prevents showing old image while new one loads
/>

Large file uploads shouldn’t go through your Worker due to size limits and timeout constraints. For uploads, have your React Native app get a signed URL directly from Supabase and upload to that URL instead.

Deploying to production

Before going live, audit your environment variable management. Never commit secrets to git — use Wrangler’s secret management for production keys and local .dev.vars files for development.

Consider setting up a custom domain for your Worker (like assets.yourapp.com). It looks more professional and gives you more control over DNS and SSL configuration.

Set up monitoring and alerting. Cloudflare’s built-in analytics show request volumes and error rates. For more detailed monitoring, integrate a service like Sentry to capture errors from your Worker.

For high-traffic applications, be aware of Cloudflare Workers’ limits: 128MB memory per request, 50ms CPU time on the free plan (up to 30s on paid plans), and 100,000 requests per day on the free tier. Most mobile apps won’t hit these limits, but plan accordingly if you’re expecting significant scale.


Frequently Asked Questions

Do you need a paid Cloudflare plan to cache Supabase assets for a React Native app?

No, the free tier includes 100,000 requests per day, which is sufficient for most apps in development and early production. When you scale beyond that, the paid plans are reasonably priced and include additional features like custom cache rules and longer CPU timeouts.

How does cache invalidation work when updating assets stored on Supabase for React Native apps?

You have a few options. The simplest is using versioned filenames (like avatar-v2.jpg instead of overwriting avatar.jpg). For more control, use the Cache-Tag headers. You can purge specific tags via Cloudflare’s API when assets change. Alternatively, set conservative TTLs for frequently changing content.

Can you use Cloudflare Workers to cache private Supabase assets in React Native Expo?

Yes, but you need to add authentication to your Worker. Validate the user’s JWT token from Supabase Auth before serving the cached response. The cache should be keyed by both the asset path AND the user ID to prevent cross-user cache leakage.

What’s the difference between expo-image disk caching and Cloudflare Worker caching?

They serve different purposes and work together. Cloudflare caching reduces latency from the internet to your app by serving assets from edge locations. expo-image’s disk caching eliminates network requests entirely by storing images on the device after the first download. Both together give you the best performance: edge caching for the first user in a region, then device caching for subsequent views.

How do you handle offline scenarios when caching Supabase assets in React Native?

The combination of expo-image’s disk cache and proper error handling covers most cases. Images that have been viewed while online will display from the device cache even without connectivity. For a better offline experience, implement prefetching for critical assets and use blurhash placeholders while images load.

Is it possible to use this caching setup with Expo Router and file-based navigation?

Absolutely. The caching layer is completely separate from your navigation structure. Your components import the CachedImage component and use it wherever they would normally use a regular Image component. Expo Router doesn’t affect how images are fetched and cached.