Tutorial
15 min read
23 views

Ephem-Cache: Edge-Caching Media-Heavy Localhost Previews with Cloudflare Workers and Quick Tunnels

Eliminate localhost preview lag with Ephem-Cache. Edge-cache media-heavy static assets via Cloudflare Workers while tunneling dynamic requests back to local.

IT
InstaTunnel Team
Published by the InstaTunnel team | Editorial policy
Ephem-Cache: Edge-Caching Media-Heavy Localhost Previews with Cloudflare Workers and Quick Tunnels

Quick answer

Ephem-Cache: Speed Up Localhost Previews with Cloudflare: quick comparison answer

Choose the tunnel tool based on the network model: public HTTPS URLs for webhooks and demos, private mesh access for internal apps, and managed infrastructure when policy controls matter most.

Which tunnel tool is best for public webhook testing?

Use a public HTTPS localhost tunnel with stable URLs. InstaTunnel focuses on webhook testing, demos, OAuth callbacks, and MCP endpoint workflows.

When should I choose a private network tool instead?

Choose a private mesh or Zero Trust tool when every user and service should stay inside a controlled private network.

Introduction: The Remote Frontend Feedback Loop Bottleneck

You are finishing a media-heavy landing page. It has high-resolution WebP images, looping background video and a few 3D assets. It all feels instant on your laptop. You start your dev server (Vite, Next.js or similar) and want a remote designer, product manager or client to review it live.

The usual move is a tunnel. Tools such as ngrok, Localtunnel and Cloudflare Tunnel (cloudflared) expose a local port on a public URL. That works well for HTML and API calls. For heavy media it has a structural weakness: every byte of every asset leaves your machine through your home or office upload link, once per viewer, per reload.

  • Upload is the bottleneck. If three reviewers open the link at once, they compete for the same upstream pipe. A 45 MB video that streams smoothly from your SSD stutters when it has to cross your uplink three times.
  • The preview misrepresents production. Stalled videos and slow images make the page feel worse than the deployed site will, so you get feedback about your upload speed rather than your design.

Ephem-Cache is the name I use for a small pattern that fixes this. A Cloudflare Worker sits in front of your tunnel. It lets Cloudflare cache the heavy, static media after the first fetch, and it forwards everything dynamic (HTML, API calls, WebSockets for hot reload) straight to your machine.

This guide covers the architecture, a working Worker, the dev-server settings that otherwise break the setup, and the limits and security trade-offs you should know about. The details here were checked against Cloudflare’s current documentation as of October 14, 2026. I deliberately left out benchmark numbers, because the results depend on your uplink, your files and your reviewers’ locations. The measurement section shows how to get your own.

1. The Architecture

[ Remote reviewer ]
        │
        ▼
[ Cloudflare edge: Worker with Workers Caching enabled ]
        │
        ├── Cache HIT ──► served from Cloudflare's cache, Worker not run
        │
        └── Cache MISS / dynamic request
                │
                ▼
        Worker proxies to the Quick Tunnel URL (trycloudflare.com)
                │
                ▼
        cloudflared ──► http://localhost:5173

The routing rules are simple:

  • Cacheable media (images, video, fonts, 3D assets) returns a Cache-Control header that lets Cloudflare store it. After the first fetch, later requests can be answered without your machine being involved.
  • Everything else (HTML, API routes, HMR WebSockets, server-rendered pages) is marked no-store and passed through on every request.

Why Workers Caching instead of the Cache API

Earlier versions of this pattern used caches.default (the Cache API) inside the Worker. The Cloudflare docs now describe a separate feature, Workers Caching, and it fits this job better:

Cache API (caches.default) Workers Caching
Scope Local to one data center. Contents do not replicate elsewhere. Tiered: a lower-tier cache near the visitor plus an upper tier that aggregates fills across the network.
Where it works Documented as functional on custom domains (and Pages Functions). Follows the Worker, so it works on workers.dev, routes, custom domains and service bindings.
Concurrent misses Not collapsed. A burst can invoke your Worker once per request. Collapsed: many simultaneous requests for one key invoke the Worker once per data center.
Partial content cache.put throws on a 206 response, so you must cache the full response yourself. Cloudflare strips Range, stores the full 200, and slices ranges itself.
Purge cache.delete is local to one data center. Global purge goes through zone purge. ctx.cache.purge() from inside the Worker, with the options listed in section 5.

These differences matter here. Request collapsing keeps three reviewers from triggering three copies of a 45 MB upload on the first load. Native range handling matters for <video> playback. Not needing a custom domain removes a setup step.

Workers Caching needs Wrangler 4.69.0 or later.

2. Setup

Step 1: Start the dev server and a Quick Tunnel

Start your dev server (Vite’s default port is 5173), then open a Quick Tunnel in another terminal:

