DEV Community

Cover image for Stop Writing Polyfills. WinterCG Killed isomorphic-fetch in 2026
Gabriel Anhaia
Gabriel Anhaia

Posted on

Stop Writing Polyfills. WinterCG Killed isomorphic-fetch in 2026


You ship a small CLI to npm. It works on Bun. It works in the GitHub Action running smoke tests on Node 24. A user installs it on Node 20.10 and pastes back a stack trace pointing at a require('isomorphic-fetch') deep inside a transitive dependency nobody has touched in two years. The fix is one line. The investigation eats an afternoon.

Open that project's tsconfig.json and you find "lib": ["ES2019", "DOM"]. An ECMAScript edition from 2019, with DOM types pulled in so the editor stops complaining about fetch. In April 2026. On a codebase that ships to Node, Bun, and Cloudflare Workers.

That tsconfig is a fossil. So is every import 'isomorphic-fetch', every node-fetch dependency, every cross-fetch polyfill in a package targeting Node 18 or higher. WinterCG (now WinterTC, the Ecma TC55 working group) finished the work. The Minimum Common Web Platform API was adopted by the Ecma General Assembly in December 2025 (W3C / Ecma joint announcement). As of the 2025 snapshot, every modern JS runtime exposes the same fetch, Request, Response, Headers, URL, URLPattern, crypto.subtle, and the streams trio, plus a long tail of supporting types on globalThis.

If your code still treats fetch as something you need to import, you are paying a tax with no service attached.

The cross-runtime breakage

The failure shape depends on which runtime you start in. You write a small library in Bun:

// src/uploader.ts
export async function uploadJSON(url: string, body: unknown) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    throw new Error(`upload failed: ${res.status}`);
  }
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Tests green. You publish. A consumer on Node 20 with a frozen tsconfig from 2022 imports your package and the editor underlines fetch because their lib has "ES2019" and no "DOM". They reach for the project's existing node-fetch polyfill and import fetch from "node-fetch" at the top of the file that consumes you. The runtime value is the polyfill, not the global. The polyfill returns its own Response. instanceof Response against the global Response starts returning false. They open an issue against your library.

The reverse is worse. You build a library targeting Node 18, import node-fetch defensively, ship. Someone runs it inside a Cloudflare Worker. node-fetch reaches for node:http, which Workers does not expose at older compatibility dates. The Worker crashes at import time. The consumer files an issue saying your library does not work in Workers. You added the polyfill specifically so it would.

Both bugs are manufactured. Both go away the moment everyone agrees fetch is on globalThis and stops importing anything to get it. WinterCG did that work. The question is whether your project caught up.

What the WinterCG Minimum Common API actually contains in 2026

The Minimum Common Web Platform API defines a curated subset of W3C and WHATWG interfaces that every conforming runtime exposes on globalThis. The first edition tracks the 2025 snapshot and was adopted as an Ecma standard in December 2025. The current editor's draft as of writing is what runtimes are aligning to today.

The list is longer than most people realize. Grouped by what you actually use:

  • Fetch and HTTP: fetch, Request, Response, Headers, FormData
  • URL: URL, URLSearchParams, URLPattern
  • Streams: ReadableStream, WritableStream, TransformStream, plus controllers and queuing strategies
  • Encoding: TextEncoder, TextDecoder, the streaming variants, CompressionStream, DecompressionStream
  • Crypto: crypto, crypto.subtle, Crypto, SubtleCrypto, CryptoKey
  • Binary: Blob, File
  • Events: Event, EventTarget, CustomEvent, MessageEvent, MessageChannel, MessagePort, AbortController, AbortSignal
  • Timers: setTimeout, setInterval, clearTimeout, clearInterval, queueMicrotask
  • Utilities: structuredClone, atob, btoa, performance, console, DOMException, reportError
  • WebAssembly: the full namespace including streaming compile and instantiate

