How Dependency Size Impacts Cold Starts in Edge JavaScript Runtimes

GitHub profile picture of flySewa

Edge runtimes have gotten pretty good at avoiding cold starts. Between isolate reuse and smarter routing, a lot of requests never hit one at all.

But cold starts still happen. During deployments, traffic spikes, and scaling events, new instances spin up from scratch. And when they do, there's a step that runs before your handler can do anything: the runtime loads your bundle, parses it, and evaluates every module in it before it can handle a request.

So when you're trying to understand why latency spikes during those events, the question worth asking isn't just "how often do cold starts happen?" It's "how much work is happening inside each one?"

A big part of the answer lives in your dependencies.

The Experiment

To put a concrete number on this, we built a minimal Cloudflare Worker to understand the cold start cost of our own library compared to Zod, using a single User schema covering name, email, age, and role.

We built it twice using Wrangler 4.81.1 and TypeScript, keeping everything identical except for the validation library being imported. This comparison uses the default zod package rather than zod/mini, and focuses on baseline installs and primary APIs rather than optimized variants. One build used Zod and the other used Valibot. Both builds were done in production mode using Wrangler's default bundling.

Here's what came out of the bundler:

LibraryRaw BundleGzipped
Zod 4.3.6141.43 KiB26.75 KiB
Valibot 1.3.111.92 KiB2.72 KiB

Valibot's gzipped bundle is 9.8x smaller than Zod's for an identical schema.

What This Experiment Does (and Doesn't) Show

This comparison focuses on bundle size and included code, not direct cold start timing, since measuring cold start latency reliably outside of production is hard because:

  • Edge platforms reuse isolates aggressively
  • Cold starts depend on traffic patterns and scaling behavior
  • Platform-level metrics are often not exposed in detail

So instead of simulating that imperfectly, this experiment isolates one part of the problem: how much code the runtime has to load and execute during initialization. We're using bundle size as a proxy for cold start cost. In most JavaScript runtimes, more code means more parsing work, and more modules mean more evaluation work.

This applies to edge runtimes that execute ESM modules on startup, including platforms like Vercel Edge Functions and Deno Deploy.

Why the Bundle Sizes Are So Different

This is where it gets interesting, because the gap is a consequence of how each library is designed.

Zod uses a class-based, chainable API. It is easy to use and has strong TypeScript inference. But internally, many parts of the library are tightly connected. From the bundler's perspective, this looks like a coarse-grained import. Even if you use a small part of Zod, shared internals like parsing logic and error handling often get included together.

Valibot is built differently. Each validator is a small standalone function. You import exactly what you need. This is a fine-grained import model. The bundler can include only the functions you use and drop the rest.

For the schema in this experiment, the bundler included only the specific validators the schema used. That's most of what ended up in the bundle.

Here's the same User schema in both libraries:

// Zod
import * as z from 'zod';

const UserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().min(0).max(120),
  role: z.enum(['admin', 'user', 'guest']),
});
// Valibot
import * as v from 'valibot';

const UserSchema = v.object({
  name: v.pipe(v.string(), v.minLength(1)),
  email: v.pipe(v.string(), v.email()),
  age: v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(120)),
  role: v.picklist(['admin', 'user', 'guest']),
});

Using import * as v from 'valibot' here does not disable tree-shaking. With modern ESM bundlers, namespace imports like this are still statically analyzable, so only the specific v.* exports referenced by the schema are retained in the final bundle.

The schemas are functionally identical. The difference is what the bundler sees. In the Zod version, the bundler can see individual exports, but shared internals like the parsing engine, error formatting, and type utilities are tightly coupled and get pulled in regardless of how little of the API you use. In the Valibot version, each import is a discrete, standalone function. There is very little shared infrastructure to drag in, so the bundler includes only what the schema actually needs.

That's where the 9.8x comes from.

Why This Matters for Cold Starts

Up to this point, we've only looked at one dependency in isolation. In a real application, that validation library sits alongside everything else you use. Routing, authentication, database clients, logging. All of it is bundled together into a single script.

When a new edge instance starts, the runtime has to process that script before it can respond to a request. It does not jump straight into your handler. It first has to load the code, read through it, and run the modules so everything is ready. As that bundle gets larger, that setup step takes more work. There is simply more code to go through, and more modules that need to run before the instance is ready.

You can think of it as replaying your module graph on each new instance. The larger the graph, the more work the runtime has to do before it can serve anything. A 9.8x difference in bundle size means the runtime is doing materially less work on every cold start.

This shows up most clearly during deployments and traffic spikes, exactly when you can least afford extra latency. During a traffic spike, the platform scales out and starts more instances. In quieter periods, instances may not stay warm, so the next request has to go through that setup again. In all of these cases, the first request handled by a new instance waits for that initialization to finish.

A smaller bundle does not remove cold starts, but it reduces how much work happens inside each one. In edge environments, where instances are created more often, that difference is more likely to affect response time.

Does Valibot Work With the Rest of Your Stack?

A fair question before switching anything is whether you lose ecosystem coverage. The short answer is no.

Valibot works with the tools most teams are already using:

  • Standard Schema – Valibot implements the Standard Schema specification, which means it works natively with any tool that supports it without adapters or wrappers.
  • tRPC – Pass a Valibot schema directly to .input() without any wrapper.
  • React Hook Form – drop-in via @hookform/resolvers/valibot
  • Hono – supported through @hono/valibot-validator
  • Conform – official support via @conform-to/valibot

Type inference works the same way you'd expect coming from Zod:

import { InferOutput } from 'valibot';

type User = InferOutput<typeof UserSchema>;

If your existing codebase is heavily Zod, migration doesn't have to be a full rewrite. Most teams move validation schema-by-schema at the edges of their system, at API boundaries, form handlers, and environment config, where the cold start impact is most direct. If you want to speed that process up, the Zod to Valibot migration guide covers the key differences and includes a codemod to handle much of the conversion automatically.

Where Bundle Size Actually Matters

None of this means Valibot is the right choice in every situation.

If you're running long-lived services where instances stay warm for extended periods, a 9.8x bundle size difference has almost no visible impact. In those environments, Zod's more ergonomic chaining and broader ecosystem familiarity may genuinely matter more.

But if you're building for edge environments like Cloudflare Workers, Vercel Edge Functions, and Deno Deploy, cold starts are closer to the request path, and initialization overhead is more likely to show up in your p99s. In those environments, choosing a smaller, more modular library reduces the amount of work done during initialization without sacrificing type safety or expressiveness.

The broader principle holds too: validation libraries aren't the only place this happens. Any large dependency with poor tree-shaking characteristics can have a similar effect. It's worth auditing what actually ends up in your bundle, not just what you imported.

If you want to run the builds yourself or adapt the setup, the full repo is here.

Edit page

Partners

Thanks to our partners who support the project ideally and financially.

Sponsors

Thanks to our GitHub sponsors who support the project financially.

  • GitHub profile picture of @stefanmaric
  • GitHub profile picture of @vasilii-kovalev
  • GitHub profile picture of @UpwayShop
  • GitHub profile picture of @ruiaraujo012
  • GitHub profile picture of @hyunbinseo
  • GitHub profile picture of @nickytonline
  • GitHub profile picture of @kibertoad
  • GitHub profile picture of @caegdeveloper
  • GitHub profile picture of @Thanaen
  • GitHub profile picture of @bmoyroud
  • GitHub profile picture of @t-lander
  • GitHub profile picture of @dslatkin