cloudflared tunnel --url http://localhost:5173

cloudflared prints a random https://<random-words>.trycloudflare.com URL. No Cloudflare account or domain is needed for this part.

Know the limits of Quick Tunnels before you build on them. Cloudflare documents these:

  • Each tunnel handles up to 200 in-flight requests. Requests beyond that get an HTTP 429.
  • Server-Sent Events are not supported. WebSockets are not listed among the unsupported features.
  • There is no SLA or uptime guarantee, and the URL changes whenever you restart cloudflared.
  • Quick Tunnels are not supported if a config.yaml exists in your .cloudflared directory.

The 200-request ceiling is one more reason to cache. Cache hits never reach the tunnel.

Step 2: Fix your dev server’s host check

This step is easy to miss, and it is the most common reason the setup shows a blank page. When the Worker forwards a request, the Host header is the tunnel hostname, not localhost. Modern dev servers reject unknown hosts.

Vite shows Blocked request. This host is not allowed. Allow the tunnel domain in vite.config.ts:

import { defineConfig } from "vite";

export default defineConfig({
  server: {
    // A leading dot allows any subdomain of trycloudflare.com.
    allowedHosts: [".trycloudflare.com"],
  },
});

Avoid allowedHosts: true. It turns host checking off completely, which exposes the dev server to DNS-rebinding attacks. As an alternative, cloudflared can rewrite the header with --http-host-header localhost:5173.

Next.js restricts its dev-only endpoints, including the HMR WebSocket at /_next/webpack-hmr, to same-origin requests. Add the hostname your reviewers will use (your Worker’s hostname) to allowedDevOrigins in next.config.js. Also upgrade to Next.js 16.1.7 or later. CVE-2026-27977 describes a flaw in versions 16.0.1 through 16.1.6 where next dev could accept Origin: null WebSocket connections even with allowedDevOrigins configured. Since you are putting a dev server on the public internet, you want that patched.

Step 3: Create the Worker project

npm create cloudflare@latest ephem-cache-worker
cd ephem-cache-worker

Cloudflare now recommends wrangler.jsonc over wrangler.toml for new projects. Use this configuration:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "ephem-cache-preview",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-14",
  "cache": { "enabled": true },
  "vars": {
    "LOCAL_ORIGIN": "https://replace-me.trycloudflare.com"
  }
}

cache.enabled makes Cloudflare check its cache before your Worker runs. The compatibility_date is today’s date, as Cloudflare’s own examples suggest.

Step 4: The Worker

Replace src/index.ts with the following. It has been syntax-checked, but test it against your own project before you rely on it.

export interface Env {
  LOCAL_ORIGIN: string; // e.g. "https://random-words.trycloudflare.com"
  PURGE_TOKEN?: string; // optional secret for the purge endpoint
}

// Heavy, rarely-changing assets worth caching during a review session.
// JS and CSS are deliberately excluded (see section 4).
const MEDIA =
  /\.(png|jpe?g|gif|webp|avif|svg|ico|mp4|webm|mov|woff2?|ttf|otf|glb|gltf|ktx2|hdr)$/i;

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    // Optional: purge this Worker's cache on demand.
    if (url.pathname === "/__ephem/purge" && request.method === "POST") {
      if (!env.PURGE_TOKEN || request.headers.get("x-purge-token") !== env.PURGE_TOKEN) {
        return new Response("Forbidden", { status: 403, headers: { "Cache-Control": "no-store" } });
      }
      const result = await ctx.cache.purge({ purgeEverything: true });
      return new Response(result.success ? "Purged" : "Purge failed", {
        status: result.success ? 200 : 500,
        headers: { "Cache-Control": "no-store" },
      });
    }

    const isUpgrade = request.headers.get("Upgrade")?.toLowerCase() === "websocket";
    const isCacheableMedia =
      request.method === "GET" && !isUpgrade && MEDIA.test(url.pathname);

    // Re-point the request at the tunnel.
    const target = new URL(request.url);
    const origin = new URL(env.LOCAL_ORIGIN);
    target.protocol = origin.protocol;
    target.host = origin.host;

    const upstreamReq = new Request(target, request);

    if (isCacheableMedia) {
      // Always ask your machine for the full file so the cache stores a
      // complete 200, never a 304 or a partial response.
      upstreamReq.headers.delete("If-None-Match");
      upstreamReq.headers.delete("If-Modified-Since");
      upstreamReq.headers.delete("Range");
    }

    let upstream: Response;
    try {
      upstream = await fetch(upstreamReq);
    } catch {
      return new Response("Ephem-Cache: could not reach the local tunnel.", {
        status: 502,
        headers: { "Content-Type": "text/plain", "Cache-Control": "no-store" },
      });
    }

    // WebSocket upgrades (HMR) pass straight through.
    if (isUpgrade || upstream.status === 101) return upstream;

    const res = new Response(upstream.body, upstream);
    if (isCacheableMedia && upstream.status === 200) {
      res.headers.set("Cache-Control", "public, max-age=3600");
    } else {
      res.headers.set("Cache-Control", "no-store");
    }
    return res;
  },
} satisfies ExportedHandler<Env>;