Conforming runtimes implement most of those to spec. The list as of April 2026: Node.js 24+ (fetch graduated from experimental in Node 21 on Undici 5.x and is fully supported in 24 with Undici 7), Deno 2.x (Web Crypto, Web Streams, URLPattern, the rest), Bun 1.x (all of it natively, with a Zig-based fetch), and the major edge runtimes (Cloudflare Workers, Vercel Edge, Netlify Edge, Fastly Compute) alongside the others tracked in the runtime-keys registry.

Write code against the Minimum Common API and you can run it on any of those without a polyfill, without a runtime check, without an if (typeof window !== "undefined") ladder. Cross-runtime support is no longer a portability problem. It is a tsconfig problem.

The stale tsconfig anti-pattern

Open the tsconfig of a project that has not been touched since 2022 and you will recognize it on sight.

{
  "compilerOptions": {
    "target": "ES2019",
    "module": "CommonJS",
    "lib": ["ES2019", "DOM"],
    "types": ["node-fetch", "@types/node"],
    "esModuleInterop": true,
    "skipLibCheck": true,
    "strict": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Five problems, and they compound.

"target": "ES2019" downlevels async iterators, optional chaining, and the newer Array, Object, and Promise methods. Every modern runtime understands ES2024, including Object.groupBy, Map.groupBy, Promise.withResolvers, and the resizable ArrayBuffer story. You emit transpiled code for runtimes that would prefer the original.

"module": "CommonJS" wraps every top-level await, turns dynamic imports into Promise.resolve().then(() => require(...)), and inflates bundle size for no reason. Node 24, Bun, and Deno are happiest with native ESM. Workers requires it.

"lib": ["ES2019", "DOM"] is the classic. The DOM lib was added so the editor would type-check fetch, but it also adds window, document, localStorage, navigator.geolocation, and a thousand globals that do not exist in any server runtime. You get the wrong autocomplete in 80% of your files.

"types": ["node-fetch", "@types/node"] is where the bill comes due. The node-fetch types ship a Response and a RequestInit whose shapes drift from the WHATWG spec. When some files import the global and others import from node-fetch, instanceof checks lie, the headers object disagrees on iteration order, and type errors are unfixable without ripping one out.

The fifth problem is what is missing. No WebWorker lib. No targeted runtime declaration. The setup leaks DOM types into a file that runs in a Worker, and the Worker laughs at document at runtime.

The fix is a six-line edit to one file.

cream-and-burnt-orange editorial illustration of crossed-out polyfill packages with one orange fetch arrow flowing through four runtime silhouettes

The clean cross-runtime tsconfig pattern

For a library or service targeting Node 24+, Bun 1.x, Deno 2.x, and Cloudflare Workers (compatibility date 2025-09-01 or later), the baseline is short.

{
  "compilerOptions": {
    "target": "ES2024",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2024", "WebWorker"],
    "types": [],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "esModuleInterop": false
  }
}
Enter fullscreen mode Exit fullscreen mode

The two lines doing the heavy lifting are "lib": ["ES2024", "WebWorker"] and "types": [].

WebWorker is the closest thing TypeScript ships to a "WinterCG-aligned environment" lib. It declares fetch, Request, Response, Headers, URL, crypto.subtle, the streams trio, TextEncoder, TextDecoder, Blob, File, FormData, AbortController, EventTarget, MessagePort, structuredClone, and the rest of the Minimum Common API surface — without the DOM globals. The editor stops underlining fetch. It also stops autocompleting document, which is the behavior you want.

Empty "types": [] tells TypeScript not to auto-include every @types/* package that happens to be installed. You opt in per file, per build target, or per dependency. A single transitive dev-dependency on @types/node would otherwise inject Node-specific type definitions into every file in your project, including the ones that compile to a Worker bundle. Empty types makes inclusion explicit.

For a Node-only entry point in the same monorepo, override in a project reference:

{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2024", "WebWorker"],
    "types": ["node"]
  },
  "include": ["src/node-cli.ts"]
}
Enter fullscreen mode Exit fullscreen mode

That file gets node:fs and process types because it asked. The library code does not. When someone imports node:fs from a file that should run in a Worker, the build fails before the deploy.

The library code becomes shorter. The full networking layer of a fetch-based SDK in 2026:

// src/client.ts
export interface ClientOptions {
  baseUrl: string;
  token?: string;
  fetchImpl?: typeof fetch;
}

export class Client {
  readonly #baseUrl: URL;
  readonly #token?: string;
  readonly #fetch: typeof fetch;

  constructor(opts: ClientOptions) {
    this.#baseUrl = new URL(opts.baseUrl);
    this.#token = opts.token;
    this.#fetch = opts.fetchImpl ?? fetch;
  }

  async post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
    const url = new URL(path, this.#baseUrl);
    const headers = new Headers({ "content-type": "application/json" });
    if (this.#token) {
      headers.set("authorization", `Bearer ${this.#token}`);
    }
    const res = await this.#fetch(url, {
      method: "POST",
      headers,
      body: JSON.stringify(body),
      signal,
    });
    if (!res.ok) {
      throw new HTTPError(res.status, await res.text());
    }
    return res.json() as Promise<T>;
  }
}

export class HTTPError extends Error {
  constructor(public status: number, public body: string) {
    super(`HTTP ${status}: ${body.slice(0, 200)}`);
    this.name = "HTTPError";
  }
}
Enter fullscreen mode Exit fullscreen mode

No imports beyond what the file declares. fetch, URL, Headers, AbortSignal, Response resolve to their global types. The optional fetchImpl parameter exists so tests can hand in a stub. It is not there to paper over a missing global. Omit it and you get the runtime's native fetch on Node 24, Bun, Deno, and Workers.

A test, identical in any of the four runtimes:

import { Client, HTTPError } from "./client.js";

const c = new Client({
  baseUrl: "https://example.test",
  fetchImpl: async (input, init) => {
    const url = input instanceof URL ? input : new URL(String(input));
    if (url.pathname === "/orders" && init?.method === "POST") {
      return new Response(JSON.stringify({ id: "ord_1" }), {
        status: 201,
        headers: { "content-type": "application/json" },
      });
    }
    return new Response("not found", { status: 404 });
  },
});

const order = await c.post<{ id: string }>("/orders", { items: [] });
if (order.id !== "ord_1") {
  throw new Error("test failed");
}
Enter fullscreen mode Exit fullscreen mode

The mock is a function that returns a real Response. No nock, no msw boot, no jest.mock("node-fetch"). The same test file runs under node --test, bun test, deno test, and vitest (with @cloudflare/vitest-pool-workers for the Workers runtime) unchanged.

The library-author angle

If you publish to npm or JSR, this changes "supporting all the runtimes" from a permanent project into a one-time tsconfig.

The old playbook had four moving parts. Write code, import cross-fetch or isomorphic-fetch to make fetch exist on Node, re-export a Response shim, maintain two build outputs (CJS and ESM). Each part had its own bug surface. The polyfill drift was the worst: the global Response.body is a ReadableStream, but node-fetch's body is a Node Readable, and code that worked on one would silently misbehave on the other.

The new playbook is one part. Write to the Minimum Common API. Ship one ESM build. Done.

A package.json for a 2026 cross-runtime library:

{
  "name": "@example/api-client",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "engines": { "node": ">=20.0.0" },
  "files": ["dist"],
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "test:node": "node --test",
    "test:bun": "bun test",
    "test:deno": "deno test --allow-net",
    "test:workers": "vitest run"
  },
  "devDependencies": {
    "typescript": "^5.7.0",
    "@cloudflare/workers-types": "^4.20260301.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

No node-fetch. No @types/node-fetch. CI runs the same test suite against four runtimes and it passes because the platform underneath does. engines.node of >=20.0.0 is the lower bound where global fetch was reliably present (experimental in 18, stable in 21, fully supported in 24).

JSR users get less ceremony. JSR assumes Web Platform APIs and runs anywhere. Publish a TypeScript file that imports nothing, JSR ships the source, and Deno, Bun, and Node consumers all import the same module.

Where it still does not work

The honesty section. The Minimum Common API gives you a floor. It does not promise everything you might want.

Node-only APIs. node:fs, node:child_process, node:net, node:os. If your code reaches for fs.readFile, you are out of WinterCG territory. Cloudflare Workers has narrowed the gap with nodejs_compat, which has rolled out additional Node module surface area through a series of compatibility-date flags: HTTP client and server methods first, then dgram, inspector, timers, and more recent additions like readline, child_process, perf_hooks, repl, and tty. The current matrix lives at developers.cloudflare.com/workers/configuration/compatibility-flags. But "Cloudflare exposes a polyfill" is different from "the API is part of the Minimum Common API." Code that depends on Node-specific behavior still needs a runtime branch.

Workers-only APIs. caches, KV, Durable Objects, R2, Queues. Cloudflare-only. Inject them through the constructor. Same pattern for Vercel's waitUntil and the Deno-only Deno.serve namespace.

Streams edge cases. ReadableStream is mandated, but the BYOB reader story has had small disagreements, and stream-locking edge cases have shifted between runtime versions. Heavy stream manipulation needs per-runtime tests.

Web Crypto subtleties. importKey takes the same arguments everywhere, but runtimes differ on which key formats they accept for which algorithms. AES-GCM works identically. Ed25519 and X25519 took longer to reach parity. Anything past hashing and HMAC needs a test matrix.

TypeScript types. The DOM and WebWorker libs approximate WinterCG; they do not match it. There are corners where the editor lets you write something the runtime does not implement. Integration tests catch what the type-check misses.

The picture is not "everything is fine everywhere." It is "the bottom 80% of what libraries need is identical, the next 15% has a clear story, and the last 5% is where you write a runtime branch with intent." Better deal than "every dependency might disagree about what Response is."

Forward motion

Next time you start a TypeScript project: library, service, or CLI, open tsconfig.json first and put "target": "ES2024", "lib": ["ES2024", "WebWorker"], "types": []. Add Node types only in the file that talks to the filesystem. Add Workers types only in the file that handles a fetch event. Do not install node-fetch, cross-fetch, or isomorphic-fetch. They were a workaround for a problem the runtime layer fixed.

When you open a project that already exists, grep the dependency tree for those three. If they show up, find what is using them and replace the import with the global. The diff is usually negative: fewer lines, fewer dependencies, smaller bundle. The bug surface goes with it.

If a user files an issue saying your library "doesn't work on Bun" or "crashes in Workers," check the imports before the code. The chance the bug is in a polyfill that should not be there in 2026 is higher than the chance it is in your logic.

WinterCG, now WinterTC, did the boring work of making the runtimes agree. What is left is for libraries to stop apologizing for a portability problem that has been solved for two years.

Your tsconfig is the last place the old world is alive. Edit it.


If this was useful

TypeScript Essentials is the entry point of The TypeScript Library, a 5-book collection that covers the language from "I write some TS at work" to "I ship libraries that run on every JS runtime." This post is condensed from the runtime and tooling chapters of the first book and the build chapter of the fifth. Start with Essentials for the language, jump to Type System for the deep-dive, take the Kotlin/Java or PHP bridge if those are your home languages, finish with In Production when you are shipping libraries.

The full collection (The TypeScript Library) is five books that share a vocabulary. Books 1 and 2 are the core path. Books 3 and 4 are bridges if your team comes from the JVM or PHP world. Book 5 is for whoever is owning the build, the monorepo, and the ESM/CJS dual-publish problem.

The TypeScript Library — the 5-book collection

  • TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling: Amazon
  • The TypeScript Type System — deep dive. Generics, mapped/conditional types, infer, template literals, branded types: Amazon
  • Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines-to-async/await: Amazon
  • PHP to TypeScript — bridge for modern PHP 8+ developers. Sync-to-async paradigm, generics, discriminated unions: Amazon
  • TypeScript in Production — production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR: Amazon
  • Hermes IDE — an IDE for developers who ship with Claude Code and other AI coding tools: hermes-ide.com
  • Me: xgabriel.com | GitHub

Top comments (0)