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.

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-Controlheader 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-storeand 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.yamlexists in your.cloudflareddirectory.
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=2and?b=2&a=1are different keys. Request headers such asUser-AgentandCookiedo not partition the cache unless you setVary. - Only
GETandHEADare cached. Other methods always reach your Worker. - Automatic bypass applies. A response with
Set-Cookie, a request with anAuthorizationheader (unless the response is explicitlypublic), or a response markedprivateorno-storewill not be cached normally. Cf-Cache-Statustells you what happened.MISSmeans your Worker ran and the response was stored.HITmeans Cloudflare answered without running your Worker.BYPASSmeans 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:
- 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 getmax-age=31536000,immutablein the browser. Putting.jsand.cssbehind a one-hour edge cache would mostly add confusion, so the regex above leaves them out. - Keep the TTL short. One hour is a starting point. Lower
max-ageif designers are swapping images during the review. - 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.comaddress can reach your dev server directly, bypassing the Worker. Cloudflare’s docs describe a--allowed-mailoption 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.comrather than disabling host checks. - Tear down after the review. Stop
cloudflared, delete the Worker (npx wrangler delete) or at least purge its cache, and rotatePURGE_TOKENif 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
- Cloudflare Docs: Quick Tunnels
- Cloudflare Docs: Workers Cache overview
- Cloudflare Docs: Workers Cache configuration
- Cloudflare Docs: Workers Cache keys
- Cloudflare Docs: Purge and invalidate the cache
- Cloudflare Docs: Workers Cache limitations
- Cloudflare Docs: Cache API
- Cloudflare Docs: How the cache works
- Cloudflare Docs: Workers limits
- Cloudflare Docs: Wrangler commands
- Cloudflare Docs: Wrangler configuration
- Cloudflare Blog: Goodbye, section 2.8 and hello to Cloudflare’s new terms of service
- Vite: Dependency Pre-Bundling
- Vite: Why Vite
- Quasar: Opening a dev server to the public (allowedHosts guidance)
- Parallel Works: tunneling framework dev servers (Next.js allowedDevOrigins)
- CVE-2026-27977 (Next.js dev HMR WebSocket origin check)
Related InstaTunnel pages
Continue from this article into the most relevant product guides and workflows.
Related Topics
Keep building with InstaTunnel
Read the docs for implementation details or compare plans before you ship.