Local Emulation with wrangler dev vs Production
This guide is part of Edge Runtime Fundamentals & Platform Constraints. It maps, behaviour by behaviour, where wrangler dev tells you the truth about production and where it quietly lies, then turns that map into a checklist you run before every ship.
The problem
wrangler dev is unusually good. It is not a mock, not a shim and not a reimplementation — it downloads and runs workerd, the same runtime that executes your Worker in Cloudflare’s network, on your laptop. That fidelity is exactly what makes it dangerous. Because most things are real, you stop asking which things are not.
So the failure arrives as a surprise. Your middleware runs in 4 ms locally and returns Error 1102: Worker exceeded resource limits in production. Your session cookie works perfectly at http://localhost:8787 and is silently dropped by the browser once the site is on HTTPS. Your feature-flag read is instant locally and returns null for the first eight seconds after a write in production, logging users out. Your request.cf.country branch is never exercised because locally the field is a placeholder. Each of these is a behaviour that lives outside the isolate — in the scheduler, the browser, the storage network, or the routing layer — and workerd on your laptop has no way to reproduce it.
Root cause: workerd emulates the isolate, not the network around it
Draw the boundary correctly and every divergence becomes predictable. wrangler dev gives you a faithful isolate: the same JavaScript engine, the same Web API surface, the same missing Node built-ins, the same immutable header guards, the same HTMLRewriter, the same streams. Anything decided inside that isolate is trustworthy locally.
Everything else — the CPU accounting that kills an over-budget invocation, the eventually consistent replication behind KV, the multi-PoP cache with its tiers, the geolocation fields injected by the network edge, the isolate lifecycle that produces cold starts, and the TLS termination that makes Secure cookies work — is infrastructure surrounding the isolate. Miniflare replaces that infrastructure with local stand-ins optimised for developer convenience: an on-disk key-value store with immediate reads, a single-process cache, a fixed placeholder cf object, one long-lived isolate, and plain HTTP.
Those stand-ins are not bugs. A KV emulator that made you wait eight seconds to see your own write would make local development miserable. But it means the local environment is systematically more forgiving than production on precisely the axes that cause outages. The discipline that follows is simple to state and easy to skip: for every forgiving stand-in, either bind to the real thing, or write down the divergence and verify it somewhere else.
Step 1 — Make the environment declare itself
The cheapest way to stop guessing which environment you are in is to have the Worker tell you. A single diagnostic route, gated so it cannot be reached in production without a shared secret, turns “I think this is local” into a fact.
// src/diagnostics.ts
export interface Env {
ENVIRONMENT: 'local' | 'preview' | 'production';
DIAG_TOKEN: string;
SESSIONS: KVNamespace;
}
export interface EnvironmentReport {
environment: string;
protocol: string;
colo: string | null;
country: string | null;
tlsVersion: string | null;
cacheApiPresent: boolean;
kvRoundTripMs: number;
isolateAgeMs: number;
}
const ISOLATE_BOOTED_AT = Date.now();
export async function report(request: Request, env: Env): Promise<Response> {
if (request.headers.get('x-diag-token') !== env.DIAG_TOKEN) {
return new Response(null, { status: 404 });
}
const cf = (request as Request & { cf?: Record<string, unknown> }).cf ?? {};
const probeKey = `diag:${crypto.randomUUID()}`;
const startedAt = Date.now();
await env.SESSIONS.put(probeKey, '1', { expirationTtl: 60 });
const readBack = await env.SESSIONS.get(probeKey);
const kvRoundTripMs = Date.now() - startedAt;
const body: EnvironmentReport = {
environment: env.ENVIRONMENT,
protocol: new URL(request.url).protocol,
colo: typeof cf.colo === 'string' ? cf.colo : null,
country: typeof cf.country === 'string' ? cf.country : null,
tlsVersion: typeof cf.tlsVersion === 'string' ? cf.tlsVersion : null,
cacheApiPresent: typeof caches !== 'undefined',
kvRoundTripMs,
isolateAgeMs: Date.now() - ISOLATE_BOOTED_AT,
// A null read here means eventual consistency is real in this environment.
...(readBack === null ? { note: 'read-after-write returned null' } : {}),
} as EnvironmentReport;
return Response.json(body, { headers: { 'cache-control': 'no-store' } });
}
Run it locally and you will see colo: "EARTH" or a similar placeholder, country either absent or a fixed value, tlsVersion: null, a KV round trip near zero, and an isolateAgeMs that keeps growing for as long as your dev server has been running. Run it against a preview deployment and every one of those numbers changes. That diff is your divergence list, produced by measurement rather than memory.
Step 2 — Bind to real resources with remote mode
wrangler dev --remote keeps workerd on your laptop but routes bindings to your actual Cloudflare account resources. You get production KV data with production propagation behaviour, real R2 objects, and real D1. It is the correct mode whenever the thing you are debugging is data, not logic.
The cost is real: you are reading and writing production state from a laptop, so guard destructive operations behind an explicit environment check rather than trusting yourself to remember.
// src/guards.ts
export type Environment = 'local' | 'preview' | 'production';
export class DestructiveOperationBlocked extends Error {
constructor(op: string, env: Environment) {
super(`Refusing to run ${op} against ${env} data from a developer session`);
this.name = 'DestructiveOperationBlocked';
}
}
/**
* Wrap any binding call that mutates shared state. In remote mode the
* bindings point at real data, so an accidental bulk delete is permanent.
*/
export async function guardedWrite<T>(
op: string,
env: { ENVIRONMENT: Environment; ALLOW_REMOTE_WRITES?: string },
fn: () => Promise<T>,
): Promise<T> {
const remoteish = env.ENVIRONMENT !== 'local';
const allowed = env.ALLOW_REMOTE_WRITES === 'yes';
if (remoteish && !allowed) throw new DestructiveOperationBlocked(op, env.ENVIRONMENT);
return fn();
}
Set ALLOW_REMOTE_WRITES only in the local session where you genuinely intend to write, and never in the deployed configuration.
Step 3 — Keep secrets in .dev.vars and out of the repo
wrangler dev reads a .dev.vars file at the project root and injects each line as a binding on env, exactly as a deployed secret would appear. It is a plain KEY=value file, it is never uploaded, and it must be in .gitignore.
# wrangler.toml
name = "edge-middleware"
main = "src/worker.ts"
compatibility_date = "2026-07-01"
compatibility_flags = ["nodejs_compat_v2"]
[vars]
ENVIRONMENT = "local"
[[kv_namespaces]]
binding = "SESSIONS"
id = "b1e4a7c9f0d2416ab8c35d9e7f120a44"
preview_id = "4c9d1f77aa3b48e2b6d0e51c8843fa10"
[[durable_objects.bindings]]
name = "LIMITER"
class_name = "RateLimiter"
[env.production.vars]
ENVIRONMENT = "production"
[dev]
port = 8787
local_protocol = "https"
# .dev.vars — gitignored, never deployed
DIAG_TOKEN=local-only-diagnostic-token
JWT_ISSUER=https://issuer.example.com/
ALLOW_REMOTE_WRITES=no
Two details matter more than they look. preview_id on the KV namespace is what keeps preview and production data separate; omit it and a preview deployment writes straight into production keys. And local_protocol = "https" is the single line that makes Secure and __Host- prefixed cookies behave locally the way they will in production — without it, the browser drops them over plain HTTP and your session code appears broken for a reason that has nothing to do with your code. The cookie mechanics themselves are covered in handling session cookies in edge middleware.
Step 4 — Simulate the divergences local mode cannot produce
For the behaviours no mode reproduces, inject the constraint yourself. A thin wrapper that adds an artificial propagation delay and an artificial CPU budget turns two invisible production behaviours into ones you can hit on demand.
// src/simulate.ts
export interface SimulationConfig {
/** Hide freshly written keys for this long, mimicking KV propagation. */
kvPropagationMs: number;
/** Throw once the invocation exceeds this many milliseconds of work. */
cpuBudgetMs: number;
/** Value to report as the visitor country when the platform supplies none. */
forcedCountry: string | null;
}
export class CpuBudgetExceeded extends Error {
constructor(public readonly elapsedMs: number, public readonly budgetMs: number) {
super(`Invocation used ${elapsedMs}ms against a ${budgetMs}ms budget`);
this.name = 'CpuBudgetExceeded';
}
}
export function simulate(cfg: SimulationConfig) {
const startedAt = Date.now();
const writtenAt = new Map<string, number>();
return {
/** Call at each checkpoint in the handler to fail fast, as production would. */
checkBudget(label: string): void {
const elapsed = Date.now() - startedAt;
if (elapsed > cfg.cpuBudgetMs) {
console.log(JSON.stringify({ event: 'budget.exceeded', label, elapsed }));
throw new CpuBudgetExceeded(elapsed, cfg.cpuBudgetMs);
}
},
wrapKV(kv: KVNamespace): Pick<KVNamespace, 'get' | 'put' | 'delete'> {
return {
async get(key: string) {
const t = writtenAt.get(key);
if (t !== undefined && Date.now() - t < cfg.kvPropagationMs) return null;
return kv.get(key);
},
async put(key: string, value: string, opts?: KVNamespacePutOptions) {
writtenAt.set(key, Date.now());
return kv.put(key, value, opts);
},
async delete(key: string) {
writtenAt.delete(key);
return kv.delete(key);
},
} as Pick<KVNamespace, 'get' | 'put' | 'delete'>;
},
country(request: Request): string | null {
const cf = (request as Request & { cf?: { country?: string } }).cf;
return cf?.country ?? cfg.forcedCountry;
},
};
}
Enable it only when ENVIRONMENT === 'local'. The kvPropagationMs wrapper is the highest-value part: set it to 5000 and every read-after-write path in your session and feature-flag code has to prove it degrades gracefully instead of treating null as “no session”. That is the same failure mode described in handling KV eventual consistency in edge reads, and it is invisible in every local mode.
Step 5 — Turn the divergence list into a runnable gate
A checklist that lives in a wiki decays. One that runs as a script does not. Point the same probe at both environments and diff the fields you care about.
// scripts/divergence.ts — run with vitest or as a plain script
export interface DivergenceRow {
field: string;
local: string;
preview: string;
expectedToDiffer: boolean;
}
export function compare(
local: Record<string, unknown>,
preview: Record<string, unknown>,
expectedToDiffer: ReadonlySet<string>,
): DivergenceRow[] {
const fields = new Set([...Object.keys(local), ...Object.keys(preview)]);
const rows: DivergenceRow[] = [];
for (const field of fields) {
rows.push({
field,
local: String(local[field] ?? 'absent'),
preview: String(preview[field] ?? 'absent'),
expectedToDiffer: expectedToDiffer.has(field),
});
}
return rows.sort((a, b) => a.field.localeCompare(b.field));
}
/** A row that differs but was not expected to is the thing worth investigating. */
export function surprises(rows: DivergenceRow[]): DivergenceRow[] {
return rows.filter((r) => r.local !== r.preview && !r.expectedToDiffer);
}
The expectedToDiffer set is the artefact worth maintaining: colo, country, tlsVersion, kvRoundTripMs, isolateAgeMs. Everything else should match, and a field that starts differing unexpectedly — a header that appears only in preview, a status that changes — is a real finding rather than noise.
Local versus production divergence
| Behaviour | wrangler dev (local) |
wrangler dev --remote |
Cloudflare production |
|---|---|---|---|
| Runtime | Real workerd | Real workerd | Real workerd |
| CPU / wall limits | Not enforced | Not enforced | Enforced; invocation terminated on breach |
| KV reads | Local store, immediately consistent | Real KV, real propagation delay | Real KV, eventually consistent |
| Durable Objects | Local, correct single-instance ordering | Real objects, real placement latency | Real objects, region-pinned |
| Cache API | Single in-process cache, no tiers | Still local; tiering not emulated | PoP cache plus optional upper tier |
request.cf geo |
Fixed placeholder values | Fixed placeholder values | Real country, region, colo, timezone |
cf.tlsVersion |
null over HTTP |
null over HTTP |
Populated by TLS termination |
Secure / __Host- cookies |
Dropped unless local_protocol = "https" |
Same | Always available |
| Cold starts | One isolate for the whole session | One isolate for the whole session | Frequent; module scope re-evaluated |
| Secrets | .dev.vars file |
.dev.vars file |
Encrypted secrets from the dashboard or CLI |
Outbound fetch |
Real internet, your laptop’s egress IP | Same | PoP egress, subject to platform rules |
| Rate limiting and WAF | Absent | Absent | Applied before your Worker runs |
The request.cf row is worth internalising. Locally that object exists but its fields are placeholders, which means a country-keyed branch does not throw — it simply always takes the same path. A test that never fails is worse than one that does, so geolocation branches should take the country as an argument, as described in country-based redirects in edge middleware.
Validating with Vitest
The divergence comparison and the simulation wrappers are ordinary code, so they can be locked down with ordinary tests. These are the tests that stop the gate from rotting into a formality.
// test/divergence.test.ts
import { describe, expect, it } from 'vitest';
import { compare, surprises } from '../scripts/divergence';
import { simulate, CpuBudgetExceeded } from '../src/simulate';
const EXPECTED = new Set(['colo', 'country', 'tlsVersion', 'kvRoundTripMs', 'isolateAgeMs']);
describe('divergence gate', () => {
it('ignores fields that are known to differ between environments', () => {
const rows = compare(
{ colo: 'EARTH', country: 'XX', environment: 'local', cacheApiPresent: true },
{ colo: 'AMS', country: 'NL', environment: 'preview', cacheApiPresent: true },
EXPECTED,
);
expect(surprises(rows).map((r) => r.field)).toEqual(['environment']);
});
it('flags an unexpected header difference as a surprise', () => {
const rows = compare(
{ setCookieSecure: 'false', cacheApiPresent: true },
{ setCookieSecure: 'true', cacheApiPresent: true },
EXPECTED,
);
expect(surprises(rows)).toHaveLength(1);
expect(surprises(rows)[0].field).toBe('setCookieSecure');
});
});
describe('simulated production constraints', () => {
it('hides a freshly written key for the configured propagation window', async () => {
const backing = new Map<string, string>();
const raw = {
get: async (k: string) => backing.get(k) ?? null,
put: async (k: string, v: string) => { backing.set(k, v); },
delete: async (k: string) => { backing.delete(k); },
} as unknown as KVNamespace;
const sim = simulate({ kvPropagationMs: 5_000, cpuBudgetMs: 50, forcedCountry: 'NL' });
const kv = sim.wrapKV(raw);
await kv.put('sess:1', '{"u":42}');
expect(await kv.get('sess:1')).toBeNull(); // production behaviour, locally
});
it('throws once the simulated CPU budget is exhausted', () => {
const sim = simulate({ kvPropagationMs: 0, cpuBudgetMs: -1, forcedCountry: null });
expect(() => sim.checkBudget('after-parse')).toThrow(CpuBudgetExceeded);
});
it('falls back to a forced country when the platform supplies none', () => {
const sim = simulate({ kvPropagationMs: 0, cpuBudgetMs: 50, forcedCountry: 'DE' });
expect(sim.country(new Request('https://app.example.com/'))).toBe('DE');
});
});
The first simulation test is the important one: it asserts that a read immediately after a write returns null, which is production’s behaviour and never local emulation’s. Once that assertion exists, any code path that treats null as “signed out” fails loudly on your machine instead of quietly in production. For the wider harness patterns see unit testing edge middleware with Vitest.
Common pitfalls and resolutions
Session works locally, breaks on the deployed site — the cookie is Secure or __Host- prefixed and was silently dropped over plain HTTP. Set local_protocol = "https" in the [dev] block and accept the self-signed certificate once.
Error 1102: Worker exceeded resource limits, never seen locally — no local mode enforces the CPU ceiling. Add explicit budget checkpoints, test with production-sized payloads, and measure on a preview deployment.
Feature flag or session read returns null only in production — eventual consistency. Wrap KV locally with a propagation delay and make the degraded path explicit rather than accidental.
Country routing never triggers locally — request.cf.country is a placeholder. Take the country as a function argument and force a value in local configuration.
A cache hit locally becomes a permanent miss deployed — one local cache versus many PoPs and tiers. Verify cache keys against a deployed environment; see debugging low cache hit ratios at the edge.
Preview writes clobber production data — the KV namespace has no preview_id, so both environments share one namespace. Always declare a distinct preview_id.
Startup cost looks negligible locally — one isolate serves your whole dev session, so module-scope work runs once. In production it runs on every cold start; keep it to constant construction, never a network fetch.
Remote mode feels slow and occasionally errors — every binding call is now a real network round trip from your laptop. That latency is not representative of production, where the Worker sits beside the storage network; use remote mode to check data semantics, not timings.
Secrets stopped resolving after a rename — .dev.vars is read only at startup and is not merged with the deployed secret set. Restart the dev server and confirm the deployed secret exists separately.
Production deployment checklist
Frequently Asked Questions
Does wrangler dev run the same runtime as production?
Yes. It downloads and runs workerd, the same runtime Cloudflare executes your Worker on, so the JavaScript engine, the Web API surface, the missing Node built-ins and the immutable header guards all match. What differs is the infrastructure around the isolate: storage, caching, geolocation, CPU accounting and the isolate lifecycle.
When should I use remote mode instead of local mode?
Use remote mode when the thing you are debugging is data rather than logic. It keeps the isolate on your laptop but routes bindings to real account resources, so you see real stored values, real key naming and real propagation delay. Guard destructive writes first, because you are now operating on production state.
Why do my Secure cookies disappear during local development?
Because the local dev server serves plain HTTP by default and browsers drop cookies marked Secure, along with anything using the host prefix, over an insecure origin. Set the local protocol to https in the dev section of your wrangler configuration and accept the self-signed certificate once.
Can local emulation reproduce eventual consistency in key-value storage?
No. The local store is a single on-disk implementation where a write is visible to the very next read. Production replicates across the network, so reads can return a stale value or null for seconds after a write. Wrap the binding locally so fresh writes are hidden for a configurable window, and assert the degraded path in a test.
Where should secrets live during local development?
In a dev vars file at the project root, which the dev server reads and injects onto the env object exactly as a deployed secret appears. It is never uploaded and must be listed in your gitignore. Deployed secrets are managed separately and are not merged with the local file.
Why does my Worker exceed CPU limits in production but not locally?
No local mode applies the platform CPU or wall-clock ceiling, so an invocation that production terminates simply runs to completion on your machine. Add explicit budget checkpoints in the handler, exercise the code with production-sized payloads, and measure the real budget against a preview deployment.