A few design decisions here are worth explaining.

Every non-media response is explicitly no-store. With Workers Caching enabled, a 200 response with no Cache-Control header is still cached under RFC 9111 heuristic freshness, for 2 hours by default. Without the explicit no-store, your HTML and API responses could be served stale from the cache.

WebSocket upgrades are returned untouched. Cloudflare documents that WebSocket upgrade requests bypass Workers Caching and always invoke your Worker. Returning the upgrade response unmodified lets hot reload keep working.

Conditional and range headers are stripped on the media path. Dev servers answer conditional requests with 304, and a 304 is not a body worth caching. Cloudflare also removes Range before invoking the Worker and returns the requested slice from the cached full response, so the Worker never has to implement byte ranges. If a Worker returns its own 206, Cloudflare will not store it.

The Worker stays small. It only forwards requests and sets headers. Both the Free and Paid plans give you subrequests and CPU time for this.

Step 5: Deploy with the current tunnel URL

The tunnel URL changes every session, so pass it at deploy time instead of editing the file:

npx wrangler secret put PURGE_TOKEN        # optional
npx wrangler deploy --var LOCAL_ORIGIN:https://your-current-tunnel.trycloudflare.com

wrangler deploy accepts --var key:value as an alternative to the vars block in your config, and the flag value wins when both are set. A useful side effect: by default the Worker version is part of the cache key, so each deploy starts with a cold cache. A fresh review session never inherits assets from an old one.

Wrap both commands in a package script so a new session is one command.

3. How the Cache Behaves

Knowing the cache key and bypass rules saves hours of confusion:

  • The key is the entrypoint, the path plus query string, and (by default) the Worker version. The host is not part of the key. ?a=1&b=2 and ?b=2&a=1 are different keys. Request headers such as User-Agent and Cookie do not partition the cache unless you set Vary.
  • Only GET and HEAD are cached. Other methods always reach your Worker.
  • Automatic bypass applies. A response with Set-Cookie, a request with an Authorization header (unless the response is explicitly public), or a response marked private or no-store will not be cached normally.
  • Cf-Cache-Status tells you what happened. MISS means your Worker ran and the response was stored. HIT means Cloudflare answered without running your Worker. BYPASS means caching was skipped.
  • The first view is not faster. A cold request for a large video has to travel the full path from your machine. Workers Caching stores the full response, so the first visitor pays for the whole file. Warm the cache yourself by opening the page once before the review starts.
  • Size limits apply. The Workers limits page notes that CDN cache limits apply: 512 MB per object on Free, Pro and Business plans and 5 GB on Enterprise. Larger assets will not be cached.

4. Invalidation: What to Cache, and for How Long

Local files change constantly, so stale assets are the main risk. The approach:

  1. Cache media, not code. Source files in a dev server keep stable names while their contents change. Vite handles those well on its own by making source module requests conditional (304), while pre-bundled dependencies get max-age=31536000,immutable in the browser. Putting .js and .css behind a one-hour edge cache would mostly add confusion, so the regex above leaves them out.
  2. Keep the TTL short. One hour is a starting point. Lower max-age if designers are swapping images during the review.
  3. Purge on demand. The optional endpoint in the Worker calls ctx.cache.purge({ purgeEverything: true }): curl -X POST https://<your-worker-host>/__ephem/purge -H "x-purge-token: $PURGE_TOKEN"

purge also accepts tags and pathPrefixes. Workers Caching is private to your Worker, and zone-level purges from the dashboard or API do not affect it. Purges use the Free-tier rate limits regardless of your plan.
4. Redeploy to reset. Because the Worker version is part of the key, wrangler deploy is itself a full invalidation.
5. Preview a production build for final reviews. If you run vite build and serve the output with vite preview, asset filenames carry content hashes, so changed files get new URLs automatically. At that point you can safely add js|css|mjs to the regex.

5. Measuring the Result Yourself

Rather than quote numbers that depend on a specific setup, measure your own:

# 1. First request: expect Cf-Cache-Status: MISS
curl -sI https://<worker-host>/media/hero.mp4 | grep -i cf-cache-status

# 2. Second request: expect HIT
curl -sI https://<worker-host>/media/hero.mp4 | grep -i cf-cache-status

# 3. Compare download time against the tunnel URL directly
curl -s -o /dev/null -w "worker: %{time_total}s  %{size_download} bytes\n" https://<worker-host>/media/hero.mp4
curl -s -o /dev/null -w "tunnel: %{time_total}s  %{size_download} bytes\n" https://<your-tunnel>.trycloudflare.com/media/hero.mp4

For a realistic picture, run the same comparison from a machine in your reviewers’ region, or ask a reviewer to open DevTools’ Network tab and share a HAR file. Watch your own uplink while two or three people load the page. The cached version should leave it nearly idle after the first fetch.

6. Limits, Terms and Costs

  • Workers Free plan: 100,000 requests per day, 10 ms of CPU per request and 50 subrequests per request. A streaming proxy uses little CPU. But with caching enabled, every request to the Worker, including cache hits, is billed at the standard request rate.
  • Cloudflare’s terms on large files. Cloudflare’s own blog explains that its terms changed so that video and other large files can be served through its CDN when they are hosted by Cloudflare services such as Stream, Images or R2. Video and large files hosted outside Cloudflare remain restricted on the CDN. A tunnel to your laptop is an external origin. Review Cloudflare’s current service-specific terms before routing large video through this setup, and keep it to short review sessions. For permanent media, host the files in R2 instead.
  • No SLA. Quick Tunnels are a testing tool. For anything you depend on, use a named Cloudflare Tunnel.

7. Security Considerations

This pattern puts an unreleased site on the public internet, so a few points deserve care.

  • Cached hits skip your Worker. Any check you write inside the Worker (a token, a password) runs only on cache misses. Once a media file is cached, anyone who knows its URL can fetch it. Do not use this pattern for confidential or embargoed assets.
  • The tunnel URL is public too. Anyone who learns the random trycloudflare.com address can reach your dev server directly, bypassing the Worker. Cloudflare’s docs describe a --allowed-mail option for Quick Tunnels that requires visitors to enter a one-time PIN. That gate would also challenge the Worker’s own requests, so it does not combine with this architecture. A practical alternative is to have the Worker add a secret header to its upstream requests and reject requests without it in a small Vite plugin: // vite.config.ts (excerpt) const guard = { name: "ephem-guard", configureServer(server) { server.middlewares.use((req, res, next) => { if (req.headers["x-ephem-secret"] !== process.env.EPHEM_SECRET) { res.statusCode = 403; return res.end("Forbidden"); } next(); }); }, };

This covers plain HTTP requests only. Keep the secret out of your repository.

  • Don’t leak secrets into bundles. Anything in client-side code, including environment variables bundled by your framework, ends up in files visitors can read. Keep database URLs and private API keys server-side.
  • Keep host allow-lists narrow. Use .trycloudflare.com rather than disabling host checks.
  • Tear down after the review. Stop cloudflared, delete the Worker (npx wrangler delete) or at least purge its cache, and rotate PURGE_TOKEN if you shared it.

Conclusion

A local tunnel is the fastest way to show unfinished work, but it makes your upload connection the delivery network for every image and video. Putting a small Cloudflare Worker with Workers Caching in front of the tunnel moves the repeat traffic for heavy media onto Cloudflare’s cache and leaves your machine to serve only the first request for each file plus the dynamic traffic that must come from it.

The pattern has real caveats: the first view is still slow, cached files are effectively public, Quick Tunnels have no uptime guarantee, and Cloudflare’s terms limit large externally hosted files on its CDN. Within those limits it makes remote reviews of media-heavy pages far less dependent on your upload speed. Start with a short session, check Cf-Cache-Status on a few assets, and measure the difference on your own files.

Sources

Continue from this article into the most relevant product guides and workflows.

Related Topics

#edge caching#Cloudflare Workers#local tunnels#localhost preview#static asset caching#edge computing#frontend developer tools#remote stakeholder review#reduce preview latency#media-heavy frontend builds#dynamic local preview#Cloudflare edge compute#local development workflow#reverse proxy for localhost#tunneling localhost#Cloudflare Workers KV#edge caching static assets#web development optimization#share localhost with clients#remote client review tools#fast localhost previews#local server tunneling#web performance at the edge#Cloudflare KV cache#localhost debugging tools#frontend staging workflows#local API tunneling#bypass ngrok latency#fast local tunneling#edge caching dynamic content#serverless edge caching#remote testing local builds#Cloudflare Workers tutorial#edge caching strategies#optimize local development#local web server sharing#instant localhost sharing#frontend deployment preview#secure local tunneling#high performance local preview#edge worker caching#cloudflare workers static assets#local development speed up#developer productivity tools#low latency local preview#caching static files at edge#proxying dynamic requests localhost#web app testing tools#client feedback workflow tools#modern frontend tooling#ephemeral edge cache#cloudflare edge worker proxy

Keep building with InstaTunnel

Read the docs for implementation details or compare plans before you ship.

Share this article

More InstaTunnel Insights

Discover more tutorials, tips, and updates to help you build better with localhost tunneling.

Browse All Articles