<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Waseem Anjum]]></title><description><![CDATA[Full Stack & AI Automation Engineer. I build AI agents, MCP servers and SaaS with Next.js, NestJS, Python & TypeScript. Lahore, PK · fabwaseem.com]]></description><link>https://fabwaseem.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Fri, 23 Oct 2026 10:02:43 GMT</lastBuildDate><atom:link href="https://fabwaseem.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[NestJS SSE in Practice: Streaming Job Progress with @Sse and RxJS]]></title><description><![CDATA[ReplaySubject per job, your own event ids, comment heartbeats and the six-stream browser limit, tested on NestJS 12.1.3]]></description><link>https://fabwaseem.hashnode.dev/nestjs-sse-job-progress</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/nestjs-sse-job-progress</guid><category><![CDATA[Node.js]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[RxJS]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[nestjs]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Mon, 19 Oct 2026 05:50:28 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/521d5f9d-0003-42f2-b548-cf03e2439393.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Progress bars are where "real-time" usually starts in a backend. A user kicks off something slow, such as a download, an export or an AI job, and wants to watch it finish. In NestJS the cleanest tool for that is Server-Sent Events: one <code>@Sse()</code> route, one RxJS <code>Observable</code>, and the browser's <code>EventSource</code> on the other end.</p>
<p>I run this in production in <a href="https://github.com/fabwaseem/snagbox">Snagbox</a>, my open-source NestJS downloader API. For this post I rebuilt its SSE layer in a small test app on NestJS 12.1.3 and Node.js 22.20, drove it from headless Chrome 155, and measured what each design choice actually does. Below are the patterns I'd keep, the ones I changed, and the numbers behind them.</p>
<h2>TL;DR: NestJS SSE in production</h2>
<p>Return an <code>Observable&lt;MessageEvent&gt;</code> from an <code>@Sse()</code> route. Then:</p>
<ul>
<li>Give each job its own <code>ReplaySubject(1)</code>, so a client that connects mid-job gets the latest progress at once. In my test that took 2 to 4 ms, against 451 to 1,002 ms with a plain <code>Subject</code>.</li>
<li>Set <code>id</code> on every message yourself. Nest numbers messages per connection, so after a reconnect the ids start at 1 again and <code>Last-Event-ID</code> is useless.</li>
<li>Close the <code>EventSource</code> on the client when the job finishes. When your Observable completes, the browser treats it as a dropped connection and reconnects, and it replayed my stream three times in 1.6 seconds.</li>
<li>Send a heartbeat as a comment frame (<code>{ comment: "ping" }</code>). The client never sees it, and Nest gives it no id.</li>
<li>Don't open one stream per item in a batch. Over HTTP/1.1, Chrome opened 6 of 8 streams to my server, and an ordinary <code>fetch()</code> to the same host was still waiting 5 seconds later. Poll one batch endpoint instead, or serve over HTTP/2.</li>
</ul>
<table>
<thead>
<tr>
<th>Result (measured on the box)</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>One 10 s job, 40 updates, SSE</td>
<td>1 request, 40 of 40 updates, 4,516 bytes</td>
</tr>
<tr>
<td>Same job, polling every 1 s</td>
<td>11 requests, 10 of 40 updates, 4,438 bytes</td>
</tr>
<tr>
<td>Same job, polling every 250 ms</td>
<td>41 requests, 40 of 40 updates, 16,506 bytes</td>
</tr>
<tr>
<td>SSE delivery lag on localhost</td>
<td>0.8 ms mean, 2 ms max</td>
</tr>
<tr>
<td>Late joiner, <code>ReplaySubject(1)</code> vs <code>Subject</code></td>
<td>2 to 4 ms vs 451 to 1,002 ms to first frame</td>
</tr>
<tr>
<td>Server memory per open stream</td>
<td>about 34 to 64 KB of RSS (5,000 and 1,000 streams)</td>
</tr>
<tr>
<td>Chrome 155, HTTP/1.1, 8 <code>EventSource</code>s</td>
<td>6 opened, the 7th, 8th and a <code>fetch()</code> stalled</td>
</tr>
</tbody></table>
<h2>What are Server-Sent Events?</h2>
<p>SSE is a plain HTTP response with <code>Content-Type: text/event-stream</code> that never ends until one side closes it. The server writes UTF-8 frames separated by a blank line. Each frame can carry <code>data:</code>, an <code>event:</code> name, an <code>id:</code> and a <code>retry:</code> delay, and a line starting with <code>:</code> is a comment. The format and the reconnection rules are in the <a href="https://html.spec.whatwg.org/multipage/server-sent-events.html">WHATWG HTML spec</a>.</p>
<p>In the browser, <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource"><code>EventSource</code></a> does all the client work. It parses frames, fires events, remembers the last id, and reconnects after a dropped connection, sending that id back in a <code>Last-Event-ID</code> header.</p>
<p>SSE only goes from the server to the client. For job progress, notifications, build logs and streamed LLM tokens, that is all you need. The client can still send commands through normal POST requests.</p>
<h2>SSE vs WebSockets vs polling</h2>
<table>
<thead>
<tr>
<th></th>
<th>SSE</th>
<th>WebSockets</th>
<th>Polling</th>
</tr>
</thead>
<tbody><tr>
<td>Direction</td>
<td>Server to client</td>
<td>Both ways</td>
<td>Client asks</td>
</tr>
<tr>
<td>Protocol</td>
<td>Plain HTTP</td>
<td>Upgrade to <code>ws://</code></td>
<td>Plain HTTP</td>
</tr>
<tr>
<td>Reconnects</td>
<td>Built into <code>EventSource</code></td>
<td>You write it</td>
<td>Not needed</td>
</tr>
<tr>
<td>Works through HTTP proxies and CDNs</td>
<td>Usually, if buffering is off</td>
<td>Needs upgrade support</td>
<td>Yes</td>
</tr>
<tr>
<td>Custom request headers from the browser</td>
<td>No</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Nest setup</td>
<td><code>@Sse()</code> in a controller</td>
<td>A gateway and an adapter</td>
<td>A <code>@Get()</code> route</td>
</tr>
</tbody></table>
<p>Use WebSockets when the client also streams data back at a high rate, for example in chat or collaborative editing. Use polling when updates are rare or when you track many items at once (more on that below). For one long job and one watcher, SSE is the best fit.</p>
<h2>How to use @Sse in NestJS</h2>
<p>The smallest working endpoint returns an Observable. Nest writes every emitted value as a frame and ends the response when the Observable completes.</p>
<pre><code class="language-ts">import { Controller, MessageEvent, Sse } from "@nestjs/common";
import { Observable, interval, map, take } from "rxjs";

@Controller("demo")
export class DemoController {
  @Sse("ticks")
  ticks(): Observable&lt;MessageEvent&gt; {
    return interval(1000).pipe(
      take(5),
      map((i) =&gt; ({ data: { tick: i } })),
    );
  }
}
</code></pre>
<p>Objects in <code>data</code> are serialized as JSON. The <code>MessageEvent</code> interface in <code>@nestjs/common</code> 12.1.3 has five optional fields: <code>data</code>, <code>id</code>, <code>type</code>, <code>retry</code> and <code>comment</code>. The full list of features is in the <a href="https://docs.nestjs.com/techniques/server-sent-events">NestJS SSE docs</a>.</p>
<p>I checked the response headers with <code>curl -D -</code>. Nest 12.1.3 sends <code>Content-Type: text/event-stream</code>, <code>Cache-Control: private, no-cache, no-store, must-revalidate, max-age=0, no-transform</code>, <code>Connection: keep-alive</code> and <code>X-Accel-Buffering: no</code>. The last one tells Nginx not to buffer the stream, which is the most common reason SSE "works locally but not in production". You don't have to set it yourself.</p>
<h2>Streaming job progress: one ReplaySubject per job</h2>
<p>A real job runs in a worker, not in the controller. The worker needs somewhere to publish progress, and the SSE route needs somewhere to subscribe. In Snagbox that is a small service holding one RxJS subject per job (<a href="https://github.com/fabwaseem/snagbox/blob/main/src/downloads/job-events.service.ts">source</a>). This is a trimmed version:</p>
<pre><code class="language-ts">import { Injectable } from "@nestjs/common";
import { Observable, ReplaySubject } from "rxjs";

export type JobEvent =
  | { type: "progress"; seq: number; percentage: number }
  | { type: "complete"; seq: number; url: string }
  | { type: "error"; seq: number; message: string };

@Injectable()
export class JobEventsService {
  private readonly channels = new Map&lt;string, ReplaySubject&lt;JobEvent&gt;&gt;();

  private channel(jobId: string): ReplaySubject&lt;JobEvent&gt; {
    let subject = this.channels.get(jobId);
    if (!subject) {
      // Buffer of 1: a client that joins mid-job gets the latest frame at once.
      subject = new ReplaySubject&lt;JobEvent&gt;(1);
      this.channels.set(jobId, subject);
    }
    return subject;
  }

  emit(jobId: string, event: JobEvent): void {
    this.channel(jobId).next(event);
  }

  finish(jobId: string, event: JobEvent): void {
    const subject = this.channel(jobId);
    subject.next(event);
    subject.complete();
    // Late subscribers still get the terminal frame for a while, then we free it.
    setTimeout(() =&gt; this.channels.delete(jobId), 5 * 60_000).unref();
  }

  stream(jobId: string): Observable&lt;JobEvent&gt; {
    return this.channel(jobId).asObservable();
  }
}
</code></pre>
<p>Why <code>ReplaySubject(1)</code> and not <code>Subject</code>? Users don't open the progress page at the exact moment a job starts. They refresh, switch tabs, or open it from a notification. A plain <code>Subject</code> only delivers future values, so a late client sees an empty progress bar until the next update arrives.</p>
<p>I measured this. A job emitted progress every 2 seconds, and a client connected between two updates, 5 seconds in, five times. With <code>ReplaySubject(1)</code> the first frame arrived in 2 to 4 ms. With <code>Subject</code> it took 451 to 1,002 ms, which is simply the wait for the next emit. With slower jobs, such as a big download that reports every few seconds, that wait gets longer.</p>
<p>Keep the buffer at 1. Progress is a state, not a log, so the latest frame is all a new client needs, and a bigger buffer only replays stale percentages.</p>
<p>This map lives in one process. If you run several API instances, the worker's events have to reach whichever instance holds the client's connection. Put a broker in between, such as Redis pub/sub, and feed each instance's subjects from it.</p>
<h2>The production SSE controller</h2>
<p>Here is the route, close to Snagbox's <a href="https://github.com/fabwaseem/snagbox/blob/main/src/downloads/downloads.controller.ts">downloads controller</a> but with three changes I made after testing:</p>
<pre><code class="language-ts">import { Controller, MessageEvent, NotFoundException, Param, Sse } from "@nestjs/common";
import { Observable, endWith, ignoreElements, interval, map, merge, of, share, takeUntil } from "rxjs";
import { JobEventsService, type JobEvent } from "./job-events.service.js";
import { JobStore } from "./job-store.js";

const HEARTBEAT_MS = 15_000;
const toMessage = (event: JobEvent): MessageEvent =&gt; ({ id: String(event.seq), data: event });

@Controller("jobs")
export class JobsController {
  constructor(
    private readonly events: JobEventsService,
    private readonly store: JobStore,
  ) {}

  @Sse(":id/events")
  stream(@Param("id") id: string): Observable&lt;MessageEvent&gt; {
    const job = this.store.get(id);
    if (!job) throw new NotFoundException(`No job ${id}`);

    // Finished before the client connected: send the terminal frame and stop.
    if (job.last &amp;&amp; job.last.type !== "progress") return of(toMessage(job.last));

    const events$ = this.events.stream(id).pipe(map(toMessage), share());
    const done$ = events$.pipe(ignoreElements(), endWith(null));
    // A comment frame (": ping") keeps proxies from reaping the idle socket.
    const heartbeat$ = interval(HEARTBEAT_MS).pipe(
      map((): MessageEvent =&gt; ({ comment: "ping" })),
      takeUntil(done$),
    );
    return merge(events$, heartbeat$);
  }
}
</code></pre>
<p><code>JobStore</code> is wherever you keep job state. Snagbox keeps its download records in Postgres through Prisma. I ran this exact controller and checked each branch with curl:</p>
<ul>
<li>An unknown id returns a normal <code>404</code> JSON response. Throwing before you return the Observable works, because Nest hasn't sent the stream headers yet. Under the spec, a non-200 response makes <code>EventSource</code> give up rather than retry, which is what you want for a job that doesn't exist.</li>
<li>A finished job returns one <code>complete</code> frame and ends.</li>
<li>A running job streams frames with <code>id: 6</code>, <code>id: 7</code> and so on, matching the job's own sequence number, with <code>: ping</code> comments in between.</li>
</ul>
<h3>Why set the id yourself</h3>
<p>If a message has no <code>id</code>, Nest 12.1.3 adds one: a counter that starts at 1 on each connection. I tested what that means for reconnects in headless Chrome. The browser reconnected and sent <code>Last-Event-ID: 3</code>, and the new connection numbered its frames from 1 again. The header is there, but it no longer means anything.</p>
<p>Using the job's sequence number as the id fixes that. On reconnect, read the header with <code>@Headers("last-event-id")</code> and skip events the client has already seen. For progress, the <code>ReplaySubject</code> already sends the latest state, so you can often ignore the header and still be correct.</p>
<h3>The heartbeat: a comment, not a data frame</h3>
<p>Snagbox merges a heartbeat into the stream every 15 seconds. The comment in its source says why: proxies reap idle connections, and the Next.js rewrite proxy in front of the API is one of them. <code>takeUntil(done$)</code> stops the timer when the job ends, so the heartbeat never holds a finished stream open.</p>
<p>Snagbox sends the heartbeat as <code>data: {"type":"ping"}</code> and every client has to ignore it. On Nest 12.1.3 you can return <code>{ comment: "ping" }</code> instead. Nest writes it as <code>: ping</code>, gives it no id, and <code>EventSource</code> doesn't fire any event for it. I confirmed both in the curl output and in Chrome.</p>
<p>The <code>share()</code> matters too. Both <code>merge</code> and <code>done$</code> subscribe to <code>events$</code>, and <code>share()</code> makes them use one subscription to the job channel instead of two.</p>
<h2>The client: close the EventSource when the job ends</h2>
<p>This is the mistake I'd most expect to reach production. When the job finishes, the Observable completes and Nest ends the response. To <code>EventSource</code>, an ended response is a dropped connection, so it waits for the retry delay and connects again.</p>
<p>I tested this with an endpoint that sent three events and completed, with <code>retry: 300</code>. In 1.6 seconds Chrome received the same three events three times, plus the first event of a fourth run. For a progress bar that means flicker and wasted requests. For "show a toast when the download is ready", it means the toast fires again and again.</p>
<p>The fix is on the client: treat the terminal event as the end.</p>
<pre><code class="language-ts">type JobEvent =
  | { type: "progress"; seq: number; percentage: number }
  | { type: "complete"; seq: number; url: string }
  | { type: "error"; seq: number; message: string };

export function watchJob(jobId: string, onEvent: (event: JobEvent) =&gt; void) {
  const source = new EventSource(`/api/jobs/${jobId}/events`, { withCredentials: true });

  source.onmessage = (message) =&gt; {
    const event = JSON.parse(message.data) as JobEvent;
    onEvent(event);
    if (event.type === "complete" || event.type === "error") source.close();
  };

  // Network blips are retried by the browser. A 404 or 401 closes the stream.
  source.onerror = () =&gt; {
    if (source.readyState === EventSource.CLOSED) onEvent({ type: "error", seq: -1, message: "stream closed" });
  };

  return () =&gt; source.close();
}
</code></pre>
<p>Two more things I checked in Chrome:</p>
<ul>
<li><strong>Named events skip <code>onmessage</code>.</strong> If you set <code>type: "progress"</code> on the Nest <code>MessageEvent</code>, the frame goes out as <code>event: progress</code>, and only <code>addEventListener("progress", ...)</code> receives it. In my test, <code>onmessage</code> got the unnamed frame and missed the named one. Either put the type inside <code>data</code>, as above, or add a listener for every event name you send.</li>
<li><strong><code>EventSource</code> can't send custom headers.</strong> The constructor takes a URL and a <code>withCredentials</code> flag, nothing else. If your API uses an <code>X-API-Key</code> or a bearer token, use a cookie session for the browser, a short-lived signed token in the query string, or <code>fetch()</code> with a streamed response body and your own frame parser.</li>
</ul>
<h2>Don't open one stream per item: the six-connection limit</h2>
<p>Over HTTP/1.1, browsers allow only six connections per host, and each open <code>EventSource</code> holds one for as long as it lives. Snagbox's controller has a comment about this: batch downloads poll <code>GET /v2/downloads?ids=...</code> instead of opening one stream per job, because "a 50-item batch would stall on its own event streams".</p>
<p>I measured how bad it is. A page in headless Chrome 155 opened 8 <code>EventSource</code>s to my local Nest server over plain HTTP/1.1. Six opened within 5 ms. The other two never opened during the 3-second window. Then the page made an ordinary <code>fetch()</code> to the same server, and it was still pending after 5 seconds. Six progress streams can freeze the rest of your app's API calls on that host.</p>
<p>You have three ways out:</p>
<ol>
<li><strong>Serve over HTTP/2.</strong> All requests share one connection, and the limit becomes the number of concurrent streams the server allows. <a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">MDN's EventSource page</a> notes that this defaults to 100. If a proxy or CDN in front of your API already speaks HTTP/2 to the browser, you may have this for free.</li>
<li><strong>Multiplex.</strong> Open one stream per user or per batch and include the job id in every event.</li>
<li><strong>Poll a batch endpoint for many items.</strong> This is what Snagbox does for playlists:</li>
</ol>
<pre><code class="language-ts">@Get()
getMany(@Query("ids") ids = "") {
  return ids
    .split(",")
    .map((id) =&gt; id.trim())
    .filter(Boolean)
    .slice(0, 100)
    .map((id) =&gt; this.store.get(id) ?? { id, status: "unknown" });
}
</code></pre>
<p>One request every second or two for a 50-item batch costs far less than 50 streams, and it can't block anything.</p>
<h2>SSE vs polling: what one job costs</h2>
<p>I ran one simulated job that lasted 10 seconds and emitted 40 progress updates, one every 250 ms. A Node.js client watched it three ways, and I counted the bytes from the socket itself (<code>socket.bytesRead</code> and <code>socket.bytesWritten</code>), so HTTP headers are included. The client and server ran on the same box, over localhost, with the Express adapter.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792387499/fabwaseem/blog/nestjs-server-sent-events/diagram-sse-vs-polling.jpg" alt="Bar chart of bytes on the wire for one 10-second job with 40 updates: SSE 4,516 bytes in 1 request, polling every 1 s 4,438 bytes in 11 requests, polling every 250 ms 16,506 bytes in 41 requests" /></p>
<table>
<thead>
<tr>
<th>Transport</th>
<th>Requests</th>
<th>Updates seen</th>
<th>Bytes down</th>
<th>Bytes up</th>
<th>Total</th>
</tr>
</thead>
<tbody><tr>
<td>SSE, one stream</td>
<td>1</td>
<td>40 of 40</td>
<td>4,437</td>
<td>79</td>
<td>4,516</td>
</tr>
<tr>
<td>Polling every 1 s</td>
<td>11</td>
<td>10 of 40</td>
<td>3,525</td>
<td>913</td>
<td>4,438</td>
</tr>
<tr>
<td>Polling every 250 ms</td>
<td>41</td>
<td>40 of 40</td>
<td>13,144</td>
<td>3,362</td>
<td>16,506</td>
</tr>
</tbody></table>
<p>Polling once a second used about the same bytes as SSE but showed only a quarter of the updates, so the bar moves in jumps and can show a value up to a second old. Polling fast enough to see every update took 41 requests and 3.7 times the bytes. SSE delivered each update 0.8 ms after the server emitted it on average, with a 2 ms maximum.</p>
<p>Each SSE update cost about 110 bytes, which is mostly the JSON. Each polled response carried around 320 bytes, because every one repeats the HTTP headers. With API-key auth, a rate limiter and logging on every request, the request count matters more than the bytes.</p>
<h2>How much memory does an open SSE stream use?</h2>
<p>An open stream is a socket, a response object and an RxJS subscription. I opened 1,000 and then 5,000 more concurrent streams against the same job and read the server's resident memory from <code>/proc/&lt;pid&gt;/status</code>. The client ran in a separate process.</p>
<table>
<thead>
<tr>
<th>Open streams</th>
<th>Server RSS before</th>
<th>RSS after</th>
<th>Per stream</th>
</tr>
</thead>
<tbody><tr>
<td>1,000</td>
<td>75.8 MB</td>
<td>138.0 MB</td>
<td>about 64 KB</td>
</tr>
<tr>
<td>5,000</td>
<td>138.7 MB</td>
<td>305.8 MB</td>
<td>about 34 KB</td>
</tr>
</tbody></table>
<p>The per-stream figure fell at the larger count, probably because the first batch also paid one-off costs as the heap grew (RSS didn't shrink between the runs). Treat it as a rough range for Nest on Express and Node 22, not a constant. It does show that one instance can hold thousands of idle progress streams. Your file-descriptor limit (<code>ulimit -n</code>) and proxy timeouts are more likely to stop you first.</p>
<p>I also counted live subscriptions in the service. With 5,000 streams open, the count was 5,000. 1.5 seconds after the clients disconnected, it was 0. Nest unsubscribes from your Observable when the client goes away, so the RxJS teardown runs and the subject doesn't leak subscribers.</p>
<p>If your handler does async setup before it returns the Observable, such as opening a database cursor or an LLM session, Nest 12.1.3 also has an <code>@SseSignal()</code> parameter decorator. It injects an <code>AbortSignal</code> that fires when the stream ends, so you can release setup resources even if the client left before the Observable existed.</p>
<h2>A smooth progress bar: never let it go backwards</h2>
<p>One Snagbox detail isn't about SSE but shows up in every progress UI. Snagbox runs yt-dlp, which downloads video and audio as two separate passes and reports each from 0 to 100%. Forwarded as-is, the bar fills, snaps back to zero and fills again.</p>
<p>Snagbox's <a href="https://github.com/fabwaseem/snagbox/blob/main/src/engine/progress.ts"><code>ProgressAggregator</code></a> fixes this. When downloaded bytes drop, it banks the finished pass. It keeps the percentage monotonic and caps it at 99.9 while the process is alive, because the last pass finishing isn't the job finishing, since the merge still has to run. Only the <code>complete</code> event reports 100%. If your job has more than one phase, do the same before anything reaches the stream.</p>
<h2>How I tested</h2>
<ul>
<li>Box: Linux, 8 vCPUs, shared with other workloads. Node.js 22.20.0, <code>@nestjs/core</code> 12.1.3 on <code>@nestjs/platform-express</code> 12.1.3, RxJS 7.8.2, TypeScript 5.9, compiled to ESM.</li>
<li>Jobs were simulated with <code>setInterval</code>, so the tests measure the transport and Nest, not a real download.</li>
<li>Bytes came from the client socket's counters, with keep-alive for the polling client.</li>
<li>The late-joiner test ran five times per subject type, joining 5.0 to 5.5 s into a job that emitted every 2 s.</li>
<li>Browser tests used <code>puppeteer-core</code> driving Chrome 155 headless, against <code>http://127.0.0.1</code> over HTTP/1.1.</li>
<li>Memory is VmRSS of the Nest process, read before opening streams and 4 seconds after the last one opened.</li>
</ul>
<p>These are single-machine, localhost numbers. Over a real network, polling gets worse, because each request pays the round trip, while SSE pays it once.</p>
<h2>Common mistakes with NestJS SSE</h2>
<ul>
<li><strong>Not closing the <code>EventSource</code> on the terminal event.</strong> The browser reconnects and replays the job.</li>
<li><strong>Relying on Nest's automatic ids.</strong> They restart at 1 on every connection. Use your own sequence.</li>
<li><strong>Sending <code>type</code> on the <code>MessageEvent</code> and listening with <code>onmessage</code>.</strong> Named events only reach <code>addEventListener</code>.</li>
<li><strong>Using a plain <code>Subject</code>.</strong> Late clients wait for the next update. Use <code>ReplaySubject(1)</code>.</li>
<li><strong>One stream per list item over HTTP/1.1.</strong> Six streams block the host. Poll a batch endpoint or use HTTP/2.</li>
<li><strong>No heartbeat behind a proxy.</strong> Idle streams get cut. Send a comment frame every 15 to 30 seconds.</li>
<li><strong>Putting the API key in a header the browser can't send.</strong> <code>EventSource</code> has no headers option.</li>
<li><strong>Keeping job channels forever.</strong> Delete them a few minutes after the terminal event, as Snagbox does.</li>
</ul>
<p>For the queue side of long-running work, read <a href="https://www.fabwaseem.com/blog/ai-automation-pipeline-nodejs-bullmq">Building an AI automation pipeline in Node.js with BullMQ</a>. If you're on an older Nest version, <a href="https://www.fabwaseem.com/blog/nestjs-12">the NestJS 12 migration guide</a> covers the upgrade. For a secure API base to put this in, see <a href="https://www.fabwaseem.com/blog/secure-nestjs-prisma-api-starter">my NestJS + Prisma API starter</a>.</p>
<h2>Key takeaways</h2>
<ul>
<li>NestJS streams SSE from any <code>@Sse()</code> route that returns an Observable, and sets <code>text/event-stream</code>, no-cache and <code>X-Accel-Buffering: no</code> for you.</li>
<li>A <code>ReplaySubject(1)</code> per job gave late clients their first frame in 2 to 4 ms, against up to 1,002 ms with a plain <code>Subject</code>.</li>
<li>Set <code>id</code> from your job's sequence number. Nest's automatic ids restart at 1 on every reconnect.</li>
<li>Close the <code>EventSource</code> on the terminal event. A completed stream made Chrome reconnect and replay it three times in 1.6 s.</li>
<li>Send heartbeats as <code>{ comment: "ping" }</code>, which the browser ignores and Nest leaves without an id.</li>
<li>Over HTTP/1.1, Chrome opened 6 of 8 streams and then stalled a normal <code>fetch()</code>. Use HTTP/2, one multiplexed stream, or a batch polling endpoint.</li>
<li>For one 10-second job, SSE used 1 request and 4,516 bytes. Polling every 250 ms needed 41 requests and 16,506 bytes for the same updates.</li>
</ul>
<hr />
<p><em>Originally published on <a href="https://www.fabwaseem.com/blog/nestjs-server-sent-events">fabwaseem.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Prisma 8 vs Prisma 7: Should You Upgrade? A Tested Migration Guide]]></title><description><![CDATA[Prisma 8 is an RC, but npm install prisma already installs it. Changes from Prisma 7, what is missing, a tested Postgres migration, when to upgrade.]]></description><link>https://fabwaseem.hashnode.dev/prisma-8-vs-prisma-7-should-you-upgrade-a-tested-migration-guide</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/prisma-8-vs-prisma-7-should-you-upgrade-a-tested-migration-guide</guid><category><![CDATA[prisma]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Mon, 19 Oct 2026 04:05:59 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/f62a2525-6385-4c81-8a92-edfe5693d880.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Heads up if any of your projects, CI jobs or Dockerfiles run <code>npm install prisma</code> without a version pin: since August 2026 that installs a Prisma 8 release candidate. I migrated a Prisma 7 app on PostgreSQL 17 side by side, and this is what I found.</p>
<p>Prisma 8 is a rewrite of Prisma ORM, not a version bump. The schema becomes a <strong>contract</strong>, <code>prisma generate</code> becomes <code>prisma contract emit</code>, <code>@prisma/client</code> is replaced by <code>@prisma/orm-postgres</code>, and queries are chained (<code>db.orm.public.User.where(...).all()</code>). It is still a release candidate, but since August 2026 <code>npm install prisma</code> installs it.</p>
<p>That last fact is the urgent part. Any Prisma 7 project, CI job or Dockerfile that installs <code>prisma</code> without a version now gets a CLI with no <code>generate</code>, <code>migrate</code> or <code>db push</code>. I upgraded a real Prisma 7 app on PostgreSQL 17 with the official side-by-side path, and this guide gives the commands, the output, two gotchas the docs don't spell out, and a clear answer on whether to move now. Facts are cited to the <a href="https://www.prisma.io/docs/orm/release-status">Prisma ORM release status page</a>.</p>
<h2>What is Prisma 8?</h2>
<p>Prisma 8 (Prisma ORM 8) is the next major version of Prisma ORM, a typed data layer for TypeScript. You describe models in a contract file, <code>prisma contract emit</code> writes <code>contract.json</code> and <code>contract.d.ts</code>, and you query through a <code>db</code> client from <code>@prisma/orm-postgres</code>. There is no generated <code>PrismaClient</code>, and migrations are TypeScript files you can review.</p>
<p>Key facts on October 17, 2026:</p>
<table>
<thead>
<tr>
<th></th>
<th>Prisma ORM 7</th>
<th>Prisma ORM 8</th>
</tr>
</thead>
<tbody><tr>
<td>npm tag</td>
<td><code>prisma@prev</code> (7.10.1)</td>
<td><code>prisma@latest</code> (8.0.0-rc.23)</td>
</tr>
<tr>
<td>Status</td>
<td>Stable, supported</td>
<td>Release candidate, GA "expected in October 2026"</td>
</tr>
<tr>
<td>Runtime package</td>
<td><code>@prisma/client</code> + a driver adapter</td>
<td><code>@prisma/orm-postgres</code> (8.0.0-rc.18)</td>
</tr>
<tr>
<td>Schema file</td>
<td><code>schema.prisma</code></td>
<td><code>contract.prisma</code> (first line <code>// use prisma-8</code>)</td>
</tr>
<tr>
<td>Codegen</td>
<td><code>prisma generate</code></td>
<td><code>prisma contract emit</code></td>
</tr>
<tr>
<td>Queries</td>
<td><code>prisma.user.findMany({ where })</code></td>
<td><code>db.orm.public.User.where(...).all()</code></td>
</tr>
<tr>
<td>Migrations</td>
<td><code>migrate dev</code> / <code>migrate deploy</code> (SQL)</td>
<td><code>migration plan</code> / <code>db migrate</code> (TypeScript)</td>
</tr>
<tr>
<td>Databases</td>
<td>PostgreSQL, MySQL, SQL Server, SQLite, MongoDB, CockroachDB</td>
<td>PostgreSQL; MongoDB early access; SQLite experimental</td>
</tr>
<tr>
<td>Node.js</td>
<td>20.19+, 22.12+ or 24+</td>
<td>22.18+, 24.11+ or 26+</td>
</tr>
<tr>
<td>TypeScript</td>
<td>5.4+</td>
<td>5.9+</td>
</tr>
</tbody></table>
<p>The CLI and the library are versioned separately, so <code>prisma</code> 8.0.0-rc.23 and <code>@prisma/orm-postgres</code> 8.0.0-rc.18 are a matching pair. Prisma says "any two <code>latest</code> versions work together."</p>
<h2>Is Prisma 8 stable? When is the release date?</h2>
<p>No, not yet. Prisma 8 is a release candidate, and Prisma's release status page says general availability "is expected in October 2026." On October 17, 2026 the newest builds were <code>prisma</code> 8.0.0-rc.23 (published October 15) and <code>@prisma/orm-postgres</code> 8.0.0-rc.18 (October 14). Prisma warns that "some details of the API may still change."</p>
<p>Two support dates follow from the release:</p>
<ul>
<li><strong>Prisma ORM 7</strong> keeps receiving bug fixes and security updates "for 18 months from the day Prisma ORM 8 reaches general availability."</li>
<li><strong>Prisma ORM 6</strong> receives security patches only until <strong>November 19, 2026</strong>.</li>
</ul>
<p>So there is no rush to leave Prisma 7, but Prisma 6 projects should move to 7 now.</p>
<h2>Why does <code>npm install prisma</code> install Prisma 8?</h2>
<p>Because Prisma moved the npm <code>latest</code> tag to Prisma 8 while it is still an RC. Prisma's <a href="https://www.prisma.io/changelog/2026-08-28">August 28 changelog</a> says the release "makes Prisma 8 available through prisma@latest." Prisma 7 is now <code>prisma@prev</code>. A Prisma 7 project that installs <code>prisma</code> without a version range gets a CLI that cannot read its workflow.</p>
<p>I ran the old commands with the Prisma 8 CLI in a Prisma 7 project:</p>
<pre><code class="language-text">$ npx prisma generate
âœ˜ [CLI.UNKNOWN_COMMAND] No command registered for `generate`
$ npx prisma migrate deploy
âœ˜ [CLI.UNKNOWN_COMMAND] No command registered for `migrate`, did you mean `migration`?
$ npx prisma db push
âœ˜ [CLI.UNKNOWN_COMMAND] No command registered for `push`, did you mean `update`?
</code></pre>
<p><strong>Do this today, even if you never upgrade:</strong> pin both packages, then grep your CI, Dockerfiles and scripts for <code>npx prisma</code> and <code>prisma@latest</code>.</p>
<pre><code class="language-json">{
  "devDependencies": { "prisma": "^7" },
  "dependencies": { "@prisma/client": "^7" }
}
</code></pre>
<p>Anywhere a command runs without a lockfile, write <code>npx prisma@7 migrate deploy</code> instead of <code>npx prisma migrate deploy</code>. If you build images with a multi-stage Dockerfile like the one in my <a href="https://www.fabwaseem.com/blog/nextjs-dockerfile">Next.js Dockerfile guide</a>, the <code>npm ci</code> step respects the lockfile; a separate <code>npx prisma ...</code> line in a later stage does not.</p>
<h2>What changed in Prisma 8?</h2>
<p>Almost everything you type. The schema is a contract, codegen is <code>contract emit</code>, the client is built in <code>src/prisma/db.ts</code>, queries are chained, migrations are TypeScript packages tracked by a hash marker in the database, and several Prisma 7 features (<code>$extends</code>, nested updates, <code>increment</code>, JSON filters, <code>P2002</code> codes) are missing. Your database tables and data do not change.</p>
<h3>Commands</h3>
<table>
<thead>
<tr>
<th>Prisma 7</th>
<th>Prisma 8</th>
</tr>
</thead>
<tbody><tr>
<td><code>prisma generate</code></td>
<td><code>prisma contract emit</code></td>
</tr>
<tr>
<td><code>prisma migrate dev</code></td>
<td><code>prisma db update</code>, or <code>prisma migration plan</code> + <code>prisma db migrate</code></td>
</tr>
<tr>
<td><code>prisma migrate deploy</code></td>
<td><code>prisma db migrate</code></td>
</tr>
<tr>
<td><code>prisma db push</code></td>
<td><code>prisma db init</code> (empty database), then <code>prisma db update</code></td>
</tr>
<tr>
<td><code>prisma db pull</code></td>
<td><code>prisma contract infer</code></td>
</tr>
<tr>
<td><code>prisma format</code></td>
<td><code>prisma contract format</code></td>
</tr>
<tr>
<td><code>prisma migrate reset</code>, <code>prisma db seed</code>, <code>prisma studio</code></td>
<td>None in the Prisma 8 CLI</td>
</tr>
</tbody></table>
<h3>Queries</h3>
<table>
<thead>
<tr>
<th>Prisma 7</th>
<th>Prisma 8</th>
</tr>
</thead>
<tbody><tr>
<td><code>prisma.user.findMany({ where: { active: true } })</code></td>
<td><code>db.orm.public.User.where({ active: true }).all()</code></td>
</tr>
<tr>
<td><code>findUnique</code> / <code>findFirst</code></td>
<td><code>.where(...).first()</code> (returns <code>null</code>)</td>
</tr>
<tr>
<td><code>select: { id: true }</code></td>
<td><code>.select("id")</code></td>
</tr>
<tr>
<td><code>include: { posts: true }</code></td>
<td><code>.include("posts")</code></td>
</tr>
<tr>
<td><code>orderBy: { id: "asc" }</code></td>
<td><code>.orderBy((u) =&gt; u.id.asc())</code></td>
</tr>
<tr>
<td><code>take</code> / <code>skip</code></td>
<td><code>.limit(n)</code> / <code>.offset(n)</code></td>
</tr>
<tr>
<td><code>create({ data: {...} })</code></td>
<td><code>.create({...})</code> (no <code>data</code> wrapper)</td>
</tr>
<tr>
<td><code>contains: "ali"</code> / <code>mode: "insensitive"</code></td>
<td><code>.like("%ali%")</code> / <code>.ilike("%ali%")</code></td>
</tr>
<tr>
<td><code>$transaction(async (tx) =&gt; ...)</code></td>
<td><code>db.transaction(async (tx) =&gt; ...)</code> with <code>tx.orm</code></td>
</tr>
<tr>
<td><code>$disconnect()</code></td>
<td><code>db.close()</code></td>
</tr>
</tbody></table>
<p><code>public</code> is the PostgreSQL schema the table lives in. The full mapping is in Prisma's <a href="https://www.prisma.io/docs/orm/coming-from-prisma-orm-7">Coming from Prisma ORM 7</a> reference.</p>
<h3>Schema types</h3>
<p>In a hand-written contract, <code>@db.</code> attributes become the field type (<code>String @db.VarChar(255)</code> is <code>VarChar(255)</code>), <code>Json</code> must be written <code>Jsonb</code>, <code>@updatedAt</code> is <code>temporal.updatedAt()</code>, <code>cuid()</code> is <code>cuid(2)</code>, and implicit many-to-many relations need an explicit join model. <code>DateTime</code> values come back as <code>Temporal.Instant</code>, which Node.js 22 and 24 do not have built in. On my Node.js 24.22.0, <code>node -p "typeof Temporal"</code> printed <code>undefined</code>, so a hand-written contract needs <code>temporal-polyfill</code>.</p>
<h2>What is missing in Prisma 8?</h2>
<p>Prisma 8 does not yet have several Prisma 7 features. The release status page lists <code>$extends</code>, "filtering inside JSON columns, atomic <code>increment</code>, most nested writes, transaction isolation levels, and the <code>P2002</code>-style error codes." <code>$extends</code> will not return; middleware, custom collections and scopes replace it. If your app relies on these, stay on Prisma 7 for now.</p>
<table>
<thead>
<tr>
<th>Prisma 7 feature</th>
<th>In Prisma 8</th>
</tr>
</thead>
<tbody><tr>
<td><code>$extends</code></td>
<td>Will not be added. Use middleware, <code>Collection</code> subclasses or scopes</td>
</tr>
<tr>
<td><code>{ increment: 1 }</code></td>
<td>Not available. Use raw SQL or read and write in a transaction</td>
</tr>
<tr>
<td>JSON path filters</td>
<td>Not available. <code>Jsonb</code> compares as a whole; use raw SQL</td>
</tr>
<tr>
<td><code>connectOrCreate</code>, nested <code>update</code>/<code>upsert</code>/<code>delete</code>, <code>set</code></td>
<td>Not available. Only nested <code>create</code>, <code>connect</code>, <code>disconnect</code></td>
</tr>
<tr>
<td><code>isolationLevel</code>, <code>timeout</code>, nested transactions</td>
<td>Not available</td>
</tr>
<tr>
<td><code>P2002</code> / <code>P2025</code> error codes</td>
<td>Use <code>error.sqlState</code> (<code>23505</code> = unique violation) or codes like <code>RUNTIME.NO_ROWS</code></td>
</tr>
<tr>
<td><code>omit</code>, <code>relationLoadStrategy</code>, TypedSQL, read replicas</td>
<td>Not available</td>
</tr>
<tr>
<td><code>has</code> / <code>hasSome</code> on <code>String[]</code></td>
<td>Not available. The field works; filter with raw SQL</td>
</tr>
<tr>
<td>MySQL, SQL Server, CockroachDB</td>
<td>Not yet. Stay on Prisma 7</td>
</tr>
</tbody></table>
<p>I replaced an <code>increment</code> with the documented raw SQL builder, and it ran on 8.0.0-rc.18:</p>
<pre><code class="language-ts">import { db } from "./prisma/db.ts";

const post = db.sql.public.Post;
const bump = db.raw.sql`UPDATE "Post" SET views = views + 1 WHERE id = ${id} RETURNING id, views`
  .returnsRow({ id: post.columns.id, views: post.columns.views })
  .build();

const rows = await db.runtime().query(bump); // [ { id: 1, views: 3 } ]
</code></pre>
<h2>How do I migrate from Prisma 7 to Prisma 8?</h2>
<p>Run both versions side by side on the same database. One command (<code>npx prisma@latest orm init --from-prisma7-schema prisma/schema.prisma</code>) renames Prisma 7 to <code>prisma7</code>, installs Prisma 8, and lets it read your existing <code>schema.prisma</code>. You then sign the database, move routes one at a time, and hand migrations to Prisma 8 only when you choose.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792246449/fabwaseem/blog/prisma-8/diagram-phases.jpg" alt="The five phases of a Prisma 7 to Prisma 8 migration: prepare Prisma 7 as prisma7, add Prisma 8, move routes one at a time, transfer migration ownership with db sign and migration plan, then remove Prisma 7" /></p>
<p>This is the path from Prisma's official <a href="https://www.prisma.io/docs/guides/upgrade-prisma-orm/postgresql">Prisma ORM 7 to 8 guide</a>, and the <a href="https://github.com/prisma/prisma8-and-7-example">prisma8-and-7-example</a> repo shows each phase. My test app was a Hono API on Prisma 7.10.1 with <code>@prisma/adapter-pg</code>, PostgreSQL 17.11, Node.js 24.22.0, a <code>Role</code> enum, a <code>DateTime @default(now())</code>, a <code>Json?</code> column and an <code>increment</code> update.</p>
<h3>Step 1: Run orm init</h3>
<pre><code class="language-bash">npx prisma@latest orm init --from-prisma7-schema prisma/schema.prisma
</code></pre>
<p>Write <code>prisma@latest</code> in full, because inside a Prisma 7 project <code>npx prisma</code> still runs version 7. In CI or from a coding agent, the command stops and asks for consent:</p>
<pre><code class="language-text">CLI.CONSENT_REQUIRED: "Prisma 7 is installed as `prisma`. Keep it as @prisma/prisma7
(binary prisma7) and move `prisma` to Prisma 8, and rename prisma.config.ts to
prisma7.config.ts?" requires explicit consent, and the session is not interactive.
Grant it by passing --confirm app.
</code></pre>
<p>Note that it had already added <code>@prisma/orm-postgres</code> to <code>package.json</code> before it stopped. With <code>--confirm app</code> it finished and changed exactly this:</p>
<pre><code class="language-diff">   "dependencies": {
     "@prisma/adapter-pg": "^7.10.1",
     "@prisma/client": "^7.10.1",
+    "@prisma/orm-postgres": "^8.0.0-rc.18",
   },
   "devDependencies": {
+    "@prisma/cli-engine": "^0.7.0",
+    "@prisma/prisma7": "^7.10.1",
-    "prisma": "^7.10.1",
+    "prisma": "^8.0.0-rc.23",
   }
</code></pre>
<p>It renamed <code>prisma.config.ts</code> to <code>prisma7.config.ts</code> (now importing <code>@prisma/prisma7/config</code>), wrote a new <code>prisma.config.ts</code>, and emitted the client into <code>src/prisma/</code>:</p>
<pre><code class="language-ts">// prisma.config.ts (written by orm init)
import "dotenv/config";
import { definePrismaConfig } from "@prisma/cli-engine";
import { defineConfig as ormConfig, prisma7Schema } from "@prisma/orm-postgres/config";

export default definePrismaConfig({
  orm: ormConfig({
    contract: prisma7Schema("prisma/schema.prisma"),
    output: "src/prisma",
    db: { connection: process.env["DATABASE_URL"]! },
  }),
});
</code></pre>
<p><code>npx prisma --version</code> now reports 8.0.0-rc.23, and <code>npx prisma7 generate</code> still writes the Prisma 7 client. Update every script and CI step that must keep using Prisma 7 to call <code>prisma7</code>.</p>
<h3>Step 2: Sign the database</h3>
<pre><code class="language-text">$ npx prisma db sign
âœ” app: signed b3b45c6c5fd8â€¦ (no marker before)
âœ” Database signed
âœ” Advanced ref "db" â†’ b3b45c6c5fd8â€¦

$ npx prisma db verify
âœ” Database marker and schema match contract
</code></pre>
<p><code>db sign</code> changes no tables. It adds a <code>prisma_contract.marker</code> table that records which contract hash the database satisfies. Prisma 7 still owns migrations, so after every <code>prisma7 migrate dev</code> or <code>prisma7 migrate deploy</code>, run <code>npx prisma contract emit</code> and <code>npx prisma db sign</code> again.</p>
<h3>Step 3: Move one route</h3>
<pre><code class="language-ts">// src/routes/users.ts â€” now on Prisma 8
import { db } from "../prisma/db.ts";

users.get("/", async (c) =&gt; {
  const result = await db.orm.public.User.include("posts", (posts) =&gt;
    posts.orderBy((post) =&gt; post.id.asc()),
  )
    .orderBy((user) =&gt; user.id.asc())
    .all();
  return c.json(result);
});

users.post("/", async (c) =&gt; {
  const body = await c.req.json&lt;{ email: string; name?: string }&gt;();
  try {
    return c.json(await db.orm.public.User.create(body), 201);
  } catch (error) {
    // No P2002 in Prisma 8: check the PostgreSQL SQL state instead
    if (error instanceof Error &amp;&amp; "sqlState" in error &amp;&amp; error.sqlState === "23505") {
      return c.json({ error: "email already exists" }, 409);
    }
    throw error;
  }
});
</code></pre>
<p>The posts route stayed on Prisma 7, including its <code>increment</code>. A post written through Prisma 7 appeared in the next Prisma 8 read, and a duplicate email returned my 409. <code>tsc --noEmit</code> passed on TypeScript 6.0.3 and, as a bonus, on <a href="https://www.fabwaseem.com/blog/typescript-7">TypeScript 7</a> 7.0.2.</p>
<h3>Step 4: Hand migrations to Prisma 8</h3>
<p>When you are ready, convert the Prisma 7 schema into a real contract, point the config at it, sign, then plan a change:</p>
<pre><code class="language-bash">npx prisma contract print --output prisma8/contract.prisma
# set contract: "prisma8/contract.prisma" in prisma.config.ts
npx prisma contract emit
npx prisma db sign
</code></pre>
<p>The printed contract produced the <strong>same hash</strong> as the Prisma 7 schema (<code>unchanged, already signed</code>), so nothing drifted. It mapped my types like this:</p>
<pre><code class="language-prisma">// use prisma-8
namespace public {
  model User {
    id        Int                @id @default(autoincrement())
    email     String
    name      String?
    role      pg.enum(Role)      @default("USER")
    createdAt TimestampString(3) @default(now())
    posts     Post[]

    @@index([email], map: "User_email_key", unique: true)
  }

  native_enum Role {
    USER = "USER"
    ADMIN = "ADMIN"
  }
}
</code></pre>
<p>Then I added <code>bio String?</code> and ran the new migration loop:</p>
<pre><code class="language-text">$ npx prisma contract emit
$ npx prisma migration plan --name add_user_bio
â†’ Review migrations/app/20261017T1408_baseline and migrations/app/20261017T1409_add_user_bio
$ npx prisma db migrate --advance-ref db
âœ” Applied 1 migration(s) (1 operation(s)) across 1 contract space(s)
â”œâ”€ Add column "bio" to "User"
$ npx prisma db verify
âœ” Database marker and schema match contract
</code></pre>
<p>The first plan writes a baseline (the schema Prisma 7 built) plus your change. <code>db migrate</code> skips the baseline because the marker already records it. Each migration is a reviewable <code>migration.ts</code>:</p>
<pre><code class="language-ts">override get operations() {
  return [
    this.addColumn({
      schema: "public",
      table: "User",
      column: col("bio", "text", { codecRef: { codecId: "pg/text@1" } }),
    }),
  ];
}
</code></pre>
<p>The Prisma 7 routes kept working after the new column. From here, retire <code>prisma7 migrate</code> from your scripts, move the remaining routes, and finally remove <code>@prisma/prisma7</code>, <code>@prisma/client</code> and <code>@prisma/adapter-pg</code>.</p>
<h2>What broke when I ran Prisma 7 and 8 side by side?</h2>
<p>Two things the guide doesn't warn about. First, <code>orm init</code> needs <code>--confirm app</code> in any non-interactive shell (CI, Docker, coding agents). Second, <code>createdAt</code> timestamps written by Prisma 8 were 5 hours off. Prisma 7 fills <code>@default(now())</code> in the client in UTC, but Prisma 8 leaves it to the database, which used my server's local time zone.</p>
<p>The evidence, from the same <code>User</code> table:</p>
<pre><code class="language-text">id | createdAt                 written by
 1 | 2026-10-17 14:06:44.701   Prisma 7 (UTC)
 2 | 2026-10-17 19:08:52.282   Prisma 8 (server time zone Asia/Karachi, UTC+5)
 4 | 2026-10-17 14:09:08.795   Prisma 7 (UTC)
</code></pre>
<p>Prisma 7 created the column as <code>TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP</code>, which is <code>timestamp without time zone</code>. Prisma's own mapping table says <code>@default(now())</code> in Prisma 8 "is a database default." For a <code>timestamp</code> column, PostgreSQL then writes the time in the session's <code>TimeZone</code> setting, which defaults to the server's. After <code>ALTER DATABASE p8demo SET timezone TO 'UTC'</code>, Prisma 8 wrote 14:09:20 at 14:09:20 UTC.</p>
<p>Many managed PostgreSQL services default to UTC, so you may never see this. Check before mixing writers:</p>
<pre><code class="language-sql">SHOW timezone;  -- must be UTC (or Etc/UTC) while Prisma 7 and 8 both write
</code></pre>
<p>Also note the data type change: read through <code>prisma7Schema(...)</code>, <code>DateTime</code> comes back as a string such as <code>"2026-10-17 14:06:44.701"</code>, not a <code>Date</code>. My JSON responses changed format between the two routes (<code>2026-10-17T14:06:44.701Z</code> from Prisma 7), so API clients that parse dates should be tested.</p>
<h2>Should I upgrade to Prisma 8 now?</h2>
<p>Not for production apps yet. Pin Prisma 7 today, because that protects you from the <code>latest</code> tag change, and plan the move after GA. Start with Prisma 8 now only for new PostgreSQL projects that don't need the missing features, or to trial it on one route of an existing app with the side-by-side setup.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792246459/fabwaseem/blog/prisma-8/diagram-decision.jpg" alt="Decision flow for Prisma 8: stay on Prisma 7 for MySQL, SQL Server or CockroachDB, or if you use $extends, nested updates, increment, JSON filters or P2002 codes; otherwise trial Prisma 8 side by side or start new PostgreSQL projects on it" /></p>
<table>
<thead>
<tr>
<th>Your situation</th>
<th>Recommendation</th>
</tr>
</thead>
<tbody><tr>
<td>Prisma 6</td>
<td>Upgrade to Prisma 7 before November 19, 2026</td>
</tr>
<tr>
<td>Prisma 7 on MySQL, SQL Server or CockroachDB</td>
<td>Stay on 7; Prisma 8 doesn't support them yet</td>
</tr>
<tr>
<td>Prisma 7 using <code>$extends</code>, nested updates, JSON filters or <code>P2002</code></td>
<td>Stay on 7 until those have replacements you accept</td>
</tr>
<tr>
<td>Prisma 7 on PostgreSQL, simple CRUD</td>
<td>Pin 7 now; trial 8 on one route after GA</td>
</tr>
<tr>
<td>New PostgreSQL project</td>
<td>Prisma 8 is reasonable; expect small API edits until GA</td>
</tr>
<tr>
<td>Using Accelerate</td>
<td>Switch connections before December 1, 2026 regardless</td>
</tr>
</tbody></table>
<h2>What happens to Prisma Accelerate?</h2>
<p>Prisma retires Accelerate on <strong>December 1, 2026</strong>, according to its <a href="https://www.prisma.io/changelog/2026-10-02">October 2 changelog</a>. If your connection string starts with <code>prisma+postgres://accelerate.prisma-data.net</code>, switch to a pooled connection or the serverless driver before then. Accelerate's query caching has no replacement, so remove <code>cacheStrategy</code> from your queries first.</p>
<p>The same changelog sets a second deadline: Prisma Postgres Early Access databases, the ones still on PostgreSQL 16, "are deleted on November 1, 2026 at 00:00 UTC." Move each one to a new database before then. Both deadlines apply whether you stay on Prisma 7 or move to 8.</p>
<h2>Prisma 8 upgrade checklist</h2>
<p>Pin Prisma 7 first, then handle the deadlines, then migrate on your own schedule. These steps follow the official guide plus what I hit in testing.</p>
<ol>
<li><strong>Pin <code>prisma</code> and <code>@prisma/client</code> to <code>^7</code></strong> and replace every unpinned <code>npx prisma</code> in CI and Dockerfiles with <code>npx prisma@7</code> or a lockfile install.</li>
<li><strong>Leave Accelerate</strong> before December 1, 2026, and move Early Access Prisma Postgres databases before November 1.</li>
<li><strong>Check requirements</strong>: Node.js 22.18+, 24.11+ or 26+, TypeScript 5.9+ with <code>strict</code>, PostgreSQL.</li>
<li><strong>Audit for missing features</strong>: <code>$extends</code>, <code>increment</code>, nested updates, JSON filters, isolation levels, <code>P2002</code> handling.</li>
<li><strong>Check the database time zone</strong> with <code>SHOW timezone;</code> and set it to UTC if Prisma 7 and 8 will both write.</li>
<li><strong>Run <code>npx prisma@latest orm init --from-prisma7-schema prisma/schema.prisma</code></strong> (add <code>--confirm app</code> in CI).</li>
<li><strong>Run <code>npx prisma db sign</code></strong>, then move one low-risk route to <code>db.orm</code>.</li>
<li><strong>After each <code>prisma7</code> migration</strong>, run <code>prisma contract emit</code> and <code>prisma db sign</code>.</li>
<li><strong>Transfer migrations</strong> with <code>contract print</code>, <code>db sign</code>, <code>migration plan</code>, <code>db migrate --advance-ref db</code>.</li>
<li><strong>Remove Prisma 7</strong> when nothing imports <code>@prisma/client</code>.</li>
</ol>
<h2>Common Prisma 8 mistakes</h2>
<p>The most common mistakes are letting <code>prisma@latest</code> slip into a Prisma 7 build, running Prisma 8's <code>db init</code> or <code>db update</code> against a database Prisma 7 already manages, forgetting to re-sign after a Prisma 7 migration, and assuming <code>DateTime</code> and error handling behave the same in both clients.</p>
<table>
<thead>
<tr>
<th>Mistake</th>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Unpinned <code>prisma</code> in CI</td>
<td><code>No command registered for generate</code></td>
<td>Pin <code>^7</code> or use <code>npx prisma@7</code></td>
</tr>
<tr>
<td><code>orm init</code> in CI without consent</td>
<td><code>CLI.CONSENT_REQUIRED</code></td>
<td>Add <code>--confirm app</code></td>
</tr>
<tr>
<td><code>db init</code> / <code>db update</code> on a Prisma 7 database</td>
<td>Skips the signed handoff; the guide says to run neither</td>
<td><code>db sign</code>, then the phase 4 handoff</td>
</tr>
<tr>
<td>Not re-signing after <code>prisma7 migrate</code></td>
<td><code>db verify</code> reports a contract mismatch</td>
<td><code>contract emit</code>, then <code>db sign</code></td>
</tr>
<tr>
<td>Catching <code>P2002</code> in moved routes</td>
<td>Unique violations crash as 500s</td>
<td>Check <code>error.sqlState === "23505"</code></td>
</tr>
<tr>
<td>Server time zone not UTC</td>
<td>Prisma 8 timestamps offset by hours</td>
<td><code>ALTER DATABASE ... SET timezone TO 'UTC'</code></td>
</tr>
<tr>
<td>Expecting <code>Date</code> objects</td>
<td>Strings or <code>Temporal.Instant</code> instead</td>
<td>Convert at the API boundary</td>
</tr>
<tr>
<td>Hand-written contract on Node.js 24</td>
<td><code>Temporal</code> is undefined</td>
<td><code>import "temporal-polyfill/full/global"</code></td>
</tr>
</tbody></table>
<p><strong>Related:</strong> I benchmarked Prisma 8 RC against Prisma 7 and Drizzle in <a href="https://www.fabwaseem.com/blog/drizzle-vs-prisma">Drizzle vs Prisma in 2026</a>: Prisma 8 RC had the slowest bundled cold start (about 370 ms). If you use Better Auth, note that Better Auth 1.7 supports Prisma 5 to 7 but not Prisma 8 yet; see my <a href="https://www.fabwaseem.com/blog/better-auth-nextjs">Better Auth with Next.js 16 setup</a>.</p>
<p><strong>Related:</strong> Prisma's <code>@default(uuid(7))</code> generates IDs in the client and sets no database default. On PostgreSQL 18 you can use <code>dbgenerated("uuidv7()")</code> instead; see my <a href="https://www.fabwaseem.com/blog/uuidv7-postgres">UUIDv7 in PostgreSQL 18 benchmark</a>.</p>
<h2>Key takeaways</h2>
<ul>
<li>Prisma 8 is a release candidate (prisma 8.0.0-rc.23, @prisma/orm-postgres 8.0.0-rc.18 on October 17, 2026) with GA expected in October 2026, but npm's latest tag already installs it.</li>
<li>Pin prisma and @prisma/client to ^7 today; the Prisma 8 CLI has no generate, migrate or db push.</li>
<li>Prisma 8 replaces schema.prisma with a contract, prisma generate with prisma contract emit, and @prisma/client with @prisma/orm-postgres and chained queries.</li>
<li>$extends, increment, most nested writes, JSON filters, isolation levels and P2002 codes are missing, and only PostgreSQL is fully supported.</li>
<li>orm init --from-prisma7-schema runs Prisma 7 and 8 side by side on one database; it needs --confirm app outside an interactive terminal.</li>
<li>Check SHOW timezone before mixing writers: Prisma 8 leaves now() to the database, which skewed my timestamps by 5 hours.</li>
<li>Prisma 7 is supported for 18 months after Prisma 8 GA, Prisma 6 security patches end November 19, 2026, and Accelerate retires December 1, 2026.</li>
</ul>
<hr />
<p><em>Originally published on <a href="https://www.fabwaseem.com/blog/prisma-8">fabwaseem.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[TypeScript 7 Upgrade Guide: What Breaks, ESLint, Next.js and Real Benchmarks]]></title><description><![CDATA[TypeScript 7 upgrade guide, tested: 5.2x faster builds, removed tsconfig options, the typescript-eslint error, TS 6 side by side and Next.js 16.4.]]></description><link>https://fabwaseem.hashnode.dev/typescript-7-upgrade-guide-what-breaks-eslint-next-js-and-real-benchmarks</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/typescript-7-upgrade-guide-what-breaks-eslint-next-js-and-real-benchmarks</guid><category><![CDATA[Web Development]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Mon, 19 Oct 2026 04:03:52 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/07c414cd-e4aa-4421-a398-5104240be723.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I tested the TypeScript 7 upgrade and ran into three catches that are easy to miss: removed tsconfig options, a <code>typescript-eslint</code> break, and a side-by-side setup that changes which compiler <code>next build</code> uses. Here is the full guide with the exact errors and fixes.</p>
<p>TypeScript 7 is the native, Go-based rewrite of the TypeScript compiler, and it has been the <code>latest</code> version on npm since July 8, 2026. It type-checks the same code as TypeScript 6 but much faster. On this box Hono's source built <strong>5.2x faster</strong> (3.01 s to 0.58 s), with identical JavaScript output.</p>
<p>The upgrade is usually one <code>npm install</code>, with three real catches. Every option deprecated in TypeScript 6 is now a hard error. TypeScript 7 has no programmatic API, so <code>typescript-eslint</code> refuses to run. And the side-by-side workaround quietly changes which compiler <code>next build</code> uses. I hit all three while testing, so this guide gives the exact errors and fixes, cited to the <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/">official TypeScript 7 announcement</a>.</p>
<h2>What is TypeScript 7?</h2>
<p>TypeScript 7 is a faithful port of the TypeScript compiler and language service from TypeScript to Go. It ships as the normal <code>typescript</code> npm package with the same <code>tsc</code> command. Microsoft says it "typically" yields "speedups between 8x and 12x on full builds" through native code and shared-memory multithreading. The type system and emitted JavaScript match TypeScript 6.0.</p>
<p>Key facts at a glance:</p>
<table>
<thead>
<tr>
<th></th>
<th>TypeScript 6.0</th>
<th>TypeScript 7.0</th>
</tr>
</thead>
<tbody><tr>
<td>Written in</td>
<td>TypeScript (runs on Node.js)</td>
<td>Go (native binary, multithreaded)</td>
</tr>
<tr>
<td>npm package</td>
<td><code>typescript@6</code> / <code>@typescript/typescript6</code></td>
<td><code>typescript@7</code> (<code>latest</code>, 7.0.2)</td>
</tr>
<tr>
<td>Type checking</td>
<td>Reference behavior</td>
<td>Same semantics, with <code>stableTypeOrdering</code> always on</td>
</tr>
<tr>
<td>Deprecated options</td>
<td>Error, silenced by <code>ignoreDeprecations: "6.0"</code></td>
<td>Hard error, cannot be silenced</td>
</tr>
<tr>
<td>Programmatic API</td>
<td>Yes (<code>ts.createProgram</code>, ...)</td>
<td>None yet; planned for 7.1</td>
</tr>
<tr>
<td>Editor</td>
<td>Built-in TS server</td>
<td>LSP server, VS Code extension</td>
</tr>
</tbody></table>
<p>The announcement adds that "practically any TypeScript code that compiles cleanly with TypeScript 6.0 (with the stableTypeOrdering flag on, and without any ignoreDeprecations flag set) should compile identically in TypeScript 7.0."</p>
<h2>Is TypeScript 7 stable and ready for production?</h2>
<p>Yes, for compiling and type-checking. TypeScript 7.0 is a stable release, and the team tested it with companies including Bloomberg, Canva, Figma, Google, Linear, Notion, Sentry, Slack and Vercel. It is not ready for tools that need the compiler API, such as <code>typescript-eslint</code> and Vue, Svelte, Astro or MDX tooling, until TypeScript 7.1 ships a new API.</p>
<p>Quotable results from the announcement:</p>
<ul>
<li><p>Slack's CI type-checking went "from about 7.5 minutes to 1.25 minutes."</p>
</li>
<li><p>The new language server "reduced failing language server commands by over 80%, and reduced server crashes by over 60%" compared with 6.0.</p>
</li>
<li><p>Opening a file with an error in the VS Code codebase dropped from about 17.5 seconds to under 1.3 seconds.</p>
</li>
</ul>
<p>The <a href="https://github.com/microsoft/TypeScript/issues/63703">TypeScript 7.1 iteration plan</a> lists "Stabilize API" as its first item. It scheduled the 7.1 beta for October 6 and the stable release for November 24, 2026. On October 17, 2026 npm still had no 7.1 beta, only nightly <code>7.1.0-dev</code> builds under the <code>next</code> tag, so treat the date as a target.</p>
<h2>How much faster is TypeScript 7? (my benchmark)</h2>
<p>On an 8-vCPU Linux box, TypeScript 7.0.2 built Hono's source (189 files, about 26,000 lines, declarations plus JavaScript) in a median 0.58 seconds, against 3.01 seconds for TypeScript 6.0.3. That is a 5.2x speedup with 27% less peak memory. Both compilers emitted byte-identical JavaScript for all 188 output files.</p>
<p>The run: <code>tsc -p tsconfig.build.json</code> on <a href="https://github.com/honojs/hono">Hono</a> at commit <code>5ca49b0</code>, three runs each, Node.js 24.22.0, measured with <code>os.wait4</code> for wall time and peak RSS.</p>
<table>
<thead>
<tr>
<th>Compiler</th>
<th>Median</th>
<th>Runs</th>
<th>Peak memory</th>
</tr>
</thead>
<tbody><tr>
<td>TypeScript 6.0.3</td>
<td>3.01 s</td>
<td>2.85, 3.01, 3.08</td>
<td>528 MB</td>
</tr>
<tr>
<td>TypeScript 7.0.2 (default, 4 checkers)</td>
<td><strong>0.58 s</strong></td>
<td>0.58, 0.50, 0.59</td>
<td>383 MB</td>
</tr>
<tr>
<td>TypeScript 7.0.2 <code>--checkers 8</code></td>
<td>0.61 s</td>
<td>0.61, 0.60, 0.62</td>
<td>416 MB</td>
</tr>
<tr>
<td>TypeScript 7.0.2 <code>--singleThreaded</code></td>
<td>0.98 s</td>
<td>0.98, 1.03, 0.89</td>
<td>250 MB</td>
</tr>
</tbody></table>
<img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792245722/fabwaseem/blog/typescript-7/diagram-bench.jpg" alt="Bar chart of the TypeScript 6 vs 7 benchmark on Hono: TypeScript 6.0.3 3.01 seconds, TypeScript 7.0.2 0.58 seconds, --checkers 8 0.61 seconds, --singleThreaded 0.98 seconds, with peak memory for each" style="display:block;margin:0 auto" />

<p>My 5.2x is below Microsoft's 8x to 12x because this project is small. Startup time weighs more on a 3-second build than on VS Code's 125-second one, so expect larger gains on larger codebases. Two things surprised me:</p>
<ul>
<li><p><strong>More checkers didn't help here.</strong> <code>--checkers 8</code> was no faster than the default of 4 and used more memory. The announcement says extra checkers help "larger codebases" at "the cost of increased memory usage."</p>
</li>
<li><p><strong>Even single-threaded, TypeScript 7 was 3x faster</strong> and used half the memory. On small CI runners <code>--singleThreaded</code> or <code>--checkers 1</code> is a reasonable choice.</p>
</li>
</ul>
<h3>Does TypeScript 7 emit the same output?</h3>
<p>The JavaScript was identical. The <code>.d.ts</code> files were not: 25 of 188 declaration files differed. In 16 the only change was quote style (<code>'/'</code> instead of <code>"/"</code>). In 9, TypeScript 7 wrote a reference such as <code>toArray: typeof toArray</code> where TypeScript 6 had inlined the full function type:</p>
<pre><code class="language-diff">-    toArray: (children: Child) =&gt; Child[];
+    toArray: typeof toArray;
</code></pre>
<p>These are equivalent types, but if you publish a library, diff your generated declarations or run your API report tool before releasing a version built with TypeScript 7.</p>
<h2>What breaks when upgrading to TypeScript 7?</h2>
<p>Three things break most often. Every compiler option deprecated in TypeScript 6.0 is now a hard error that <code>ignoreDeprecations</code> no longer silences. The new defaults (<code>types: []</code>, <code>rootDir: "./"</code>, <code>strict: true</code>) surface errors in existing configs. And any tool that imports the <code>typescript</code> API fails, because 7.0 ships without one.</p>
<h3>Removed tsconfig options</h3>
<p>I ran a deliberately old <code>tsconfig.json</code> through both compilers. TypeScript 6 already reports these as errors (<code>TS5101</code>/<code>TS5107</code>, "will stop functioning in TypeScript 7.0"), but you can silence them with <code>"ignoreDeprecations": "6.0"</code>. TypeScript 7 reports <code>TS5102</code>/<code>TS5108</code> and ignores that flag (with it set, 7.0.2 still exited 1):</p>
<pre><code class="language-text">tsconfig.json(3,15): error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.
tsconfig.json(5,25): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.
tsconfig.json(6,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
  Use '"paths": {"*": ["./*"]}' instead.
tsconfig.json(7,24): error TS5090: Non-relative paths are not allowed. Did you forget a leading './'?
tsconfig.json(8,5): error TS5102: Option 'downlevelIteration' has been removed. Please remove it from your configuration.
tsconfig.json(9,24): error TS5108: Option 'esModuleInterop=false' has been removed. Please remove it from your configuration.
</code></pre>
<p>The full list of removals and their replacements, from the announcement:</p>
<table>
<thead>
<tr>
<th>Removed in TypeScript 7</th>
<th>Use instead</th>
</tr>
</thead>
<tbody><tr>
<td><code>target: "es5"</code>, <code>downlevelIteration</code></td>
<td><code>es2015</code> or later (a bundler can down-level if you must)</td>
</tr>
<tr>
<td><code>moduleResolution: "node"</code> / <code>"node10"</code> / <code>"classic"</code></td>
<td><code>"nodenext"</code> (Node) or <code>"bundler"</code> (Vite, Next.js, webpack)</td>
</tr>
<tr>
<td><code>module: "amd"</code>, <code>"umd"</code>, <code>"systemjs"</code>, <code>"none"</code></td>
<td><code>"esnext"</code> or <code>"preserve"</code></td>
</tr>
<tr>
<td><code>baseUrl</code></td>
<td><code>paths</code> with <code>./</code>-relative entries</td>
</tr>
<tr>
<td><code>esModuleInterop: false</code>, <code>allowSyntheticDefaultImports: false</code></td>
<td>Remove (always on)</td>
</tr>
<tr>
<td><code>alwaysStrict: false</code></td>
<td>Remove (always on)</td>
</tr>
<tr>
<td><code>module Foo {}</code> namespace syntax</td>
<td><code>namespace Foo {}</code></td>
</tr>
<tr>
<td><code>import ... assert { type: "json" }</code></td>
<td><code>import ... with { type: "json" }</code></td>
</tr>
<tr>
<td><code>tsc file.ts</code> inside a folder with <code>tsconfig.json</code></td>
<td><code>tsc --ignoreConfig file.ts</code></td>
</tr>
</tbody></table>
<h3>New defaults that cause errors</h3>
<p>After removing those options, two TypeScript 6 defaults (which 7 keeps) produced new errors:</p>
<pre><code class="language-text">error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be
explicitly set to this or another path to adjust your output's file layout.

src/index.ts(2,19): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node?
Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
</code></pre>
<p><code>rootDir</code> now defaults to <code>./</code>, and <code>types</code> now defaults to <code>[]</code>, so <code>@types/*</code> packages are no longer included automatically. This is the config that compiled and ran on TypeScript 7.0.2:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "outDir": "dist",
    "rootDir": "./src",
    "types": ["node"]
  },
  "include": ["src"]
}
</code></pre>
<p>Other defaults to know: <code>strict</code> is <code>true</code>, <code>module</code> is <code>esnext</code>, <code>noUncheckedSideEffectImports</code> is <code>true</code>, and <code>stableTypeOrdering</code> is always on.</p>
<h3>Language and JavaScript changes</h3>
<p>Template literal inference now keeps Unicode code points whole. This compiles on TypeScript 7 and fails on 6, which also makes it a quick way to prove which compiler a tool is running:</p>
<pre><code class="language-ts">// lib/which-ts.ts â€” compiles only on TypeScript 7
type HeadTail&lt;S&gt; = S extends `${infer Head}${infer Tail}` ? [Head, Tail] : never
export const check: HeadTail&lt;'ðŸ˜€abc'&gt; = ['ðŸ˜€', 'abc']
</code></pre>
<p>TypeScript 6 reports <code>Type '"ðŸ˜€"' is not assignable to type '"ï¿½"'</code>, because it splits the emoji into two UTF-16 halves.</p>
<p>JSDoc-typed JavaScript is also stricter: <code>@enum</code>, <code>@class</code>, a bare <code>?</code> type and Closure-style <code>function(string): void</code> are no longer recognized. The <a href="https://github.com/microsoft/typescript-go/blob/main/CHANGES.md">CHANGES.md</a> file tracks the full list.</p>
<h2>Does typescript-eslint work with TypeScript 7?</h2>
<p>No. <code>typescript-eslint</code> 8.71.2 (the latest on October 17, 2026) declares <code>typescript &gt;=4.8.4 &lt;6.1.0</code> as a peer dependency and throws at startup when it finds TypeScript 7. Because <code>eslint-config-next</code> uses it, <code>npm run lint</code> fails in every Next.js app that upgrades. The fix is to keep TypeScript 6 installed under the <code>typescript</code> name for the API.</p>
<p>This is the exact error from <code>npx eslint .</code> in a Next.js 16.4.2 app after <code>npm i -D typescript@7.0.2</code>:</p>
<pre><code class="language-text">typescript-eslint does not support TS 7.0.
Please see https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0 to run typescript-eslint using the TS 6 API.
See also https://github.com/typescript-eslint/typescript-eslint/issues/10940 for tracking typescript-eslint's support for TS &gt;=7.1

Oops! Something went wrong! :(
ESLint: 9.39.5
Error: typescript-eslint does not support TS 7.0.
</code></pre>
<p>Support is tracked in <a href="https://github.com/typescript-eslint/typescript-eslint/issues/10940">typescript-eslint issue #10940</a> and depends on the 7.1 API.</p>
<h2>How do I run TypeScript 7 and 6 side by side?</h2>
<p>Install TypeScript 7 under an alias such as <code>@typescript/native</code>, and install <code>@typescript/typescript6</code> under the <code>typescript</code> name. Tools that import <code>typescript</code> (ESLint, Vue, Svelte) get the 6.0 API, while <code>npx tsc</code> runs the TypeScript 7 binary. This is the setup the TypeScript team recommends in its announcement.</p>
<pre><code class="language-json">{
  "devDependencies": {
    "@typescript/native": "npm:typescript@^7.0.2",
    "typescript": "npm:@typescript/typescript6@^6.0.2"
  }
}
</code></pre>
<p>What I saw after <code>npm install</code>:</p>
<pre><code class="language-text">$ npx tsc -v
Version 7.0.2
$ npx tsc6 -v
Version 6.0.3
$ npx eslint .
(no output, exit 0)
</code></pre>
<p>The compatibility package ships a <code>tsc6</code> binary, so the two never fight over the name <code>tsc</code>.</p>
<h3>The catch: next build now uses TypeScript 6</h3>
<p>With the alias setup, <code>next build</code> type-checks with <strong>TypeScript 6</strong>, not 7. Next.js resolves the package named <code>typescript</code>, finds the compatibility package and runs its <code>tsc6</code>. I proved it with the emoji file above: <code>npx tsc</code> passed, but <code>next build</code> failed with the TypeScript 6 error:</p>
<pre><code class="language-text">  Running TypeScript ...
lib/which-ts.ts(3,42): error TS2322: Type '"ðŸ˜€"' is not assignable to type '"ï¿½"'.
Failed to type check.
</code></pre>
<p>So pick one of two setups until typescript-eslint supports 7.1:</p>
<table>
<thead>
<tr>
<th>Setup</th>
<th><code>next build</code> checks with</th>
<th>ESLint (type-aware)</th>
<th>Best for</th>
</tr>
</thead>
<tbody><tr>
<td><strong>A.</strong> <code>typescript@7</code> only</td>
<td>TypeScript 7 (fast)</td>
<td>Broken</td>
<td>Projects that lint with Biome or oxlint, or no type-aware lint</td>
</tr>
<tr>
<td><strong>B.</strong> Alias: <code>typescript</code> = TS 6, <code>@typescript/native</code> = TS 7</td>
<td>TypeScript 6</td>
<td>Works</td>
<td>Projects on <code>eslint-config-next</code></td>
</tr>
</tbody></table>
<p>With setup B, add <code>npx tsc --noEmit</code> as its own CI step to get TypeScript 7's speed for type checking. Next.js's <code>typescript.ignoreBuildErrors</code> would skip the slower check in <code>next build</code>, but only use it if that <code>tsc</code> step blocks the merge, because the <a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/typescript">Next.js docs</a> call that option "very dangerous" on its own.</p>
<img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792245724/fabwaseem/blog/typescript-7/diagram-flow.jpg" alt="Decision flow for upgrading to TypeScript 7: fix TypeScript 6 deprecations first, then if the project uses typescript-eslint, Vue, Svelte, Astro or MDX tooling, use the alias setup with tsc 7 in CI, otherwise install typescript@7 directly" style="display:block;margin:0 auto" />

<h2>Does Next.js support TypeScript 7?</h2>
<p>Yes. Since Next.js 16.3, <code>next build</code> can type-check with TypeScript 7, and in 16.4 it uses the project's <code>tsc</code> CLI by default (<code>experimental.useTypeScriptCli: true</code>), which works without the compiler API. Install <code>typescript@^7</code> and it is used automatically. In my test the "Running TypeScript" step dropped from 1,368 ms to 231 ms.</p>
<p>The <a href="https://nextjs.org/blog/next-16-3">Next.js 16.3 release post</a> says: "To start using TypeScript 7 for type checking during next build, just bump your project's local dependency." The <a href="https://nextjs.org/docs/app/api-reference/config/typescript">TypeScript config docs</a> add the trade-off: CLI type checking "prints the native tsc diagnostics" and "does not apply Next.js-specific code frames," and it checks every file your <code>tsconfig.json</code> includes, test files too.</p>
<p>My measurements on the Next.js 16.4.2 app from my <a href="https://www.fabwaseem.com/blog/nextjs-use-cache">use cache guide</a>:</p>
<table>
<thead>
<tr>
<th></th>
<th>TypeScript 5.9.3 (create-next-app default)</th>
<th>TypeScript 7.0.2</th>
</tr>
</thead>
<tbody><tr>
<td><code>npx tsc --noEmit</code></td>
<td>1.37 s</td>
<td>0.38 s</td>
</tr>
<tr>
<td><code>next build</code> "Finished TypeScript in"</td>
<td>1,368 ms</td>
<td>231 ms</td>
</tr>
<tr>
<td>Real type error caught by <code>next build</code></td>
<td>Yes</td>
<td>Yes (<code>TS2322</code>, build fails)</td>
</tr>
</tbody></table>
<p><code>create-next-app</code> 16.4.2 still writes <code>"typescript": "^5"</code> into new projects, so new apps start on 5.9 until you upgrade. The app's default tsconfig (<code>moduleResolution: "bundler"</code>, no <code>baseUrl</code>) needed no changes for TypeScript 7.</p>
<h2>TypeScript 7 upgrade checklist</h2>
<p>To upgrade safely, get to TypeScript 6.0 with zero deprecation errors first, then install TypeScript 7, fix the <code>types</code> and <code>rootDir</code> defaults, decide how to keep the 6.0 API for ESLint and framework tooling, and add TypeScript 7 to CI. Library authors should also diff their generated <code>.d.ts</code> files before publishing.</p>
<ol>
<li><p><strong>Upgrade to TypeScript 6.0</strong> and remove <code>ignoreDeprecations</code>. Fix every <code>TS5101</code>/<code>TS5107</code> deprecation error; each becomes a hard TypeScript 7 error.</p>
</li>
<li><p><strong>Replace</strong> <code>baseUrl</code> with <code>./</code>-relative <code>paths</code>, and <code>moduleResolution: "node"</code> with <code>"bundler"</code> or <code>"nodenext"</code>.</p>
</li>
<li><p><strong>Install TypeScript 7</strong>: <code>npm i -D typescript@^7</code> (or the alias setup if step 6 applies).</p>
</li>
<li><p><strong>Add</strong> <code>"types": ["node"]</code> (plus <code>jest</code>, <code>bun</code> and so on) if globals like <code>process</code> stop resolving.</p>
</li>
<li><p><strong>Set</strong> <code>rootDir</code> when <code>tsconfig.json</code> sits above <code>src</code> and you emit files.</p>
</li>
<li><p><strong>Check API-dependent tools</strong>: typescript-eslint, <code>eslint-config-next</code>, Vue/Volar, Svelte, Astro, MDX, Angular templates. If you use any, keep <code>typescript</code> pointed at <code>@typescript/typescript6</code>.</p>
</li>
<li><p><strong>Add</strong> <code>npx tsc --noEmit</code> <strong>to CI</strong> and check that it runs version 7 (<code>npx tsc -v</code>).</p>
</li>
<li><p><strong>Tune parallelism</strong>: try <code>--checkers 1</code> or <code>--singleThreaded</code> on small CI runners and higher <code>--checkers</code> on large monorepos, and measure.</p>
</li>
<li><p><strong>Libraries: diff</strong> <code>.d.ts</code> <strong>output</strong> between 6 and 7 before publishing.</p>
</li>
<li><p><strong>Editor</strong>: install the TypeScript 7 VS Code extension, and use "Disable TypeScript 7 Language Server" for Vue or Svelte projects.</p>
</li>
</ol>
<h2>Common TypeScript 7 upgrade mistakes</h2>
<p>The most common mistakes are upgrading straight from 5.x without passing through 6.0's deprecation errors, assuming the alias setup makes <code>next build</code> use TypeScript 7, forgetting the new <code>types: []</code> default, and trusting a passing build while lint is silently skipped. Each produces either a confusing error or a slower build than you expected.</p>
<table>
<thead>
<tr>
<th>Mistake</th>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Skipping TypeScript 6</td>
<td>Wall of <code>TS5102</code>/<code>TS5108</code> errors</td>
<td>Upgrade to 6.0 first and clear its deprecations</td>
</tr>
<tr>
<td>Keeping <code>ignoreDeprecations: "6.0"</code></td>
<td>Still errors on 7</td>
<td>Remove it and fix the options</td>
</tr>
<tr>
<td><code>typescript@7</code> with <code>eslint-config-next</code></td>
<td>"typescript-eslint does not support TS 7.0"</td>
<td>Alias setup B</td>
</tr>
<tr>
<td>Alias setup but expecting fast <code>next build</code></td>
<td>Build still uses <code>tsc6</code></td>
<td>Run <code>npx tsc --noEmit</code> in CI as well</td>
</tr>
<tr>
<td>Relying on automatic <code>@types/*</code></td>
<td><code>Cannot find name 'process'</code> (TS2591)</td>
<td><code>"types": ["node"]</code></td>
</tr>
<tr>
<td>Upgrading a Vue, Svelte or Astro app's editor</td>
<td>Lost template type checking</td>
<td>Keep TypeScript 6 for the editor</td>
</tr>
<tr>
<td>Publishing a library without checking <code>.d.ts</code></td>
<td>Different declaration text</td>
<td>Diff and review before release</td>
</tr>
</tbody></table>
<p>If you deploy through Docker, rebuild without cache after changing the compiler. My <a href="https://www.fabwaseem.com/blog/nextjs-dockerfile">Next.js Dockerfile guide</a> shows a multi-stage build where <code>npm run build</code>, and with it the type check, runs in the builder stage.</p>
<p><strong>Related:</strong> Prisma 8 needs TypeScript 5.9 or newer, and its client type-checked cleanly on TypeScript 7.0.2 in my test. See <a href="https://www.fabwaseem.com/blog/prisma-8">Prisma 8 vs Prisma 7: should you upgrade?</a></p>
<p><strong>Related:</strong> on NestJS, TypeScript 7 isn't usable through the Nest CLI yet: <code>nest build</code> asks for TypeScript 6 until 7.1. Plain <code>tsc</code> 7 works. Details in my <a href="https://www.fabwaseem.com/blog/nestjs-12">NestJS 12 migration guide</a>.</p>
<p><strong>Related:</strong> Oxlint's type-aware mode runs on typescript-go too. On this site's code it matched all 80 typescript-eslint findings in 1.29 s vs 20.5 s; details in <a href="https://www.fabwaseem.com/blog/oxlint-vs-eslint">Oxlint vs Biome vs ESLint</a>.</p>
<h2>Key takeaways</h2>
<ul>
<li><p>TypeScript 7 is the native Go port of TypeScript, <code>latest</code> on npm since July 8, 2026 (7.0.2), with the same type system as 6.0.</p>
</li>
<li><p>On Hono's source it built 5.2x faster than TypeScript 6 on my 8-vCPU box, with identical JavaScript output.</p>
</li>
<li><p>Everything deprecated in TypeScript 6 is a hard error in 7: es5, node10 resolution, baseUrl, AMD/UMD modules, esModuleInterop false.</p>
</li>
<li><p>New defaults bite: types is [] and rootDir is ./, so add "types": ["node"] and an explicit rootDir.</p>
</li>
<li><p>typescript-eslint does not support TypeScript 7; keep @typescript/typescript6 under the typescript name until 7.1 ships an API.</p>
</li>
<li><p>Next.js 16.3+ type-checks with TypeScript 7 automatically, but the alias setup makes next build use TypeScript 6, so run npx tsc in CI too.</p>
</li>
<li><p>Library authors should diff .d.ts output, which can differ in quote style and typeof references.</p>
</li>
</ul>
<hr />
<p><em>Originally published on</em> <a href="https://www.fabwaseem.com/blog/typescript-7"><em>fabwaseem.com</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[NestJS 12 Migration Guide: Upgrading from v11, Tested and Benchmarked]]></title><description><![CDATA[NestJS 12 migration, tested: what nest upgrade changes, the TypeScript 6 and Jest fixes it misses, and v11 vs v12 benchmarks.]]></description><link>https://fabwaseem.hashnode.dev/nestjs-12-migration-guide-upgrading-from-v11-tested-and-benchmarked</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/nestjs-12-migration-guide-upgrading-from-v11-tested-and-benchmarked</guid><category><![CDATA[nestjs]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 18:55:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/45bea19d-f5dc-4361-84c2-c5bc9c6bed9c.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>NestJS 12</strong> is the first major release of the Node.js framework since v11. It was released on August 27, 2026, and 12.1.3 is the current version. The headline changes are ESM-only packages, built-in Standard Schema validation (so Zod, Valibot and ArkType work without a custom pipe), a rebuilt CLI with a <code>nest upgrade</code> command, Vitest and oxlint as defaults for new projects, and the new <code>@nestjs/observe</code> SDK. Existing CommonJS apps keep working.</p>
<p>The release notes cover what changed. This guide covers what happened when I actually upgraded. I built a small but realistic NestJS 11 API with class-validator DTOs, Joi config validation, lifecycle hooks, an <code>@Optional()</code> dependency and e2e tests. I ran <code>nest upgrade</code> on it, fixed everything that broke, and benchmarked v11 against v12 for startup, throughput and memory. I also measured Zod against class-validator, Jest against Vitest, ESLint against oxlint, TypeScript 6 against TypeScript 7, and NestJS 12 on Bun. Every number below comes from those runs.</p>
<h2>TL;DR: should I upgrade to NestJS 12?</h2>
<p>Yes, but budget an hour rather than a minute. <code>nest upgrade</code> bumped every package and rewrote my config module correctly, but the build and the test suite both failed afterwards. TypeScript 6 needs three tsconfig fixes, and Jest needs <code>--experimental-vm-modules</code> to load the ESM-only packages. Once those are fixed, request throughput is the same as v11 (within 5%), cold start is about 90 ms slower, and you can move to Zod validation, which was 28% faster than class-validator in my test.</p>
<table>
<thead>
<tr>
<th></th>
<th>NestJS 11.2.7</th>
<th>NestJS 12.1.3</th>
</tr>
</thead>
<tbody><tr>
<td>Package format</td>
<td>CommonJS</td>
<td>ESM only (CJS apps work via <code>require(esm)</code>)</td>
</tr>
<tr>
<td>Minimum Node.js to run</td>
<td>20</td>
<td>20.19+ or 22.12+</td>
</tr>
<tr>
<td>TypeScript in a new project</td>
<td>5.x</td>
<td>6.x (7.0 not yet supported by <code>nest build</code>)</td>
</tr>
<tr>
<td>Validation</td>
<td>class-validator <code>ValidationPipe</code></td>
<td>class-validator or Standard Schema (Zod, Valibot, ArkType)</td>
</tr>
<tr>
<td>New-project defaults</td>
<td>Jest, ESLint, CommonJS</td>
<td>Vitest, oxlint, ESM</td>
</tr>
<tr>
<td>Cold start, my API (median)</td>
<td>296 ms</td>
<td>386 ms (CJS), 363 ms (ESM)</td>
</tr>
<tr>
<td>Hello route, req/s</td>
<td>36,777</td>
<td>34,866 (CJS), 36,789 (ESM)</td>
</tr>
<tr>
<td>POST with validation, req/s</td>
<td>17,173</td>
<td>16,237 class-validator, 20,804 Zod</td>
</tr>
</tbody></table>
<h2>What's new in NestJS 12?</h2>
<p>The <a href="https://github.com/nestjs/nest/releases/tag/v12.0.0">NestJS v12.0.0 release</a> groups the changes into five areas:</p>
<ul>
<li><strong>ESM packages.</strong> Every core <code>@nestjs/*</code> package now ships as ESM. A CommonJS app loads them through Node's <code>require(esm)</code>, so you don't have to convert your own code.</li>
<li><strong>Standard Schema.</strong> <code>@Body()</code>, <code>@Query()</code>, <code>@Param()</code> and <code>@RawBody()</code> take a <code>schema</code> option. The new <code>StandardSchemaValidationPipe</code> and <code>StandardSchemaSerializerInterceptor</code> validate requests and responses with any <a href="https://standardschema.dev/">Standard Schema</a> library. <code>@nestjs/config</code> now accepts a Zod schema as <code>validationSchema</code> too.</li>
<li><strong>A rebuilt CLI.</strong> It adds <code>nest upgrade</code> (with <code>--dry-run</code>), <code>nest deploy</code>, Rspack as the default bundler for monorepos, and <code>bun</code> as a package manager. <code>nest new</code> scaffolds ESM, Vitest and oxlint by default.</li>
<li><strong>Native observability.</strong> <code>@nestjs/observe</code> hooks into Nest's own request lifecycle instead of patching the HTTP server like a generic APM agent.</li>
<li><strong>Smaller additions.</strong> These include <code>routeConflictPolicy</code> and <code>routeResolutionStrategy</code> for route shadowing, machine-readable <code>errorCode</code> on HTTP exceptions, structured logging params, and graceful shutdown in the Express adapter.</li>
</ul>
<p>The breaking changes that matter most for typical REST APIs are listed in the <a href="https://docs.nestjs.com/migration-guide">official migration guide</a>: ESM packages, lifecycle hook ordering by component hierarchy, <code>@Optional()</code> inheritance, <code>@nestjs/config</code> moving to Standard Schema (Joi must be v18+), NATS v3, <code>graphql-ws</code>-only subscriptions, and the removal of the legacy Terminus health indicator API.</p>
<h2>How I tested the NestJS 12 upgrade</h2>
<ul>
<li><strong>App:</strong> a NestJS 11.2.7 project scaffolded with CLI 11.0.24 (Express adapter), plus a <code>users</code> module. It has a class-validator DTO, <code>@nestjs/config</code> 4 with a Joi 17 schema and custom <code>validationOptions</code>, <code>onModuleInit</code> and <code>onApplicationBootstrap</code> hooks, a repository subclass that inherits an <code>@Optional()</code> constructor dependency, a <code>GET /users/me</code> route declared after <code>GET /users/:id</code>, and Jest e2e tests with supertest.</li>
<li><strong>Upgrade:</strong> CLI 12.0.8 <code>nest upgrade</code>, which installed NestJS 12.1.3, <code>@nestjs/config</code> 12.0.1 and Joi 18.3.0.</li>
<li><strong>Runtime:</strong> Node.js 24.22.0 on an 8 vCPU Xeon VM. Node.js 26.12.0 and Bun 1.4.3 were used for the cross-runtime checks.</li>
<li><strong>Benchmarks:</strong> hyperfine for startup and tooling times (20 runs for startup, 5 to 8 for tools). For HTTP I used oha with 64 connections, taking the median of three 10-second runs after a warm-up, with the server pinned to 2 cores and oha on the other 6.</li>
</ul>
<p>"Cold start" means the wall time to start the process, boot Nest, bind the port and close it again. Inside the process, <code>NestFactory.create()</code> plus <code>listen()</code> took 70 ms on v11 and 84 to 93 ms on v12. The rest is Node starting and loading modules.</p>
<h2>How do I upgrade to NestJS 12?</h2>
<p>The upgrade has five steps. The first trips almost everyone coming from a v11 project.</p>
<h3>Step 1: upgrade the CLI (and TypeScript with it)</h3>
<p>The guide says to upgrade the CLI first, both globally and in your project. On my v11 project the local install failed:</p>
<pre><code class="language-bash">$ npm i -D @nestjs/cli@latest @nestjs/schematics@latest
npm error ERESOLVE unable to resolve dependency tree
npm error Found: typescript@5.9.3
npm error peer typescript@"&gt;=6.0.0" from @nestjs/schematics@12.0.7
</code></pre>
<p><code>@nestjs/schematics</code> 12 needs TypeScript 6 or newer as a peer, and a v11 project has 5.x. Skipping the local install doesn't help either. The global v12 binary hands off to the project's local v11 CLI, which answers <code>Invalid command: upgrade</code>. The fix is to bump TypeScript in the same command:</p>
<pre><code class="language-bash">npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6
</code></pre>
<p>Use TypeScript 6, not 7. <code>typescript@latest</code> is 7.0.2 now, and the Nest CLI can't build with it yet (more on that below). The schematics also need Node.js 22.22.3+, 24.15+ or 26+, which is a higher floor than running the app.</p>
<h3>Step 2: run nest upgrade --dry-run</h3>
<pre><code class="language-bash">npx nest upgrade --dry-run
</code></pre>
<p>On my app, the dry run printed this report and changed nothing:</p>
<pre><code class="language-text">Changes applied:
  ✔ Updated NestJS packages: @nestjs/common@^12.0.0, @nestjs/core@^12.0.0, @nestjs/testing@^12.0.0,
    @nestjs/platform-express@^12.0.0, @nestjs/cli@^12.0.0, @nestjs/schematics@^12.0.0, @nestjs/config@^12.0.0
  ✔ Updated "ts-jest" from "^29.2.5" to "^29.4.0" (first release supporting Jest 30)
  ✔ /src/app.module.ts: moved "validationOptions" settings under "validationOptions.libraryOptions"
  ✔ Updated "joi" from "^17.13.8" to "^18.0.0" (Joi v18 implements Standard Schema, required by @nestjs/config v12)
Notes:
  ℹ Lifecycle hooks are now invoked by component hierarchy level (found in /src/users/users.service.ts)
Please review:
  ⚠ Jest: the NestJS 12 packages are ESM-only and Jest can only require() ESM modules on Node.js v24.9+
Action required:
  ➜ tsconfig.build.json does not set "rootDir". TypeScript 6 requires it explicitly (error TS5011)
</code></pre>
<p>It's a good report. It found the Joi schema, the config option that needs to move, and the file that uses lifecycle hooks. Add <code>--no-observe</code> (or <code>--observe</code>) to skip the prompt about setting up <code>@nestjs/observe</code>.</p>
<h3>Step 3: run nest upgrade</h3>
<pre><code class="language-bash">npx nest upgrade --no-observe
</code></pre>
<p>This took 6 seconds including the install. It changed 3 files: <code>package.json</code>, <code>package-lock.json</code> and one line in <code>app.module.ts</code>:</p>
<pre><code class="language-diff">-      validationOptions: { allowUnknown: true, abortEarly: true },
+      validationOptions: { libraryOptions: { allowUnknown: true, abortEarly: true } },
</code></pre>
<p>It doesn't touch your module format, so a CommonJS app stays CommonJS. It doesn't switch you to Vitest or oxlint either.</p>
<h3>Step 4: fix the TypeScript 6 errors</h3>
<p>After the upgrade, <code>nest build</code> failed with five errors:</p>
<pre><code class="language-text">error TS5011: The common source directory of 'tsconfig.build.json' is './src'. The 'rootDir' setting must be explicitly set
error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0.
src/users/create-user.dto.ts:3:14 - error TS2564: Property 'email' has no initializer and is not definitely assigned in the constructor.
</code></pre>
<p>These come from TypeScript 6, not from NestJS. TypeScript 6 turns <code>strict</code> on by default, which brings in <code>strictPropertyInitialization</code>, and that flags every class-validator DTO property. It also deprecates <code>baseUrl</code> and makes <code>rootDir</code> explicit. The v12 project template sets these options:</p>
<pre><code class="language-json">// tsconfig.json
{
  "compilerOptions": {
    "rootDir": ".",
    "strictPropertyInitialization": false
    // and delete "baseUrl": "./" unless you use path aliases
  }
}
</code></pre>
<pre><code class="language-json">// tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": { "rootDir": "./src" },
  "include": ["src"],
  "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
</code></pre>
<p>The upgrade report only mentioned <code>tsconfig.build.json</code>. My e2e tests use the base <code>tsconfig.json</code> through ts-jest, and they failed with the same TS5011 error until I added <code>"rootDir": "."</code> there too. If you use path aliases, replace <code>baseUrl</code> with <code>paths</code> entries relative to the tsconfig, or set <code>"ignoreDeprecations": "6.0"</code> for now. My <a href="https://www.fabwaseem.com/blog/typescript-7">TypeScript 7 upgrade guide</a> covers the rest of the 6.0 and 7.0 changes.</p>
<h3>Step 5: make Jest load the ESM packages</h3>
<p>Once it compiled, every Jest suite failed:</p>
<pre><code class="language-text">Must use import to load ES Module: node_modules/@nestjs/testing/index.js
  - Use Node v24.9+ where Jest supports require(esm) natively
</code></pre>
<p>I was already on Node 24.22, and Node 26.12 failed the same way. Jest 30.5.2 only takes its ESM path when <code>vm.SyntheticModule</code> exists, and Node only exposes it behind a flag. Setting that flag fixed all six tests:</p>
<pre><code class="language-json">"test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json"
</code></pre>
<p>(Use <code>cross-env</code> if you need Windows support.) The alternative is to move to Vitest, which is what new v12 projects use. More on that below.</p>
<h2>Which NestJS 12 breaking changes actually bit?</h2>
<h3>@Optional() inheritance depends on the exact v12 version</h3>
<p>The migration guide says a subclass without its own constructor no longer inherits its parent's <code>@Optional()</code> markers, and that Nest now throws <code>UnknownDependenciesException</code> where v11 injected <code>undefined</code>. I tested this directly with <code>@nestjs/testing</code>:</p>
<table>
<thead>
<tr>
<th>Case</th>
<th>11.2.7</th>
<th>12.0.0 to 12.0.4</th>
<th>12.1.0 to 12.1.3</th>
</tr>
</thead>
<tbody><tr>
<td>Subclass with no constructor</td>
<td>resolves</td>
<td><strong>throws</strong></td>
<td>resolves</td>
</tr>
<tr>
<td>Subclass that redeclares the constructor without <code>@Optional()</code></td>
<td>resolves</td>
<td><strong>throws</strong></td>
<td><strong>throws</strong></td>
</tr>
</tbody></table>
<p>On v11, both subclasses silently got <code>undefined</code> for the missing dependency. 12.1.0 added a lookup that finds the class which actually declares the constructor, which brought the inherited markers back for subclasses without a constructor. A subclass that redeclares its constructor without <code>@Optional()</code> throws on every v12 release, and on early 12.0.x releases the constructor-less subclass throws too. Declare the marker again on the subclass:</p>
<pre><code class="language-typescript">@Injectable()
class UsersRepo extends BaseRepo {
  constructor(@Optional() @Inject(AUDIT) audit?: AuditLog) {
    super(audit);
  }
}
</code></pre>
<h3>Lifecycle hook order</h3>
<p><code>nest upgrade</code> flagged my hooks file. The order of <code>onModuleInit</code> and <code>onApplicationBootstrap</code> across my repository and service was identical on v11 and v12. The change only affects providers at different levels of the component hierarchy. If your teardown or startup code depends on another provider's hook having run, add a test that checks the order.</p>
<h3>Route shadowing is still opt-in</h3>
<p><code>GET /users/me</code> declared after <code>GET /users/:id</code> still returned the <code>:id</code> handler's 400 error on v12, the same as v11. The new options are off by default. Turn them on to get warnings and literal-before-parameter ordering:</p>
<pre><code class="language-typescript">const app = await NestFactory.create(AppModule, {
  routeConflictPolicy: { duplicate: 'error', shadow: 'warn' },
  routeResolutionStrategy: 'specificity',
});
</code></pre>
<p>With both set, <code>/users/me</code> returned <code>{"me":true}</code>, and Nest logged a <code>WARN</code> line for each literal and parameter route pair that overlaps. I recommend turning both on in new apps.</p>
<h2>How do I use Zod for validation in NestJS 12?</h2>
<p>Add a <code>schema</code> to the parameter decorator and register <code>StandardSchemaValidationPipe</code>. No custom pipe or extra package is needed:</p>
<pre><code class="language-typescript">import { z } from 'zod';

export const createUserSchema = z.object({
  email: z.email(),
  name: z.string().min(2),
  age: z.number().int().min(13).max(120),
});
export type CreateUser = z.infer&lt;typeof createUserSchema&gt;;

@Controller('zusers')
export class ZUsersController {
  constructor(private users: UsersService) {}

  @Post()
  create(@Body({ schema: createUserSchema }) body: CreateUser) {
    return this.users.create(body);
  }

  @Get(':id')
  find(@Param('id', { schema: z.coerce.number().int().positive() }) id: number) {
    return this.users.find(id);
  }
}
</code></pre>
<pre><code class="language-typescript">// main.ts: both pipes can run side by side during a migration
app.useGlobalPipes(
  new ValidationPipe({ whitelist: true, transform: true }),
  new StandardSchemaValidationPipe(),
);
</code></pre>
<p>With both pipes registered globally, class-validator DTO routes and Zod routes work in the same app. Each pipe only acts on parameters it recognises, so you can migrate one controller at a time. Invalid input returns the usual 400 response, with messages prefixed by field path:</p>
<pre><code class="language-json">{"message":["email: Invalid email address","name: Too small: expected string to have &gt;=2 characters","age: Too small: expected number to be &gt;=13"],"error":"Bad Request","statusCode":400}
</code></pre>
<p>Unknown keys are stripped by default, as with <code>whitelist: true</code>, because <code>z.object</code> drops them.</p>
<p>On the same app, server and load, <code>POST /zusers</code> with Zod 4.6.7 handled <strong>20,804 req/s</strong>, and <code>POST /users</code> with class-validator handled <strong>16,237 req/s</strong>. That makes Zod 28% faster, with p99 latency of 6.2 ms against 7.3 ms. Zod also gives you a single source for the TypeScript type, the validation and the OpenAPI schema. On v12 I'd use it for new endpoints. class-validator stays fully supported, so there's no need to rewrite working DTOs.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792260713/fabwaseem/blog/nestjs-12/diagram-perf.jpg" alt="NestJS 11 vs 12 benchmark: cold start 296 ms on v11, 386 ms on v12 CommonJS, 363 ms on v12 ESM; requests per second for hello, GET user and POST with validation, with Zod at 20,804 vs class-validator at 16,237" /></p>
<p>Machine-readable error codes come with v12 too. <code>throw new BadRequestException('Password is too weak', { errorCode: 'WEAK_PASSWORD' })</code> returned <code>{"message":"Password is too weak","error":"Bad Request","statusCode":400,"errorCode":"WEAK_PASSWORD"}</code>, so clients can branch on a stable code instead of parsing the message.</p>
<h2>Is NestJS 12 slower than NestJS 11?</h2>
<p>Request handling is not slower. Startup is.</p>
<table>
<thead>
<tr>
<th>Metric (Node 24.22)</th>
<th>v11.2.7</th>
<th>v12.1.3 CJS</th>
<th>v12.1.3 ESM</th>
</tr>
</thead>
<tbody><tr>
<td>Cold start, median</td>
<td>296 ms</td>
<td>386 ms</td>
<td>363 ms</td>
</tr>
<tr>
<td>Peak RSS during start</td>
<td>94 MB</td>
<td>108 MB</td>
<td>108 MB</td>
</tr>
<tr>
<td>Import core + common + platform-express</td>
<td>212 ms</td>
<td>262 ms</td>
<td>256 ms</td>
</tr>
<tr>
<td><code>GET /</code> hello, req/s</td>
<td>36,777</td>
<td>34,866</td>
<td>36,789</td>
</tr>
<tr>
<td><code>GET /users/:id</code>, req/s</td>
<td>29,998</td>
<td>28,387</td>
<td>30,251</td>
</tr>
<tr>
<td><code>POST /users</code> + class-validator, req/s</td>
<td>17,173</td>
<td>16,237</td>
<td>16,921</td>
</tr>
</tbody></table>
<p>Throughput moved by 5% or less in either direction, which is within the run-to-run variation. Cold start grew by about 90 ms (30%) in a CommonJS app, and most of that is the framework's own import cost: 50 ms more just to load the three core packages. The CJS app's <code>require(esm)</code> path is not the main cause, because the same app converted to ESM was only 23 ms faster. Adding Zod and a second controller added another 44 ms.</p>
<p>For a long-running API, 90 ms at boot doesn't matter. For serverless and scale-to-zero it does, and two cheap fixes help:</p>
<ul>
<li><strong>Enable Node's compile cache.</strong> With <code>NODE_COMPILE_CACHE=/tmp/node-cache</code>, v12 started in 338 ms instead of 386 ms, and v11 in 257 ms.</li>
<li><strong>Run on Bun.</strong> The same v12 ESM build started in 264 ms on Bun 1.4.3, faster than v11 on Node.</li>
</ul>
<p>Node 26.12 started the v12 app in 355 ms, close to Node 24 with the compile cache.</p>
<h2>Should I convert my NestJS app to ESM?</h2>
<p>You don't have to: <code>nest upgrade</code> leaves CommonJS apps alone, and they run fine. Converting gave me 23 ms of startup and nothing else measurable. Do it when a dependency you need is ESM-only and uses top-level await (which <code>require(esm)</code> can't load), or when you want Vitest without extra configuration.</p>
<p>The conversion took me ten minutes on a small app:</p>
<ol>
<li>Add <code>"type": "module"</code> to <code>package.json</code>. Projects generated with <code>@nestjs/schematics</code> 11.0.6+ already use <code>"module": "nodenext"</code>, so tsconfig needs no other change.</li>
<li>Add <code>.js</code> to every relative import (<code>'./app.module.js'</code>). A one-line regex changed 9 files.</li>
<li>Replace <code>__dirname</code> with <code>import.meta.dirname</code> and call <code>await bootstrap()</code>.</li>
<li>Fix namespace imports of CommonJS packages. My build passed, but startup crashed with <code>TypeError: Joi.string is not a function</code> until I changed <code>import * as Joi from 'joi'</code> to <code>import Joi from 'joi'</code>. TypeScript doesn't catch this one, so start the app after converting.</li>
</ol>
<h2>Jest vs Vitest and ESLint vs oxlint in NestJS 12</h2>
<p>A new v12 project (<code>nest new</code>, which defaulted to ESM when run without a TTY) gets this <code>package.json</code>: <code>"type": "module"</code>, <code>vitest</code> 4 with <code>vite</code> 8, <code>oxlint</code> plus <code>oxlint-tsgolint</code> for type-aware rules, and TypeScript 6. There's no SWC plugin. I copied the template's Vitest config into my converted app, and all six e2e tests passed unchanged, including DI that depends on decorator metadata.</p>
<table>
<thead>
<tr>
<th>Task (same tests or same files)</th>
<th>Old default</th>
<th>New default</th>
</tr>
</thead>
<tbody><tr>
<td>e2e suite, NestJS 11 + Jest 30</td>
<td>1.22 s</td>
<td></td>
</tr>
<tr>
<td>e2e suite, NestJS 12</td>
<td>1.62 s (Jest + vm-modules)</td>
<td><strong>0.98 s (Vitest 4.1)</strong></td>
</tr>
<tr>
<td>Lint <code>src/</code> and <code>test/</code></td>
<td>2.14 s (ESLint 9 + typescript-eslint)</td>
<td><strong>0.39 s (oxlint 1.88, type-aware)</strong></td>
</tr>
<tr>
<td>Type-check (<code>tsc --noEmit</code>)</td>
<td>0.98 s (TypeScript 6.0.3)</td>
<td><strong>0.31 s (TypeScript 7.0.2)</strong></td>
</tr>
</tbody></table>
<p>Jest got 33% slower on v12 because of the ESM loading path. Vitest was 40% faster than that, and 20% faster than Jest on v11. oxlint was 5.5 times faster than ESLint on this small codebase, and the gap grows with more files. It's not a drop-in replacement, though. ESLint reported 61 problems (35 of them Prettier formatting and 25 <code>no-unsafe-*</code> rules), while oxlint reported one warning that ESLint didn't (<code>no-misused-spread</code>). Keep Prettier as a separate step if you move to oxlint.</p>
<h2>Does NestJS 12 support TypeScript 7?</h2>
<p>You can't build NestJS 12 with TypeScript 7 through the Nest CLI yet. With <code>typescript@7.0.2</code> installed, <code>nest build</code> stops with:</p>
<pre><code class="language-text">Error  The installed TypeScript version (7.0.2) does not expose the programmatic compiler API that the Nest CLI requires.
TypeScript 7.0 ships the "tsc" executable only; the compiler API is expected to return in 7.1.
Please install TypeScript 6 (e.g. "npm i -D typescript@^6") until then.
</code></pre>
<p><code>nest build -b swc</code> stops with the same error. Plain <code>tsc -p tsconfig.build.json</code> from TypeScript 7 compiled the app correctly, and the output booted normally. So if you want the 3x faster type-check now, run <code>tsc</code> 7 for type-checking and builds in CI, and keep TypeScript 6 for <code>nest start --watch</code> until 7.1.</p>
<h2>Can I run NestJS 12 on Bun?</h2>
<p>Yes. The compiled ESM app ran unchanged with <code>bun dist/main.js</code>, including class-validator, Joi config and Express. Under the same load test, Bun 1.4.3 beat Node 24.22:</p>
<table>
<thead>
<tr>
<th>NestJS 12.1.3 ESM app</th>
<th>Node 24.22</th>
<th>Bun 1.4.3</th>
</tr>
</thead>
<tbody><tr>
<td>Cold start</td>
<td>361 ms</td>
<td>264 ms</td>
</tr>
<tr>
<td><code>GET /</code> hello, req/s</td>
<td>36,789</td>
<td>54,867 (1.49x)</td>
</tr>
<tr>
<td><code>GET /users/:id</code>, req/s</td>
<td>30,251</td>
<td>48,553 (1.61x)</td>
</tr>
<tr>
<td><code>POST /users</code> + class-validator, req/s</td>
<td>16,921</td>
<td>22,158 (1.31x)</td>
</tr>
</tbody></table>
<p>The v12 CLI also accepts <code>bun</code> as a package manager. For the broader trade-offs (memory over time, APM agents, native addons), see my <a href="https://www.fabwaseem.com/blog/bun-vs-node">Bun vs Node benchmark</a>.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792260715/fabwaseem/blog/nestjs-12/diagram-steps.jpg" alt="NestJS 12 upgrade path: bump CLI with TypeScript 6, nest upgrade --dry-run, nest upgrade, fix tsconfig rootDir and strictPropertyInitialization, set Jest vm-modules or move to Vitest, then adopt Zod and route conflict policy" /></p>
<h2>NestJS 12 upgrade checklist</h2>
<ol>
<li>Make sure you're on Node.js 22.22.3+, 24.15+ or 26 for the CLI. The app itself needs only 20.19+ or 22.12+.</li>
<li>Run <code>npm i -D @nestjs/cli@latest @nestjs/schematics@latest typescript@^6</code>.</li>
<li>Run <code>npx nest upgrade --dry-run</code> and read the report.</li>
<li>Run <code>npx nest upgrade --no-observe</code> and review the diff.</li>
<li>tsconfig: add <code>rootDir</code> to both files, remove <code>baseUrl</code>, and set <code>strictPropertyInitialization: false</code> if you use class DTOs.</li>
<li>Tests: add <code>NODE_OPTIONS=--experimental-vm-modules</code> for Jest, or move to Vitest.</li>
<li>Search for subclasses that redeclare a constructor and add <code>@Optional()</code> where needed.</li>
<li>Run the app and hit every route, especially if you use NATS, GraphQL subscriptions, Terminus or custom pipes.</li>
<li>Optionally turn on <code>routeConflictPolicy</code> and <code>routeResolutionStrategy: 'specificity'</code>, write new endpoints with Zod, and set <code>NODE_COMPILE_CACHE</code> in production.</li>
</ol>
<p>On AWS Lambda's Node 20, 22 and 24 runtimes, <code>require(esm)</code> is off by default. Set <code>NODE_OPTIONS=--experimental-require-module</code> there for a CommonJS app.</p>
<h2>Common mistakes when upgrading to NestJS 12</h2>
<ul>
<li><strong>Installing <code>typescript@latest</code>.</strong> That's 7.0, and the Nest CLI refuses to build with it. Pin <code>^6</code> until TypeScript 7.1.</li>
<li><strong>Upgrading only the global CLI.</strong> The global binary hands off to the project's local v11 CLI, which doesn't know <code>upgrade</code>.</li>
<li><strong>Trusting the "Node 24.9+" Jest note.</strong> On Node 24.22 and 26.12, Jest 30 still needed <code>--experimental-vm-modules</code> to load <code>@nestjs/testing</code>.</li>
<li><strong>Fixing only <code>tsconfig.build.json</code>.</strong> Tests compile with <code>tsconfig.json</code>, which needs <code>rootDir</code> too.</li>
<li><strong>Converting to ESM and keeping <code>import * as X</code> for CommonJS packages.</strong> It compiles and then crashes at startup.</li>
<li><strong>Missing redeclared <code>@Optional()</code> markers.</strong> A subclass that redeclares its constructor now throws <code>UnknownDependenciesException</code> instead of injecting <code>undefined</code>, and 12.0.0 to 12.0.4 also throw for subclasses without a constructor. Use 12.1.x and re-mark optional parameters.</li>
<li><strong>Rewriting every DTO to Zod at once.</strong> Both pipes run side by side. Migrate one controller at a time.</li>
</ul>
<p>Related: if you're choosing a database layer for your Nest API, read <a href="https://www.fabwaseem.com/blog/drizzle-vs-prisma">Drizzle vs Prisma in 2026</a>. If you're exposing the API to AI agents, read <a href="https://www.fabwaseem.com/blog/mcp-vs-api">MCP vs API</a>.</p>
<p><strong>Related:</strong> NestJS on the Fastify adapter was 1.45 to 1.66× faster than on Express in my <a href="https://www.fabwaseem.com/blog/hono-vs-express">Hono vs Express vs Fastify benchmark</a>. And since <code>nest new</code> now scaffolds Oxlint, see <a href="https://www.fabwaseem.com/blog/oxlint-vs-eslint">Oxlint vs Biome vs ESLint</a>: Oxlint ran this site's ESLint rules 81× faster.</p>
<h2>Key takeaways</h2>
<ul>
<li>NestJS 12 shipped on August 27, 2026, with ESM-only packages, Standard Schema validation, a rebuilt CLI and <code>@nestjs/observe</code>. 12.1.3 is current.</li>
<li><code>nest upgrade --dry-run</code> gives an accurate report, but upgrade the local CLI together with <code>typescript@^6</code> or npm fails with ERESOLVE.</li>
<li>After the upgrade, fix TypeScript 6's <code>rootDir</code> (in both tsconfigs), <code>baseUrl</code> and strict DTO errors, and give Jest <code>--experimental-vm-modules</code>.</li>
<li>Throughput stayed within 5% of v11. Cold start grew by about 90 ms, and <code>NODE_COMPILE_CACHE</code> or Bun wins most of it back.</li>
<li>Zod through <code>@Body({ schema })</code> was 28% faster than class-validator, 20,804 vs 16,237 req/s, and both pipes can run in one app.</li>
<li>Vitest ran the same e2e suite 40% faster than Jest on v12, and oxlint linted 5.5 times faster than ESLint.</li>
<li>TypeScript 7 can type-check and build a Nest 12 app with plain <code>tsc</code>, but <code>nest build</code> needs TypeScript 6 until 7.1.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Stateless MCP Servers: What the 2026-07-28 Spec Changed and How to Migrate]]></title><description><![CDATA[MCP servers are stateless since the 2026-07-28 spec. What changed, a tested TypeScript SDK v2 server, and a migration list.]]></description><link>https://fabwaseem.hashnode.dev/stateless-mcp-servers-what-the-2026-07-28-spec-changed-and-how-to-migrate</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/stateless-mcp-servers-what-the-2026-07-28-spec-changed-and-how-to-migrate</guid><category><![CDATA[mcp]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 18:54:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/16490b76-7b58-4af6-8bff-8e821fb4ba82.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>MCP servers are stateless now. The <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">2026-07-28 Model Context Protocol specification</a> removed the <code>initialize</code> handshake and the <code>Mcp-Session-Id</code> header. Every request now carries its own protocol version and client capabilities, so any server instance can answer any request.</p>
<p>That is great for scaling and painful if your server kept state in the session. In this guide I build a stateless MCP server with the official TypeScript SDK v2 (<code>@modelcontextprotocol/server</code> 2.3.1). I run two copies behind different ports and send real requests to both with <code>curl</code> and the SDK client. You will see exactly what changed on the wire, how to replace session state with handles and <code>requestState</code>, and the errors you get when something is wrong. Every rule is cited to the spec.</p>
<h2>What is a stateless MCP server?</h2>
<p>A stateless MCP server handles every JSON-RPC request on its own, with no handshake and no session. Under the 2026-07-28 spec each request carries its protocol version, client capabilities and client info in <code>_meta</code>, plus <code>MCP-Protocol-Version</code>, <code>Mcp-Method</code> and <code>Mcp-Name</code> HTTP headers. Any instance behind a plain load balancer can answer it, with no sticky sessions or session store.</p>
<p>The official <a href="https://blog.modelcontextprotocol.io/posts/2026-07-28/">2026-07-28 release post</a> says it directly: "MCP is transforming from a bidirectional stateful protocol into a request/response stateless protocol," and it calls this "one of the most highly-requested features" from developers. It also says "any request can land on any instance behind a plain round-robin load balancer."</p>
<table>
<thead>
<tr>
<th>Term</th>
<th>Meaning in 2026-07-28</th>
</tr>
</thead>
<tbody><tr>
<td>Modern / legacy</td>
<td>Modern = per-request metadata (2026-07-28+). Legacy = <code>initialize</code> handshake (2025-11-25 and earlier)</td>
</tr>
<tr>
<td><code>_meta</code> envelope</td>
<td><code>io.modelcontextprotocol/protocolVersion</code>, <code>.../clientCapabilities</code>, <code>.../clientInfo</code> on every request</td>
</tr>
<tr>
<td><code>server/discover</code></td>
<td>New RPC every server must implement: supported versions, capabilities, identity</td>
</tr>
<tr>
<td>MRTR</td>
<td>Multi Round-Trip Requests: server returns <code>input_required</code>, client retries the same call with answers</td>
</tr>
<tr>
<td><code>requestState</code></td>
<td>Opaque string the server returns with <code>input_required</code> and the client echoes on retry</td>
</tr>
<tr>
<td>Dual-era</td>
<td>A server or client that speaks both modern and legacy versions</td>
</tr>
</tbody></table>
<h2>What changed in the MCP 2026-07-28 spec?</h2>
<p>The 2026-07-28 revision removed sessions, the <code>initialize</code> handshake, the HTTP GET stream, SSE resumability and server-to-client requests. It added <code>server/discover</code>, per-request <code>_meta</code>, required routing headers, a <code>resultType</code> on every result, cache fields on list results, <code>subscriptions/listen</code> for change notifications, and Multi Round-Trip Requests for elicitation and sampling.</p>
<p>The full list from the <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog">official changelog</a>, grouped by what you have to do:</p>
<table>
<thead>
<tr>
<th>Change</th>
<th>Before (2025-11-25)</th>
<th>Now (2026-07-28)</th>
<th>What to do</th>
</tr>
</thead>
<tbody><tr>
<td>Sessions</td>
<td><code>Mcp-Session-Id</code> header, DELETE to end</td>
<td>Removed</td>
<td>Delete session stores and sticky routing; use explicit handles</td>
</tr>
<tr>
<td>Handshake</td>
<td><code>initialize</code> + <code>notifications/initialized</code></td>
<td>Removed; version and capabilities in every request's <code>_meta</code></td>
<td>Let the SDK attach <code>_meta</code>; read capabilities per request</td>
</tr>
<tr>
<td>Discovery</td>
<td>Capabilities from <code>initialize</code></td>
<td><code>server/discover</code> (servers <strong>must</strong> implement it)</td>
<td>SDK v2 implements it for you</td>
</tr>
<tr>
<td>Server-to-client requests</td>
<td><code>elicitation/create</code>, <code>sampling/createMessage</code>, <code>roots/list</code> pushed by the server</td>
<td>Server returns <code>resultType: "input_required"</code>; client retries</td>
<td>Rewrite <code>elicitInput()</code> calls as <code>inputRequired(...)</code></td>
</tr>
<tr>
<td>Notifications stream</td>
<td>HTTP GET stream, <code>resources/subscribe</code></td>
<td><code>subscriptions/listen</code> (one long-lived POST stream)</td>
<td>Answer GET with 405</td>
</tr>
<tr>
<td>Resumability</td>
<td><code>Last-Event-ID</code> redelivery</td>
<td>Removed; broken stream = re-issue request</td>
<td>Make tools safe to retry</td>
</tr>
<tr>
<td>Headers</td>
<td><code>MCP-Protocol-Version</code></td>
<td>Plus required <code>Mcp-Method</code> and <code>Mcp-Name</code></td>
<td>Servers must reject header/body mismatches</td>
</tr>
<tr>
<td>Results</td>
<td>No type field</td>
<td>Required <code>resultType</code>: <code>"complete"</code> or <code>"input_required"</code></td>
<td>SDK sets it</td>
</tr>
<tr>
<td>Caching</td>
<td>None</td>
<td>Required <code>ttlMs</code> and <code>cacheScope</code> on list and read results</td>
<td>Set real cache hints</td>
</tr>
<tr>
<td>Removed methods</td>
<td><code>ping</code>, <code>logging/setLevel</code></td>
<td>Removed; log level per request in <code>_meta</code></td>
<td>Delete ping handlers</td>
</tr>
<tr>
<td>Errors</td>
<td>Resource not found <code>-32002</code></td>
<td><code>-32602</code>; new codes <code>-32020</code> to <code>-32022</code></td>
<td>Update error mapping</td>
</tr>
</tbody></table>
<p>The spec also deprecated Roots, Sampling, Logging, the old HTTP+SSE transport and Dynamic Client Registration. Under the new <a href="https://modelcontextprotocol.io/specification/2026-07-28/deprecated">feature lifecycle policy</a> deprecated features get a minimum twelve-month window before removal.</p>
<h2>Should an MCP server be stateless?</h2>
<p>Yes. Since 2026-07-28 the protocol itself is stateless, so every modern MCP server is stateless at the protocol level. Your application can still keep state, but in your own database, addressed by explicit handles that tools return and the model passes back. That works better for models too, because they can see and reuse the handle.</p>
<p>The release post explains why: "If your server needs to carry state across calls, mint an explicit handle from a tool and have the model pass it back as an argument. We found this works better than session state hidden in the transport - the model can see the handle and thread it between tools."</p>
<table>
<thead>
<tr>
<th></th>
<th>Stateful MCP (2025-era sessions)</th>
<th>Stateless MCP (2026-07-28)</th>
</tr>
</thead>
<tbody><tr>
<td>First request</td>
<td><code>initialize</code>, then <code>notifications/initialized</code></td>
<td>The real call (<code>tools/call</code>)</td>
</tr>
<tr>
<td>Load balancing</td>
<td>Sticky sessions or a shared session store</td>
<td>Plain round-robin</td>
</tr>
<tr>
<td>Serverless (Vercel, Lambda)</td>
<td>Awkward: session must survive cold starts</td>
<td>Natural fit</td>
</tr>
<tr>
<td>Where state lives</td>
<td>Hidden in the transport session</td>
<td>Your database, keyed by a handle the model can see</td>
</tr>
<tr>
<td>Asking the user for input</td>
<td>Server pushes <code>elicitation/create</code> mid-call</td>
<td>Server returns <code>input_required</code>; client retries</td>
</tr>
<tr>
<td>Broken stream</td>
<td>Resume with <code>Last-Event-ID</code></td>
<td>Re-issue the request</td>
</tr>
</tbody></table>
<p>AWS's architecture team reached the same conclusion for their platform: the sticky sessions and shared session stores a session-based server needed "are no longer necessary" (<a href="https://aws.amazon.com/blogs/architecture/mcp-went-stateless-is-your-aws-mcp-server-deployment-well-architected/">AWS Architecture Blog</a>).</p>
<h2>How do I build a stateless MCP server in TypeScript?</h2>
<p>Install <code>@modelcontextprotocol/server</code> v2, write a factory that builds a fresh <code>McpServer</code> and registers your tools, and pass it to <code>createMcpHandler</code>. The handler serves 2026-07-28 requests and, by default, 2025-era requests too, all per request. On Node, wrap it with <code>toNodeHandler</code> from <code>@modelcontextprotocol/node</code>; on web runtimes, call <code>handler.fetch(request)</code>.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792245076/fabwaseem/blog/mcp-server-stateless/diagram-flow.jpg" alt="Stateless MCP request flow: an MCP client sends self-describing POST requests through a round-robin load balancer to any of several server instances, each building a fresh McpServer per request and sharing state through a database keyed by cart_id handles" /></p>
<h3>Step 1: Install</h3>
<p>Tested on Node.js 24.22.0:</p>
<pre><code class="language-bash">npm i @modelcontextprotocol/server@2.3.1 @modelcontextprotocol/node@2.1.1 @modelcontextprotocol/client@2.3.1 zod@^4
</code></pre>
<p>The SDK depends on Zod 4 and you import it as <code>zod/v4</code>.</p>
<h3>Step 2: Keep state in a store, not a session</h3>
<p>The demo is a tiny cart service. The cart lives in a shared store keyed by a server-minted handle. I used a JSON file so two processes share it; in production this is Postgres or Redis.</p>
<pre><code class="language-ts">// store.ts
import { readFileSync, writeFileSync, existsSync } from 'node:fs'

type Cart = { owner: string; items: { sku: string; qty: number }[]; createdAt: string }
const FILE = new URL('./data/carts.json', import.meta.url)

function load(): Record&lt;string, Cart&gt; {
  return existsSync(FILE) ? JSON.parse(readFileSync(FILE, 'utf8')) : {}
}
function save(all: Record&lt;string, Cart&gt;) {
  writeFileSync(FILE, JSON.stringify(all, null, 2))
}

export const carts = {
  create(owner: string): string {
    const all = load()
    const id = `cart_${crypto.randomUUID()}`
    all[id] = { owner, items: [], createdAt: new Date().toISOString() }
    save(all)
    return id
  },
  get(id: string, owner: string): Cart | undefined {
    const cart = load()[id]
    return cart &amp;&amp; cart.owner === owner ? cart : undefined // a handle is not a credential
  },
  update(id: string, cart: Cart) {
    const all = load()
    all[id] = cart
    save(all)
  },
}
</code></pre>
<p>Note the <code>owner</code> check. A handle such as <code>cart_...</code> travels through the model, so treat it as an identifier, never as proof of access. Always check it against the authenticated user.</p>
<h3>Step 3: Write the server</h3>
<pre><code class="language-ts">// server.ts
import { createServer } from 'node:http'
import {
  McpServer,
  createMcpHandler,
  createRequestStateCodec,
  inputRequired,
  acceptedContent,
} from '@modelcontextprotocol/server'
import type { CallToolResult, InputRequiredResult } from '@modelcontextprotocol/server'
import { toNodeHandler } from '@modelcontextprotocol/node'
import * as z from 'zod/v4'
import { carts } from './store.ts'

const PORT = Number(process.env.PORT ?? 3101)
const INSTANCE = process.env.INSTANCE ?? `instance-${PORT}`

// Shared by every instance, so state minted on one instance verifies on another.
const stateCodec = createRequestStateCodec&lt;{ step: 'confirm-clear'; cartId: string }&gt;({
  key: new TextEncoder().encode(process.env.MCP_STATE_KEY ?? 'dev-only-key-change-me-32-bytes!!'),
  ttlSeconds: 300,
})

function buildServer(): McpServer {
  const server = new McpServer(
    { name: 'shop-mcp', version: '1.0.0' },
    {
      requestState: { verify: stateCodec.verify },
      cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' } },
    },
  )
  // Demo principal. In a real server this comes from the verified bearer token (authInfo).
  const owner = 'demo-user'

  server.registerTool(
    'create_cart',
    { description: 'Create a shopping cart and return its cart_id handle. Pass cart_id to the other cart tools.' },
    async (): Promise&lt;CallToolResult&gt; =&gt; {
      const cartId = carts.create(owner)
      return {
        content: [{ type: 'text', text: `Created ${cartId} on ${INSTANCE}` }],
        structuredContent: { cart_id: cartId },
      }
    },
  )

  server.registerTool(
    'add_item',
    {
      description: 'Add a product to a cart created with create_cart',
      inputSchema: z.object({ cart_id: z.string(), sku: z.string(), qty: z.number().int().min(1) }),
    },
    async ({ cart_id, sku, qty }): Promise&lt;CallToolResult&gt; =&gt; {
      const cart = carts.get(cart_id, owner)
      if (!cart) return { content: [{ type: 'text', text: `Unknown cart_id ${cart_id}` }], isError: true }
      cart.items.push({ sku, qty })
      carts.update(cart_id, cart)
      return { content: [{ type: 'text', text: `${cart.items.length} line(s) in ${cart_id}, served by ${INSTANCE}` }] }
    },
  )

  const confirmSchema = z.object({ confirm: z.boolean() })
  server.registerTool(
    'clear_cart',
    {
      description: 'Remove every item from a cart after the user confirms',
      inputSchema: z.object({ cart_id: z.string() }),
    },
    async ({ cart_id }, ctx): Promise&lt;CallToolResult | InputRequiredResult&gt; =&gt; {
      const cart = carts.get(cart_id, owner)
      if (!cart) return { content: [{ type: 'text', text: `Unknown cart_id ${cart_id}` }], isError: true }
      const state = ctx.mcpReq.requestState&lt;{ step: 'confirm-clear'; cartId: string }&gt;()
      const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmSchema)
      if (state?.cartId !== cart_id || answer?.confirm !== true) {
        // Multi Round-Trip Request: ask the client, then expect a retry of this same call.
        return inputRequired({
          inputRequests: {
            confirm: inputRequired.elicit({
              message: `Remove all ${cart.items.length} line(s) from ${cart_id}?`,
              requestedSchema: confirmSchema,
            }),
          },
          requestState: await stateCodec.mint({ step: 'confirm-clear', cartId: cart_id }),
        })
      }
      cart.items = []
      carts.update(cart_id, cart)
      return { content: [{ type: 'text', text: `Cleared ${cart_id} on ${INSTANCE}` }] }
    },
  )
  return server
}

// Fresh McpServer per request; no sessions to store or route.
const handler = createMcpHandler(buildServer, { responseMode: 'json' })

createServer(toNodeHandler(handler)).listen(PORT, '127.0.0.1', () =&gt; {
  console.log(`${INSTANCE} listening on http://127.0.0.1:${PORT}/mcp`)
})
</code></pre>
<p>The file type-checks with TypeScript 7.0.2 in strict mode and runs directly with <code>node server.ts</code> on Node 24, which strips types natively. I started two instances to stand in for two containers behind a load balancer:</p>
<pre><code class="language-bash">PORT=3101 INSTANCE=A node server.ts &amp;
PORT=3102 INSTANCE=B node server.ts &amp;
</code></pre>
<p>On a Next.js App Router project the same handler goes in <code>app/api/mcp/route.ts</code> as <code>export const POST = (req: Request) =&gt; handler.fetch(req)</code>. My guide on <a href="https://www.fabwaseem.com/blog/build-mcp-server-nextjs">building an MCP server in Next.js</a> covers that route with Origin checks and bearer auth. To teach coding agents when and how to call your tools, pair the server with an <a href="https://www.fabwaseem.com/blog/agent-skills">Agent Skill</a>.</p>
<h2>What does a stateless MCP request look like?</h2>
<p>A stateless MCP request is a single HTTP POST with three headers, <code>MCP-Protocol-Version</code>, <code>Mcp-Method</code> and (for tool calls) <code>Mcp-Name</code>, and a JSON-RPC body whose <code>params._meta</code> repeats the protocol version and declares the client's capabilities. There is no earlier handshake: the first request a client sends can be the tool call itself.</p>
<p>This is the exact request my <code>curl</code> helper sends for <code>create_cart</code>:</p>
<pre><code class="language-http">POST /mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: create_cart

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_cart","arguments":{},
 "_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
          "io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8"},
          "io.modelcontextprotocol/clientCapabilities":{}}}}
</code></pre>
<h3>server/discover</h3>
<p>The SDK implements <code>server/discover</code> for you. Real response from instance A:</p>
<pre><code class="language-json">{"result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{"listChanged":true}},
 "resultType":"complete","ttlMs":0,"cacheScope":"private",
 "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"shop-mcp","version":"1.0.0"}}},
 "jsonrpc":"2.0","id":1}
</code></pre>
<h3>tools/list with cache hints</h3>
<p>Because I set <code>cacheHints</code>, the list result tells clients they may cache it for 60 seconds:</p>
<pre><code class="language-json">{"resultType":"complete","ttlMs":60000,"cacheScope":"public","tools":["create_cart","add_item","clear_cart"]}
</code></pre>
<p>Without a hint the SDK sends <code>ttlMs: 0</code> and <code>cacheScope: "private"</code>, the most conservative policy. If your tool list differs per user, keep <code>cacheScope</code> at <code>"private"</code>. The spec says <code>"public"</code> allows shared intermediaries to cache the response.</p>
<h3>One cart, two instances</h3>
<p>I created a cart on instance A, then added items through B and then A. Every call landed on a different process and nothing broke, because the state is in the store, not the connection:</p>
<pre><code class="language-text">create_cart  on A  → Created cart_d892bc05-… on A
add_item     on B  → 1 line(s) in cart_d892bc05-…, served by B
add_item     on A  → 2 line(s) in cart_d892bc05-…, served by A
</code></pre>
<h2>How do I replace session state when migrating?</h2>
<p>Replace session state in three ways. Data that outlives one call goes in your database under a handle a tool returns, such as <code>cart_id</code>. Data needed only between the rounds of one interaction goes in <code>requestState</code>, signed with HMAC. Per-user data comes from the auth token on each request, never from a session ID.</p>
<table>
<thead>
<tr>
<th>What you kept in the session</th>
<th>Where it goes now</th>
</tr>
</thead>
<tbody><tr>
<td>Shopping cart, open document, browser tab</td>
<td>Database row, addressed by a handle returned from a tool</td>
</tr>
<tr>
<td>"User already confirmed step 1"</td>
<td><code>requestState</code>, minted with <code>createRequestStateCodec</code></td>
</tr>
<tr>
<td>Who the user is</td>
<td>Bearer token / OAuth on every request (<code>authInfo</code>)</td>
</tr>
<tr>
<td>Client capabilities</td>
<td><code>_meta</code> of the current request</td>
</tr>
<tr>
<td>Log level</td>
<td><code>io.modelcontextprotocol/logLevel</code> in the request's <code>_meta</code></td>
</tr>
</tbody></table>
<p>The TypeScript SDK's <a href="https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/support-2026-07-28.md">2026-07-28 migration notes</a> state that <code>requestState</code> "round-trips through the client and is therefore untrusted input" and tell you to integrity-protect it. <code>createRequestStateCodec</code> does that with HMAC-SHA256, and the notes add that it is "signed, not encrypted", so never put secrets in it.</p>
<h2>How do elicitation and confirmations work without sessions?</h2>
<p>Without sessions the server can no longer push <code>elicitation/create</code> in the middle of a call. Instead it returns a result with <code>resultType: "input_required"</code>, the questions in <code>inputRequests</code>, and an optional <code>requestState</code>. The client asks the user, then retries the same call with <code>inputResponses</code> and the <code>requestState</code> echoed back. This pattern is called Multi Round-Trip Requests (MRTR).</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792245079/fabwaseem/blog/mcp-server-stateless/diagram-mrtr.jpg" alt="Multi Round-Trip Request sequence: the client calls clear_cart on instance B, B returns input_required with inputRequests and a signed requestState, the client asks the user, then retries the same call with inputResponses and requestState on instance A, which verifies the HMAC and returns complete" /></p>
<p>Round 1, sent to instance B with <code>clientCapabilities: {"elicitation":{"form":{}}}</code>:</p>
<pre><code class="language-json">{"result":{"resultType":"input_required",
 "inputRequests":{"confirm":{"method":"elicitation/create","params":{
   "message":"Remove all 2 line(s) from cart_d892bc05-…?",
   "requestedSchema":{"type":"object","properties":{"confirm":{"type":"boolean"}},"required":["confirm"]},
   "mode":"form"}}},
 "requestState":"v1.eyJwIjp7InN0ZXAiOiJjb25maXJtLWNsZWFyIiwi…"}}
</code></pre>
<p>Round 2, the retry, sent to instance <strong>A</strong> with <code>"inputResponses":{"confirm":{"action":"accept","content":{"confirm":true}}}</code> and the same <code>requestState</code>:</p>
<pre><code class="language-json">{"result":{"content":[{"type":"text","text":"Cleared cart_d892bc05-… on A"}],"resultType":"complete"}}
</code></pre>
<p>Instance A verified state minted by instance B because both share <code>MCP_STATE_KEY</code>. When I changed one character of <code>requestState</code>, the server rejected it:</p>
<pre><code class="language-json">{"jsonrpc":"2.0","id":1,"error":{"code":-32602,"message":"Invalid or expired requestState","data":{"reason":"invalid_request_state"}}}
</code></pre>
<p>With the SDK client you don't write the retry yourself. It answers <code>input_required</code> through your <code>elicitation/create</code> handler and retries automatically:</p>
<pre><code class="language-ts">// client.ts
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'

const client = new Client(
  { name: 'shop-client', version: '1.0.0' },
  {
    versionNegotiation: { mode: 'auto' }, // the v2 client defaults to the 2025 handshake
    capabilities: { elicitation: { form: {} } },
  },
)

client.setRequestHandler('elicitation/create', async (request) =&gt; {
  console.log('server asks:', request.params.message)
  return { action: 'accept', content: { confirm: true } }
})

await client.connect(new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3101/mcp')))
console.log('era:', client.getProtocolEra())
</code></pre>
<p>Output:</p>
<pre><code class="language-text">era: modern
server asks: Remove all 1 line(s) from cart_3ab350d7-…?
[ { type: 'text', text: 'Cleared cart_3ab350d7-… on A' } ]
</code></pre>
<h2>What errors does a stateless MCP server return?</h2>
<p>A 2026-07-28 server returns HTTP 400 with JSON-RPC code <code>-32020</code> when headers are missing or disagree with the body, <code>-32022</code> for an unsupported protocol version, and <code>-32021</code> when a tool needs a client capability the request did not declare. It returns 404 with <code>-32601</code> for unknown or removed methods such as <code>ping</code>, and 405 for GET.</p>
<p>All of these are real responses from my server:</p>
<table>
<thead>
<tr>
<th>Request</th>
<th>HTTP</th>
<th>JSON-RPC error</th>
</tr>
</thead>
<tbody><tr>
<td><code>Mcp-Name: add_item</code> but body <code>name: create_cart</code></td>
<td>400</td>
<td><code>-32020</code> "the request headers and body disagree"</td>
</tr>
<tr>
<td><code>tools/call</code> without <code>Mcp-Name</code></td>
<td>400</td>
<td><code>-32020</code> "the required Mcp-Name header is absent"</td>
</tr>
<tr>
<td><code>MCP-Protocol-Version: 2027-01-01</code></td>
<td>400</td>
<td><code>-32022</code> "Unsupported protocol version", <code>data.supported: ["2026-07-28"]</code></td>
</tr>
<tr>
<td><code>clear_cart</code> without the elicitation capability</td>
<td>400</td>
<td><code>-32021</code> "the request's client capabilities do not declare the required capability"</td>
</tr>
<tr>
<td><code>ping</code> (removed in 2026-07-28)</td>
<td>404</td>
<td><code>-32601</code> "Method not found"</td>
</tr>
<tr>
<td><code>GET /mcp</code> (old notification stream)</td>
<td>405</td>
<td>"Method not allowed."</td>
</tr>
</tbody></table>
<p>The header check exists for security. The <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http">Streamable HTTP spec</a> says servers must reject mismatches because otherwise "a load balancer routing on the header value while the MCP server executes based on the body value" could be tricked.</p>
<h2>Will old MCP clients still work with my stateless server?</h2>
<p>Mostly yes, with one important exception. <code>createMcpHandler</code> defaults to <code>legacy: 'stateless'</code>, which answers 2025-era <code>initialize</code> and later requests per request without issuing a session ID. Ordinary tools work. Tools that need user input fail for old clients, because a per-request legacy connection cannot receive server-to-client requests.</p>
<p>The legacy <code>initialize</code> returned <code>200</code>, <code>content-type: text/event-stream</code> and no <code>Mcp-Session-Id</code> header:</p>
<pre><code class="language-text">data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"shop-mcp","version":"1.0.0"}},"jsonrpc":"2.0","id":1}
</code></pre>
<p>Then I ran the same client with <code>versionNegotiation: { mode: 'legacy' }</code>. <code>create_cart</code> and <code>add_item</code> worked, but <code>clear_cart</code> returned:</p>
<pre><code class="language-text">Cannot request input 'confirm' (elicitation/create): the client on this 2025-era connection did not declare
the required capability (no client capabilities are available on this connection — per-request legacy
serving cannot receive server-to-client requests)
</code></pre>
<p>So if old clients need confirmations, keep your existing sessionful handler for them. The SDK's migration notes show how: put <code>isLegacyRequest(request)</code> in front of a <code>createMcpHandler(factory, { legacy: 'reject' })</code> entry and route legacy traffic to your old handler.</p>
<p>Two client-side facts worth knowing:</p>
<ul>
<li>The v2 <code>Client</code> <strong>defaults to the 2025 handshake</strong>. You must opt in with <code>versionNegotiation: { mode: 'auto' }</code> (probe with <code>server/discover</code>, fall back) or <code>{ pin: '2026-07-28' }</code>.</li>
<li>The <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning">versioning page</a> has a full compatibility matrix. A legacy client talking to a modern-only server fails, because, in the spec's words, "Legacy clients have no fall-forward mechanism."</li>
</ul>
<h2>Migration checklist: stateful to stateless MCP</h2>
<p>To migrate, upgrade to an SDK that implements 2026-07-28, switch to a per-request handler, move session data into handles, <code>requestState</code> or auth, and rewrite pushed elicitation as <code>input_required</code>. Then remove session infrastructure, set cache hints, and test with both modern and legacy clients before you drop sticky routing.</p>
<ol>
<li><strong>Upgrade the SDK.</strong> TypeScript: move from <code>@modelcontextprotocol/sdk</code> 1.x to <code>@modelcontextprotocol/server</code> 2.x (<code>createMcpHandler</code>). Python, Go and C# also have updated Tier 1 SDKs, per the release post.</li>
<li><strong>Find every use of the session ID</strong> (<code>extra.sessionId</code>, <code>ctx.sessionId</code>, maps keyed by <code>Mcp-Session-Id</code>) and move that data to a handle, <code>requestState</code> or the auth principal.</li>
<li><strong>Return handles from tools</strong> (<code>cart_id</code>, <code>job_id</code>, <code>doc_id</code>) and name them in tool descriptions so the model passes them back.</li>
<li><strong>Authorize every handle</strong> against the authenticated user.</li>
<li><strong>Rewrite <code>elicitInput()</code> / <code>requestSampling()</code></strong> as <code>return inputRequired({...})</code> and read <code>ctx.mcpReq.inputResponses</code> on re-entry.</li>
<li><strong>Seal <code>requestState</code></strong> with <code>createRequestStateCodec</code> and share the key across instances.</li>
<li><strong>Make tools safe to retry.</strong> Streams are no longer resumable, so a dropped connection means the client re-issues the request.</li>
<li><strong>Set <code>cacheHints</code></strong> for <code>tools/list</code> and resources, <code>private</code> if results vary per user.</li>
<li><strong>Delete <code>ping</code> handlers and per-session log level code</strong>; log to stderr or OpenTelemetry instead.</li>
<li><strong>Change resource-not-found errors</strong> from <code>-32002</code> to <code>-32602</code>.</li>
<li><strong>Test both eras</strong>: a modern client (<code>mode: 'auto'</code>) and a legacy client, including tools that ask for input.</li>
<li><strong>Only then remove</strong> sticky sessions, the session store and the GET route.</li>
</ol>
<h2>Common stateless MCP mistakes</h2>
<p>The most common mistakes are trusting a handle as authorization, putting secrets in <code>requestState</code>, forgetting the routing headers in a custom client, marking per-user lists as <code>public</code>, and assuming every old client can still answer elicitation. Each of these either fails with a <code>400</code> you can see or leaks data silently, so test them on purpose.</p>
<table>
<thead>
<tr>
<th>Mistake</th>
<th>What happens</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Treating <code>cart_id</code> as proof of access</td>
<td>Any user who sees an ID can use it</td>
<td>Check owner against the token on every call</td>
</tr>
<tr>
<td>Secrets in <code>requestState</code></td>
<td>Client can base64-decode it (signed, not encrypted)</td>
<td>Store an ID, keep the secret server-side</td>
</tr>
<tr>
<td>Different <code>MCP_STATE_KEY</code> per instance</td>
<td>Retries on another instance fail <code>-32602</code></td>
<td>Share one key across the fleet</td>
</tr>
<tr>
<td>Custom client skips <code>Mcp-Method</code> / <code>Mcp-Name</code></td>
<td><code>400</code>, <code>-32020</code></td>
<td>Send both headers, matching the body</td>
</tr>
<tr>
<td><code>cacheScope: 'public'</code> on per-user lists</td>
<td>Shared caches may serve one user's list to another</td>
<td>Use <code>private</code></td>
</tr>
<tr>
<td>v2 client left on defaults</td>
<td>Still speaks the 2025 handshake</td>
<td><code>versionNegotiation: { mode: 'auto' }</code></td>
</tr>
<tr>
<td>Elicitation tools behind <code>legacy: 'stateless'</code></td>
<td>Old clients get "cannot receive server-to-client requests"</td>
<td>Keep a sessionful legacy route until they upgrade</td>
</tr>
</tbody></table>
<p><strong>Related:</strong> stateless or not, every MCP call costs more than a REST call. I measured how much, in latency, throughput and tokens, in <a href="https://www.fabwaseem.com/blog/mcp-vs-api">MCP vs API: when you need which</a>.</p>
<h2>Key takeaways</h2>
<ul>
<li>The MCP 2026-07-28 spec made the protocol stateless: no <code>initialize</code>, no <code>Mcp-Session-Id</code>, every request self-describing.</li>
<li>Servers must implement <code>server/discover</code> and reject header/body mismatches with <code>-32020</code>.</li>
<li>Keep application state in your database behind handles that tools return; authorize every handle.</li>
<li>Elicitation is now Multi Round-Trip Requests: return <code>input_required</code>, the client retries with <code>inputResponses</code> and <code>requestState</code>.</li>
<li>Sign <code>requestState</code> with <code>createRequestStateCodec</code> and share the key so any instance can verify it.</li>
<li><code>createMcpHandler</code> also serves 2025-era clients per request, but they cannot answer elicitation on that path.</li>
<li>The v2 TypeScript client still defaults to the 2025 handshake; opt in with <code>versionNegotiation: { mode: 'auto' }</code>.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[MCP Security in 2026: The OAuth SDK CVEs, Tested, and How to Lock Down Your Server]]></title><description><![CDATA[MCP security in 2026: the OAuth SDK flaws (CVE-2026-104850) reproduced, why upgrading isn't enough, and how to secure an MCP server.]]></description><link>https://fabwaseem.hashnode.dev/mcp-security-in-2026-the-oauth-sdk-cves-tested-and-how-to-lock-down-your-server</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/mcp-security-in-2026-the-oauth-sdk-cves-tested-and-how-to-lock-down-your-server</guid><category><![CDATA[mcp]]></category><category><![CDATA[Security]]></category><category><![CDATA[oauth]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 18:53:03 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/63e792d6-8a91-4f1b-ae08-1b719741a48f.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong>MCP security</strong> in October 2026 mostly means OAuth. Within two days of each other, both official MCP SDKs, TypeScript and Python, published high-severity advisories for the same flaw: a malicious MCP server could make your client send its OAuth credentials to an authorization server the attacker controls. The Rust SDK had fixed a related bug two weeks earlier, a LiteLLM MCP authentication bypass reached CISA's list of actively exploited vulnerabilities, and Ars Technica reported prompt-injection chains between agents connected over MCP.</p>
<p>This guide covers the real advisories, with links and affected versions. I also reproduced the TypeScript SDK flaw on my own machine to show what leaks, which versions stop it, and the one setting most people will miss after upgrading. I then walk through how this site's own MCP server at <code>https://www.fabwaseem.com/api/mcp</code> handles OAuth, scopes and audit logging, with measurements. Nothing here is hypothetical: every vulnerability has a public advisory, and every number comes from a run.</p>
<h2>TL;DR: is MCP secure?</h2>
<p>The protocol's authorization spec is sound. The problems are in implementations: clients that trust whatever a server says, and servers that skip audience checks or expose too much. If you build an MCP client, upgrade and <strong>pass <code>expectedIssuer</code></strong>. In my lab, <code>@modelcontextprotocol/sdk</code> 1.31.0 and 1.32.1 still sent a client secret to the attacker's authorization server when <code>ClientCredentialsProvider</code> was created without it. If you run an MCP server, validate the token's audience, scope every tool and deny unmapped tools by default, and keep an audit log.</p>
<table>
<thead>
<tr>
<th>Advisory</th>
<th>Component</th>
<th>Affected</th>
<th>Fixed in</th>
<th>Severity</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-6qxp-vccf-f47h">CVE-2026-104850 / GHSA-6qxp-vccf-f47h</a></td>
<td><code>@modelcontextprotocol/sdk</code> and <code>@modelcontextprotocol/client</code> (TypeScript)</td>
<td>1.12.0 to 1.30.1; client 2.0.0 to 2.1.x</td>
<td>1.31.0; 2.2.0</td>
<td>High, CVSS 7.5</td>
</tr>
<tr>
<td><a href="https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-qx49-fqc8-xw99">GHSA-qx49-fqc8-xw99</a></td>
<td><code>mcp</code> (Python)</td>
<td>1.9.1 to 1.29.x; 2.0.0a1 to 2.1.x</td>
<td>1.30.0; 2.2.0</td>
<td>High, CVSS 7.5</td>
</tr>
<tr>
<td><a href="https://github.com/advisories/GHSA-33f5-2c5q-wgwj">CVE-2026-63127 / GHSA-33f5-2c5q-wgwj</a></td>
<td><code>rmcp</code> (Rust)</td>
<td>before 2.0.0</td>
<td>2.0.0</td>
<td>High, CVSS 8.2</td>
</tr>
<tr>
<td><a href="https://github.com/advisories/GHSA-7488-6r32-c95q">CVE-2026-59822 / GHSA-7488-6r32-c95q</a></td>
<td>LiteLLM MCP endpoint</td>
<td>before 1.84.0</td>
<td>1.84.0</td>
<td>High, on CISA KEV since Sep 2, 2026</td>
</tr>
<tr>
<td><a href="https://github.com/advisories/GHSA-6xpm-ggf7-wc3p">CVE-2025-6514 / GHSA-6xpm-ggf7-wc3p</a></td>
<td><code>mcp-remote</code></td>
<td>0.0.5 to 0.1.15</td>
<td>0.1.16</td>
<td>Critical, CVSS 9.6</td>
</tr>
<tr>
<td><a href="https://github.com/advisories/GHSA-7f8r-222p-6f5g">CVE-2025-49596 / GHSA-7f8r-222p-6f5g</a></td>
<td>MCP Inspector</td>
<td>before 0.14.1</td>
<td>0.14.1</td>
<td>Critical</td>
</tr>
</tbody></table>
<h2>What happened to MCP security in October 2026?</h2>
<p>Four separate issues landed in about a month, and three share a root cause: one side trusted what the other side said about who it was.</p>
<ul>
<li><strong>Both official SDKs: issuer confusion.</strong> In MCP's OAuth flow, the client asks the MCP server where to sign in, using <a href="https://datatracker.ietf.org/doc/html/rfc9728">protected resource metadata</a>. Affected SDK versions didn't tie stored credentials to the authorization server they came from. A malicious or compromised MCP server could name its own authorization server and receive the client's <code>client_secret</code>, signed client assertion or saved <code>refresh_token</code>, with no user interaction. The TypeScript advisory was published on September 30, 2026, and the Python one on September 28.</li>
<li><strong>Rust SDK: missing resource check.</strong> <code>rmcp</code> before 2.0.0 didn't check that the <code>resource</code> in a server's metadata matched the server it connected to. A malicious server could steer the OAuth flow to a real authorization server and walk away with a token for the real resource.</li>
<li><strong>LiteLLM: authentication bypass.</strong> The gateway's MCP endpoint let a request with a made-up <code>Authorization</code> header fall through to an OAuth2 passthrough path, which treated it as authenticated. CISA added it to the <a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog">Known Exploited Vulnerabilities catalog</a> on September 2, 2026.</li>
<li><strong>Agent-to-agent prompt injection.</strong> <a href="https://arstechnica.com/security/2026/10/vulnerability-in-agents-from-google-and-others-exposes-structural-flaw-in-mcp/">Ars Technica</a> reported research in which one compromised internal agent passed injected instructions to other agents that trusted it, leading in some cases to server-side request forgery (SSRF), for example through an HTTP client in Google's MCP Toolbox for Databases that followed redirects without checking where they went.</li>
</ul>
<p>The 2025 bugs are still worth knowing. <code>mcp-remote</code>, a popular OAuth proxy, passed a server-supplied authorization URL to the system shell (CVE-2025-6514), and early MCP Inspector versions ran an unauthenticated proxy that could run commands (CVE-2025-49596). The pattern hasn't changed: anything an MCP server sends you is untrusted input, including metadata, URLs and tool output.</p>
<h2>I reproduced the MCP SDK OAuth flaw locally</h2>
<p>I built a three-process lab on <code>127.0.0.1</code> with Node.js 24.22:</p>
<ul>
<li>a <strong>legitimate authorization server</strong> on port 4401, the only one the client should ever send its secret to;</li>
<li>a <strong>malicious MCP server</strong> on port 4402, which answers every request with <code>401</code> and protected resource metadata naming the attacker's authorization server;</li>
<li>the <strong>attacker's authorization server</strong> on port 4403, which logs every request it receives.</li>
</ul>
<p>The client used <code>StreamableHTTPClientTransport</code> with an auth provider in four configurations. In each one it connected to the malicious server while holding credentials meant only for the legitimate authorization server. I ran it against the last vulnerable release (1.30.1), the first fixed release (1.31.0) and the current release (1.32.1).</p>
<table>
<thead>
<tr>
<th>Client configuration</th>
<th>1.30.1</th>
<th>1.31.0</th>
<th>1.32.1</th>
</tr>
</thead>
<tbody><tr>
<td><code>ClientCredentialsProvider</code> without <code>expectedIssuer</code></td>
<td><strong>secret leaked</strong></td>
<td><strong>secret leaked</strong> (deprecation warning)</td>
<td><strong>secret leaked</strong></td>
</tr>
<tr>
<td><code>ClientCredentialsProvider</code> with <code>expectedIssuer</code></td>
<td><strong>secret leaked</strong></td>
<td>blocked</td>
<td>blocked</td>
</tr>
<tr>
<td>Stored refresh token saved <strong>without</strong> <code>issuer</code></td>
<td><strong>token + secret leaked</strong></td>
<td><strong>token + secret leaked</strong> (warning)</td>
<td><strong>token + secret leaked</strong></td>
</tr>
<tr>
<td>Stored refresh token saved <strong>with</strong> <code>issuer</code></td>
<td><strong>token + secret leaked</strong></td>
<td>blocked</td>
<td>blocked</td>
</tr>
</tbody></table>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792261508/fabwaseem/blog/mcp-security/diagram-lab.jpg" alt="MCP OAuth issuer confusion: a malicious MCP server names an attacker authorization server; SDK 1.30.1 leaks the client secret and refresh token in every configuration, while 1.31.0+ blocks it only when expectedIssuer or issuer-bound storage is used" /></p>
<p>Here's what the attacker's authorization server received from the vulnerable client in the stored-token case. These are the lab's dummy values:</p>
<pre><code class="language-json">{"authorization":"Basic ZGVtby1jbGllbnQ6ZGVtby1zZWNyZXQtZm9yLUxFR0lULUFTLW9ubHk=",
 "body":{"grant_type":"refresh_token","refresh_token":"legit-refresh-token-123",
         "resource":"http://127.0.0.1:4402/mcp"}}
</code></pre>
<p>The <code>Basic</code> header decodes to <code>demo-client:demo-secret-for-LEGIT-AS-only</code>. That's the client secret and a refresh token for the legitimate server, handed to the attacker.</p>
<p>With the fix configured, the 1.31.0 client refused before sending anything:</p>
<pre><code class="language-text">Error: OAuth client information is bound to authorization server http://127.0.0.1:4401
and is not presented to http://127.0.0.1:4403.
</code></pre>
<p>In the issuer-bound stored-token case, the attacker's server only received a metadata <code>GET</code> and a dynamic client registration <code>POST</code> containing the public client name and redirect URI. It got no secret and no token.</p>
<p>Two findings matter in practice:</p>
<ol>
<li><strong>Upgrading alone doesn't fix bundled providers.</strong> <code>ClientCredentialsProvider</code>, <code>PrivateKeyJwtProvider</code>, <code>StaticPrivateKeyJwtProvider</code> and <code>CrossAppAccessProvider</code> still follow the server's choice unless you pass <code>expectedIssuer</code>. 1.31.0 only prints a deprecation warning. The advisory says this explicitly, and my lab confirmed it on 1.32.1 too.</li>
<li><strong>Old saved tokens stay dangerous.</strong> Credentials saved before 1.31.0 carry no <code>issuer</code>, so the fixed SDK still uses them as-is. Clear them so users sign in again, or add <code>issuer</code> to them, and rotate any secret that may have reached an untrusted server.</li>
</ol>
<h2>How do I fix the MCP SDK OAuth vulnerability?</h2>
<p>For TypeScript clients:</p>
<pre><code class="language-bash">npm i @modelcontextprotocol/sdk@^1.31.0    # 1.x
npm i @modelcontextprotocol/client@^2.2.0  # 2.x
</code></pre>
<pre><code class="language-typescript">import { ClientCredentialsProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const authProvider = new ClientCredentialsProvider({
  clientId: process.env.MCP_CLIENT_ID!,
  clientSecret: process.env.MCP_CLIENT_SECRET!,
  expectedIssuer: 'https://auth.example.com', // the only AS allowed to see these credentials
});

const transport = new StreamableHTTPClientTransport(new URL('https://mcp.example.com/mcp'), { authProvider });
</code></pre>
<p>If you wrote your own <code>OAuthClientProvider</code>, store exactly what <code>saveTokens()</code> and <code>saveClientInformation()</code> receive, including the new <code>issuer</code> field. Return <code>issuer</code> from <code>clientInformation()</code> for pre-registered clients too. For Python, upgrade to <code>mcp</code> 1.30.0 or 2.2.0, and pass an explicit <code>issuer=</code> for machine-to-machine providers, as the <a href="https://workos.com/blog/mcp-auth-bugs-trusting-the-other-side">WorkOS write-up</a> of the three bugs explains.</p>
<p>Then:</p>
<ul>
<li>clear credentials saved before the upgrade, or add <code>issuer</code> to them;</li>
<li>rotate client secrets and signing keys, and revoke tokens, for any client that may have connected to an untrusted server;</li>
<li>remember that a <strong>new interactive sign-in</strong> still goes to whichever authorization server the MCP server names. Only complete sign-ins for servers you trust.</li>
</ul>
<h2>What does the MCP spec require for authorization?</h2>
<p>The <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization">2026-07-28 MCP authorization spec</a> already defends against these bugs. Implementations just have to follow it:</p>
<ul>
<li><strong>Resource indicators (RFC 8707).</strong> Clients MUST send <code>resource</code> in both the authorization and token requests, set to the MCP server's canonical URI.</li>
<li><strong>Audience validation.</strong> Servers MUST verify that a token was issued for them and reject everything else with <code>401</code>. Clients MUST NOT send a server tokens issued for something else, which rules out token passthrough.</li>
<li><strong>Issuer recording (RFC 9207).</strong> Before redirecting, the client MUST record the issuer from the validated authorization server metadata and check the <code>iss</code> returned on the redirect. Authorization servers SHOULD send <code>iss</code> and advertise <code>authorization_response_iss_parameter_supported</code>.</li>
<li><strong>PKCE with S256</strong>, and protected resource metadata (<a href="https://datatracker.ietf.org/doc/html/rfc9728">RFC 9728</a>) for discovery.</li>
</ul>
<p>The companion <a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/security_best_practices">security best practices page</a> covers the confused deputy problem, token passthrough, SSRF (including SSRF against authorization servers), session hijacking, local server compromise, authorization URL validation, mix-up attacks, localhost redirect impersonation, client ID metadata document trust policies and scope minimization. The October SDK flaw is a mix-up attack. If you write MCP auth code, read that page end to end.</p>
<h2>How does fabwaseem.com secure its own MCP server?</h2>
<p>This site runs a production MCP server for its CMS. It's how these posts get published. Here is what it does, verified against the live endpoint and the code. I've left out anything that isn't already public.</p>
<h3>Discovery and challenge</h3>
<p>An unauthenticated <code>tools/list</code> returns <code>401</code> with a standard challenge pointing at protected resource metadata:</p>
<pre><code class="language-text">HTTP/2 401
www-authenticate: Bearer resource_metadata="https://www.fabwaseem.com/.well-known/oauth-protected-resource",
  error="invalid_token", scope="projects:read ... site:read offline_access",
  error_description="Invalid or missing bearer token"
</code></pre>
<p>The resource metadata names exactly one authorization server, the site itself, and the authorization server metadata at <code>/.well-known/oauth-authorization-server</code> advertises:</p>
<ul>
<li><code>issuer: https://www.fabwaseem.com</code> and <code>authorization_response_iss_parameter_supported: true</code></li>
<li><code>code_challenge_methods_supported: ["S256"]</code> only, so no <code>plain</code> PKCE</li>
<li><code>grant_types_supported: ["authorization_code", "refresh_token"]</code> with public clients (<code>token_endpoint_auth_methods_supported: ["none"]</code>), so there's no client secret that could leak</li>
<li>dynamic client registration and <code>client_id_metadata_document_supported: true</code></li>
</ul>
<h3>Tokens bound to the resource</h3>
<p>The authorization code, the code exchange and every refresh check that the requested <code>resource</code> canonically matches <code>https://www.fabwaseem.com/api/mcp</code>. A mismatch returns <code>invalid_target</code>. PKCE verifiers are compared in constant time. Codes live for 5 minutes and access tokens for 1 hour. Refresh tokens rotate on every use, and reusing an old refresh token revokes the whole token family. Tokens, codes and refresh tokens are stored only as peppered SHA-256 hashes.</p>
<h3>SSRF-safe client metadata fetching</h3>
<p>Client ID metadata documents mean the server fetches a URL the client chooses. That's an SSRF risk, and the spec calls it out. The fetcher only accepts <code>https:</code> URLs with no credentials or fragments. It resolves DNS itself and rejects private and loopback addresses, then connects to the checked IP while keeping the original hostname for TLS, so DNS rebinding can't swap in an internal address after the check. It also caps the response at 64 KB and times out after 5 seconds. Redirect URIs must match exactly, and plain <code>http:</code> is only allowed for loopback hosts.</p>
<h3>Per-tool scopes, default deny</h3>
<p>Every one of the 23 tools maps to one scope (<code>posts:read</code>, <code>posts:write</code>, <code>posts:publish</code>, <code>history:undo</code> and so on). A <code>create_post</code> or <code>update_post</code> call that would publish the post additionally requires <code>posts:publish</code>. The gate is three rules:</p>
<pre><code class="language-typescript">// A tool without a scope mapping needs full access, so a new tool is never
// callable by a scoped token by accident.
const required = scopesForToolCall(name, args) ?? [ADMIN_SCOPE]
if (scopeAllows(granted, required)) return null
return { id, required, message: `Insufficient scope: ${required.join(" ")} is required` }
</code></pre>
<ul>
<li><strong>Unmapped tools require admin.</strong> Adding a tool can't silently widen what an existing token can do.</li>
<li><strong>Batches are all-or-nothing.</strong> Every message in a JSON-RPC batch is checked, and one denied call blocks the whole batch.</li>
<li><strong>Some scopes are never implied.</strong> <code>history:undo</code> must be granted explicitly, even to an admin token. When I listed tools with this site's full-access token, the server returned 22 of 23, hiding <code>undo_history</code>.</li>
</ul>
<p><code>tools/list</code> is filtered too, so a scoped client only sees tools it can call. That's a security and a context-window win. Measured on the live server and counted with the o200k_base tokenizer:</p>
<table>
<thead>
<tr>
<th>Token scopes</th>
<th>Tools visible</th>
<th>Tokens for <code>tools/list</code></th>
</tr>
</thead>
<tbody><tr>
<td>Full access</td>
<td>22</td>
<td>7,778</td>
</tr>
<tr>
<td><code>posts:read</code></td>
<td>4</td>
<td>680</td>
</tr>
<tr>
<td><code>posts:read</code> + <code>site:read</code></td>
<td>5</td>
<td>783</td>
</tr>
<tr>
<td>Blog writer (read, write, taxonomy, media)</td>
<td>10</td>
<td>4,308</td>
</tr>
<tr>
<td>Blog publisher (writer + <code>posts:publish</code>)</td>
<td>11</td>
<td>4,554</td>
</tr>
</tbody></table>
<p>A read-only agent loads 91% fewer tool-definition tokens and can't even see the write tools. For more on how much context MCP tool definitions cost, see <a href="https://www.fabwaseem.com/blog/mcp-vs-api">MCP vs API</a>.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/v1792261514/fabwaseem/blog/mcp-security/diagram-flow.jpg" alt="How fabwaseem.com's MCP server checks a tools/call request: bearer token hash lookup, audience bound to the canonical resource, per-tool scope gate with default deny, audit event with actor and diff, and undo" /></p>
<h3>Audit log and undo</h3>
<p>Every write, whether from MCP, REST or the admin UI, records an audit event with:</p>
<ul>
<li>the actor type (OAuth client, API token or admin session) and the token or client name;</li>
<li>the source, the tool or route, and the action;</li>
<li>the entity and a field-level diff;</li>
<li>a request ID, a hashed IP and the user agent.</li>
</ul>
<p>Raw IPs and token values are never stored, and the caller's scopes are used for the authorization check but never persisted. Events can be listed with <code>history:read</code> and reversed with <code>history:undo</code>. The 15 unit tests that cover the scope gate, PKCE, redirect matching, resource binding and private-address blocking all pass.</p>
<h2>MCP security checklist</h2>
<p><strong>If you build MCP clients:</strong></p>
<ol>
<li>Upgrade to <code>@modelcontextprotocol/sdk</code> 1.31.0+, <code>@modelcontextprotocol/client</code> 2.2.0+, <code>mcp</code> (Python) 1.30.0+ or 2.2.0+, or <code>rmcp</code> 2.0.0+.</li>
<li>Pass <code>expectedIssuer</code> (TypeScript) or <code>issuer=</code> (Python) to every machine-to-machine provider.</li>
<li>Clear or migrate credentials saved before the upgrade. Rotate secrets that may have reached untrusted servers.</li>
<li>Treat server metadata, authorization URLs and tool output as untrusted input. Never pass a server-supplied URL to a shell, which is the <code>mcp-remote</code> lesson.</li>
<li>Ask the user before connecting to a new server, and before anything destructive.</li>
</ol>
<p><strong>If you run MCP servers:</strong></p>
<ol>
<li>Validate the token audience against your canonical resource URI. Never forward client tokens to upstream APIs.</li>
<li>Support PKCE S256 only. Send <code>iss</code> on redirects and advertise it.</li>
<li>Give every tool a scope, deny unmapped tools, filter <code>tools/list</code>, and check every message in a batch.</li>
<li>Prefer public clients with PKCE over client secrets you'd have to protect.</li>
<li>Rotate refresh tokens, detect reuse, and store only hashes of tokens and codes.</li>
<li>Fetch client metadata URLs with SSRF protections: an HTTPS allow-list, IP checks after DNS, pinned connections, size and time limits.</li>
<li>Log every write with the actor, tool and diff, and make writes reversible.</li>
<li>Rate-limit token and registration endpoints, and keep the MCP Inspector and other dev proxies off public networks.</li>
</ol>
<h2>Common MCP security mistakes</h2>
<ul>
<li><strong>Upgrading the SDK and stopping there.</strong> Bundled credential providers stayed vulnerable in my lab on 1.31.0 and 1.32.1 until I passed <code>expectedIssuer</code>.</li>
<li><strong>Keeping pre-fix tokens.</strong> Tokens and client information saved without <code>issuer</code> are still sent wherever the server points.</li>
<li><strong>Token passthrough.</strong> Accepting any bearer token, or forwarding it to another API, breaks the audience boundary the spec relies on.</li>
<li><strong>Scopes on paper only.</strong> A <code>read</code> scope means nothing if <code>tools/call</code> doesn't check it, or if a new tool is open to every token by default.</li>
<li><strong>Trusting tool output.</strong> Text returned by a tool can carry instructions. Agents that chain tools or other agents need their own guardrails, as the Ars Technica research showed.</li>
<li><strong>Exposing dev tooling.</strong> An MCP Inspector or local proxy listening on a public interface is a remote shell waiting to happen.</li>
</ul>
<p>Related: building your own server? Start with <a href="https://www.fabwaseem.com/blog/build-mcp-server-nextjs">how to build an MCP server in Next.js</a> and <a href="https://www.fabwaseem.com/blog/mcp-server-stateless">stateless MCP servers under the 2026-07-28 spec</a>.</p>
<h2>Key takeaways</h2>
<ul>
<li>Both official MCP SDKs had a high-severity OAuth flaw (CVE-2026-104850 in TypeScript, GHSA-qx49-fqc8-xw99 in Python). A malicious MCP server could collect a client's secret or refresh token.</li>
<li>Fixed versions are TypeScript SDK 1.31.0 and client 2.2.0, Python <code>mcp</code> 1.30.0 and 2.2.0, and <code>rmcp</code> 2.0.0 for the related Rust bug.</li>
<li>In my local lab, 1.30.1 leaked credentials in all four configurations. 1.31.0 and 1.32.1 blocked the leak only with <code>expectedIssuer</code> or issuer-bound stored credentials.</li>
<li>Upgrading is not enough. Pass <code>expectedIssuer</code>, clear tokens saved before the fix, and rotate secrets that may have leaked.</li>
<li>The MCP spec already requires resource indicators, audience validation, PKCE and issuer checks. Most incidents come from skipping them.</li>
<li>On the server side, scope every tool, deny unmapped tools by default, filter <code>tools/list</code> and check batches. A read-only token on this site sees 4 tools and 680 tokens instead of 22 and 7,778.</li>
<li>Log every write with the actor, tool and diff, and make it undoable. Store only hashes of tokens and IPs.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[A secure NestJS + Prisma API starter, piece by piece]]></title><description><![CDATA[Helmet, CORS, rate limiting, CSRF, Pino logging and a Prisma error filter in a NestJS 11 + Prisma 7 API starter, explained with the real code.]]></description><link>https://fabwaseem.hashnode.dev/secure-nestjs-prisma-api-starter</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/secure-nestjs-prisma-api-starter</guid><category><![CDATA[nestjs]]></category><category><![CDATA[prisma]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 18:12:39 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/31924900-3276-4d81-bbd1-72e3e7e9f9fb.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most NestJS tutorials stop at <code>nest new</code> and a controller. The part that takes time is everything that wraps the controllers: security headers, a CORS policy that matches your frontends, rate limits, CSRF for cookie sessions, logs you can actually search, and errors that don't leak your database schema to the client.</p>
<p><a href="https://github.com/fabwaseem/nestjs-prisma-starter">nestjs-prisma-starter</a> is the base I use for that layer. It's NestJS 11 with Prisma 7 on PostgreSQL. This post walks through it one piece at a time, using the real code from the repository, and it's honest about the parts that still need finishing before production.</p>
<h2>Running it locally</h2>
<p>You need Node.js and a PostgreSQL database. The repository ships a <code>.env.example</code> with the two variables that are required, <code>DATABASE_URL</code> and <code>COOKIE_SECRET</code>:</p>
<pre><code class="language-bash">git clone https://github.com/fabwaseem/nestjs-prisma-starter.git
cd nestjs-prisma-starter
npm install
cp .env.example .env
npx prisma generate
npm run start:dev
</code></pre>
<p>Outside production the app serves Swagger at <code>/api/docs</code>, and <code>GET /health</code> tells you straight away whether the database connection works. Generate a long random value for <code>COOKIE_SECRET</code> rather than keeping the placeholder, because it signs your cookies.</p>
<h2>The bootstrap order matters</h2>
<p>Everything security-related is wired in <code>src/main.ts</code>, in a deliberate order: body limits, then Helmet, CORS, cookies, CSRF and compression, then the global prefix, versioning, validation, the exception filter and logging. Middleware in Express runs in registration order, so a request is size-checked and given security headers before any route code sees it.</p>
<p>The app is created with buffered logs so that early startup messages go through Pino instead of Nest's default console logger:</p>
<pre><code class="language-ts">const app = await NestFactory.create(AppModule, {
  bufferLogs: true,
});

const configService = app.get(ConfigService);
app.useLogger(app.get(Logger));

app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
</code></pre>
<p>The 10 MB body limit is generous for a JSON API. If your endpoints only take small payloads, lowering it is the cheapest denial-of-service protection you'll ever add.</p>
<h2>Helmet: security headers</h2>
<p>Helmet sets the response headers browsers use to lock a site down. The starter configures it explicitly instead of relying on defaults:</p>
<pre><code class="language-ts">app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: [`'self'`],
        styleSrc: [`'self'`, `'unsafe-inline'`],
        scriptSrc: [`'self'`, `'unsafe-inline'`],
        imgSrc: [`'self'`, 'data:', 'https:'],
      },
    },
    crossOriginEmbedderPolicy: process.env.NODE_ENV === 'production',
    crossOriginResourcePolicy: { policy: 'cross-origin' },
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
    noSniff: true,
    referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
    xssFilter: true,
    frameguard: { action: 'deny' },
  }),
);
</code></pre>
<p>A few of these deserve a comment:</p>
<ul>
<li><strong>HSTS</strong> is set for one year (<code>31536000</code> seconds) with <code>includeSubDomains</code> and <code>preload</code>. Preload is a commitment: once your domain is on the browser preload list, every subdomain must serve HTTPS. Turn it on only when that's true.</li>
<li><strong>CSP</strong> allows <code>'unsafe-inline'</code> for scripts and styles. A pure JSON API rarely serves HTML, so the policy mostly matters for the Swagger UI page in development. If you serve HTML from this app in production, tighten <code>scriptSrc</code>.</li>
<li><strong><code>crossOriginResourcePolicy: 'cross-origin'</code></strong> lets other origins load responses, which an API consumed by a separate frontend usually needs.</li>
<li><strong><code>frameguard: deny</code></strong> stops the responses from being framed, which closes off clickjacking.</li>
</ul>
<h2>CORS: an allow-list with credentials</h2>
<p>CORS is driven by an <code>ALLOWED_ORIGINS</code> environment variable, a comma-separated list:</p>
<pre><code class="language-ts">const allowedOrigins = configService
  .get&lt;string&gt;('ALLOWED_ORIGINS')
  ?.split(',') || ['http://localhost:3000', 'http://localhost:4200'];

app.enableCors({
  origin: (origin, callback) =&gt; {
    if (
      !origin ||
      allowedOrigins.indexOf(origin) !== -1 ||
      allowedOrigins.includes('*')
    ) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'X-Requested-With',
    'Accept',
    'X-CSRF-Token',
  ],
  exposedHeaders: ['X-Total-Count', 'X-Page-Count', 'Set-Cookie'],
  maxAge: 3600,
});
</code></pre>
<p>Three behaviors to know. A request with no <code>Origin</code> header is allowed, which is what curl, server-to-server calls and health checks send; CORS is a browser control, not authentication. Because <code>credentials: true</code> is on, the origin function echoes the specific origin back instead of <code>*</code>, so browsers will send cookies. And a <code>*</code> entry in the list allows every origin while still sending credentials, so never put <code>*</code> in production config. Also note that <code>split(',')</code> doesn't trim, so <code>a.com, b.com</code> with a space won't match <code>b.com</code>.</p>
<p><code>X-CSRF-Token</code> is in the allowed headers, which is the hook for the next piece.</p>
<h2>Cookies and CSRF</h2>
<p>CSRF only matters when the browser sends credentials automatically, which means cookie-based sessions. Bearer tokens in an <code>Authorization</code> header aren't sent automatically, so the starter makes CSRF opt-in with <code>CSRF_ENABLED=true</code>:</p>
<pre><code class="language-ts">app.use(cookieParser(configService.get&lt;string&gt;('COOKIE_SECRET')));

if (configService.get&lt;string&gt;('CSRF_ENABLED') === 'true') {
  app.use(
    csurf({
      cookie: {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
      },
    }),
  );

  app.use((req: any, res: any, next: any) =&gt; {
    res.cookie('XSRF-TOKEN', req.csrfToken(), {
      httpOnly: false,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
    });
    next();
  });
}
</code></pre>
<p>This is the classic pattern. The secret lives in an <code>httpOnly</code> cookie the page can't read. A second cookie, <code>XSRF-TOKEN</code>, is readable by JavaScript. The frontend copies its value into the <code>X-CSRF-Token</code> header on state-changing requests, and the middleware compares the two. Axios and Angular's HTTP client look for an <code>XSRF-TOKEN</code> cookie by name. Both cookies are <code>sameSite: 'strict'</code> and <code>secure</code> in production.</p>
<p>One caveat: the <code>csurf</code> package has been deprecated by its maintainers. It still works, but for a new project I'd plan a move to a maintained double-submit implementation, and keep the cookie and header names the same so the frontend doesn't change.</p>
<h2>Compression</h2>
<p>Responses over 1 KB are compressed, and a client can opt out by sending an <code>x-no-compression</code> header:</p>
<pre><code class="language-ts">app.use(
  compression({
    filter: (req, res) =&gt; {
      if (req.headers['x-no-compression']) {
        return false;
      }
      return compression.filter(req, res);
    },
    threshold: 1024,
  }),
);
</code></pre>
<p>If you run behind a reverse proxy or CDN that already compresses, you can drop this and let the edge do the work.</p>
<h2>Prefix, versioning and validation</h2>
<p>All routes live under <code>/api</code>, except the health check, metrics and root. URI versioning defaults to <code>1</code>, so a controller route <code>users</code> is served at <code>/api/v1/users</code>:</p>
<pre><code class="language-ts">app.setGlobalPrefix('api', {
  exclude: ['/health', '/metrics', '/'],
});

app.enableVersioning({
  type: VersioningType.URI,
  defaultVersion: '1',
});

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    transformOptions: {
      enableImplicitConversion: true,
    },
    disableErrorMessages: process.env.NODE_ENV === 'production',
    validationError: {
      target: false,
      value: false,
    },
  }),
);
</code></pre>
<p>The validation pipe is the most important security line in the file. <code>whitelist</code> strips properties that have no decorator on the DTO, and <code>forbidNonWhitelisted</code> turns those extra properties into a 400 instead of silently dropping them. That shuts down mass assignment: a client can't add <code>"role": "admin"</code> to a profile update. <code>target: false</code> and <code>value: false</code> keep the submitted object and value out of error responses, and production hides validation messages entirely.</p>
<h2>Rate limiting with three windows</h2>
<p>Throttling is set in <code>AppModule</code> with <code>@nestjs/throttler</code>, registered as a global guard so every route is covered without decorators:</p>
<pre><code class="language-ts">ThrottlerModule.forRoot([
  {
    name: 'short',
    ttl: 1000, // 1 second
    limit: 10, // 10 requests per second
  },
  {
    name: 'medium',
    ttl: 60000, // 1 minute
    limit: 100, // 100 requests per minute
  },
  {
    name: 'long',
    ttl: 3600000, // 1 hour
    limit: 1000, // 1000 requests per hour
  },
]),
</code></pre>
<pre><code class="language-ts">providers: [
  {
    provide: APP_GUARD,
    useClass: ThrottlerGuard,
  },
],
</code></pre>
<p>The limits are deliberately loose for a general API: ten requests a second is enough for a busy single-page app, while a login or password-reset route deserves something much stricter. The throttler supports that per route with the <code>@Throttle()</code> decorator, and <code>@SkipThrottle()</code> exempts routes such as internal webhooks.</p>
<p>Three named windows let you allow short bursts while capping sustained traffic. Every request has to pass all three. The default storage is in memory, which is per process, so with several instances behind a load balancer each one counts separately. That's the point where you add a shared store such as Redis. Behind a proxy, also make sure Express trusts it, or every client will appear to share the proxy's IP.</p>
<h2>Structured logging with Pino</h2>
<p><code>nestjs-pino</code> replaces Nest's logger with Pino, configured in <code>AppModule</code>. In development it pretty-prints on one line. In production it writes JSON at <code>info</code> level:</p>
<pre><code class="language-ts">serializers: {
  req: (req) =&gt; ({
    id: req.id,
    method: req.method,
    url: req.url,
    remoteAddress: req.remoteAddress,
    remotePort: req.remotePort,
  }),
  res: (res) =&gt; ({
    statusCode: res.statusCode,
  }),
},
autoLogging: {
  ignore: (req) =&gt; req.url === '/health' || req.url === '/metrics',
},
customLogLevel: (req, res, err) =&gt; {
  if (res.statusCode &gt;= 400 &amp;&amp; res.statusCode &lt; 500) {
    return 'warn';
  } else if (res.statusCode &gt;= 500 || err) {
    return 'error';
  }
  return 'info';
},
</code></pre>
<p>The serializers are the security feature here. By listing the fields to keep, they leave out headers entirely, so <code>Authorization</code> tokens and session cookies never reach your log storage. Health and metrics checks are skipped so uptime probes don't drown real traffic, and the level follows the status code, so a 4xx is a warning and a 5xx is an error you can alert on.</p>
<p>The starter also registers a <code>LoggingInterceptor</code> that logs method, URL, status, size, response time, IP and user agent. With pino-http already logging every request, that's two lines per request. In production I'd keep one of them.</p>
<h2>The Prisma error filter</h2>
<p>The global exception filter turns every error into one response shape: <code>statusCode</code>, <code>timestamp</code>, <code>path</code> and <code>message</code>. It also translates Prisma's error codes into HTTP statuses:</p>
<pre><code class="language-ts">if (exception instanceof Prisma.PrismaClientKnownRequestError) {
  status = HttpStatus.BAD_REQUEST;
  errorName = 'PrismaError';

  switch (exception.code) {
    case 'P2002':
      status = HttpStatus.CONFLICT;
      message = `Unique constraint failed on ${exception.meta?.target}`;
      break;
    case 'P2025':
      status = HttpStatus.NOT_FOUND;
      message = 'Record not found';
      break;
    case 'P2003':
      status = HttpStatus.BAD_REQUEST;
      message = 'Foreign key constraint failed';
      break;
    default:
      message = exception.message;
  }
} else if (exception instanceof Prisma.PrismaClientValidationError) {
  status = HttpStatus.BAD_REQUEST;
  message = 'Validation error';
  errorName = 'PrismaValidationError';
}
</code></pre>
<p>So a duplicate email becomes a 409, a missing record a 404 and a broken relation a 400, instead of a generic 500. The response hides details for 500s in production:</p>
<pre><code class="language-ts">response.status(status).json({
  statusCode: status,
  timestamp: new Date().toISOString(),
  path: request.url,
  message:
    process.env.NODE_ENV === 'production' &amp;&amp; status === 500
      ? 'An internal error occurred'
      : message,
});
</code></pre>
<p>Look at the <code>default</code> branch, though. Any other known Prisma error returns <code>exception.message</code> with a 400, and that masking only applies to 500s. Prisma messages can describe your tables and query, so in production I'd map unknown codes to a generic message as well. The same applies to the plain <code>Error</code> branch: its status stays 500, so it is masked, but a 400 built from a raw message is not.</p>
<p>The filter logs one line per error with the method, URL, status and error name. The stack trace argument is commented out in the code, which keeps logs short but makes a 500 harder to debug. I'd log the stack for 5xx responses only, since that's where you need it.</p>
<h2>Environment validation, Prisma 7 and health</h2>
<p>Configuration is validated at startup with Joi. <code>DATABASE_URL</code> and <code>COOKIE_SECRET</code> are required, and <code>abortEarly: false</code> reports every missing variable at once instead of one per restart:</p>
<pre><code class="language-ts">export const validationSchema = Joi.object({
  NODE_ENV: Joi.string()
    .valid('development', 'production', 'test', 'staging')
    .default('development'),
  PORT: Joi.number().default(3000),
  HOST: Joi.string().default('0.0.0.0'),
  ALLOWED_ORIGINS: Joi.string().default('http://localhost:3000'),
  COOKIE_SECRET: Joi.string().required(),
  CSRF_ENABLED: Joi.string().valid('true', 'false').default('false'),
  DATABASE_URL: Joi.string().required(),
  THROTTLE_TTL: Joi.number().default(60),
  THROTTLE_LIMIT: Joi.number().default(10),
});
</code></pre>
<p>The Prisma side uses Prisma 7's driver adapter. The client is generated into <code>src/generated/prisma</code>, the connection URL lives in <code>prisma.config.ts</code>, and the service passes a <code>PrismaPg</code> adapter. It logs queries only outside production:</p>
<pre><code class="language-ts">constructor() {
  const adapter = new PrismaPg({
    connectionString: process.env.DATABASE_URL as string,
  });
  super({
    adapter,
    log:
      process.env.NODE_ENV === 'production'
        ? ['error', 'warn']
        : ['query', 'info', 'warn', 'error'],
  });
}
</code></pre>
<p><code>PrismaModule</code> is global, so any feature module can inject <code>PrismaService</code>. There's also a <code>cleanDatabase()</code> helper for tests that refuses to run in production. <code>GET /health</code> runs <code>SELECT 1</code> and reports <code>database: connected</code> or <code>error</code>, along with uptime and environment, which is what a container orchestrator or uptime monitor needs.</p>
<h2>What to finish before production</h2>
<p>The core security layer above is real and working. A few things in the repository are still behind the README, and you should know them before you build on it:</p>
<ul>
<li>The README describes a Users module, a seed script, a <code>docker-compose.yml</code> and <code>prisma:*</code> npm scripts. Those aren't in the code yet, so use <code>npx prisma generate</code> and <code>npx prisma migrate dev</code> directly.</li>
<li><code>THROTTLE_TTL</code> and <code>THROTTLE_LIMIT</code> are validated but not used. The throttler limits are hard-coded in <code>AppModule</code>. Switching to <code>ThrottlerModule.forRootAsync</code> with <code>ConfigService</code> would make them configurable.</li>
<li>The e2e test imports <code>src/app.module</code>, but the module lives at <code>src/modules/app/app.module.ts</code>, so the test needs its import fixed.</li>
<li><code>enableShutdownHooks()</code> and the manual <code>SIGTERM</code>/<code>SIGINT</code> handlers both close the app. One of them is enough.</li>
</ul>
<p>None of these touch the request path, but they're the difference between a starter and a template you can clone and ship.</p>
<h2>Takeaway</h2>
<p>Security in a NestJS API is mostly configuration, and it belongs in one place you can read top to bottom. Helmet for headers, an explicit CORS allow-list, opt-in CSRF for cookie sessions, a strict validation pipe, layered rate limits, header-free structured logs and an exception filter that speaks HTTP instead of SQL. The code is on <a href="https://github.com/fabwaseem/nestjs-prisma-starter">GitHub</a>. If you're upgrading the framework underneath it, my <a href="https://www.fabwaseem.com/blog/nestjs-12">NestJS 12 migration guide</a> covers that step.</p>
]]></content:encoded></item><item><title><![CDATA[Building Benchline: browser tools that are also a stateless MCP server]]></title><description><![CDATA[How Benchline turns 61 browser tools into a stateless Streamable HTTP MCP server in Next.js 16: pure functions, a registry, zod and SSRF guards.]]></description><link>https://fabwaseem.hashnode.dev/building-benchline-stateless-mcp-server</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/building-benchline-stateless-mcp-server</guid><category><![CDATA[Open Source]]></category><category><![CDATA[mcp]]></category><category><![CDATA[AI]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 18:11:36 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/84066797-f51a-4a6d-9c45-0def0398eadd.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Benchline is a free directory of browser tools for the small, exact jobs around shipping software: formatting JSON, merging PDFs, decoding a JWT, writing an llms.txt file. It lives at <a href="https://benchline.fabwaseem.com">benchline.fabwaseem.com</a> and the code is MIT-licensed on <a href="https://github.com/fabwaseem/benchline">GitHub</a>.</p>
<p>The part I care about most is that every tool is two things at once. It is a page a person can open, and it is a tool an AI agent can call over the Model Context Protocol. Both run the same function. There is no database, no account and no session.</p>
<p>This post walks through how that works, using the real code from the repository.</p>
<h2>The rule: one tool, one pure function</h2>
<p>Every tool lives in its own folder under <code>src/tools/&lt;slug&gt;/</code>:</p>
<pre><code class="language-text">src/tools/&lt;slug&gt;/
  meta.ts     name, tagline, category, FAQ, SEO copy
  tool.tsx    the client workspace a person uses
  lib/        the pure function, covered by vitest
  mcp.ts      the agent entry point, calling the same lib/
</code></pre>
<p>The <code>lib/</code> folder is the tool. The page and the MCP handler are two thin shells around it. That split is what makes the dual surface cheap. When I fix a bug in the JSON formatter, I fix it once, and the page, the MCP server and the HTTP API all pick it up.</p>
<p>Here is the core of the JSON formatter's <code>lib/format.ts</code>:</p>
<pre><code class="language-ts">export function formatJson(input: string, options: FormatOptions): FormatResult {
  const text = input.replace(/^\uFEFF/, "");
  const errors = issuesIn(text);
  if (errors.length &gt; 0) {
    return { valid: false, output: "", errors, tree: null };
  }
  try {
    const value = sortIfNeeded(JSON.parse(text) as unknown, options.sortKeys);
    const output = options.minify ? JSON.stringify(value) : JSON.stringify(value, null, gap(options.indent));
    return { valid: true, output, errors: [], tree: treeOf(value, "root") };
  } catch (error) {
    const issue = issueFromSyntax(text, error);
    return { valid: false, output: "", errors: [issue], tree: null };
  }
}
</code></pre>
<p>Nothing in it knows about React, HTTP or MCP. It takes a string and options and returns a result object. That makes it easy to test with vitest and easy to call from anywhere.</p>
<h2>The MCP side of a tool</h2>
<p>The agent entry point is <code>mcp.ts</code>. It declares a zod input schema, a zod output schema, and a <code>run</code> function that calls the same <code>formatJson</code>:</p>
<pre><code class="language-ts">export const inputSchema = z.object({
  json: z.string().describe("JSON text to format or validate."),
  indent: z.enum(indents).default("2"),
  sortKeys: z.boolean().default(false).describe("Sort object keys. Array order is kept."),
  minify: z.boolean().default(false),
});

async function run(input: unknown) {
  const parsed = inputSchema.parse(input);
  const result = formatJson(parsed.json, parsed);
  return outputSchema.parse({
    output: result.output,
    valid: result.valid,
    errors: result.errors.map((error) =&gt; ({ message: error.message, line: error.line, column: error.column })),
  });
}

export const tool: BenchTool = {
  slug: "json-formatter",
  name: "format_json",
  title: "Format and validate JSON",
  description: "Pretty-print or minify JSON. Returns the formatted text when it is valid, or the line and column of each syntax error.",
  openWorld: false,
  inputSchema,
  outputSchema,
  run,
};
</code></pre>
<p>Every tool exports the same shape, a <code>BenchTool</code>:</p>
<pre><code class="language-ts">export type BenchTool = {
  slug: string;
  name: string;
  title: string;
  description: string;
  openWorld: boolean;
  inputSchema: z.ZodType;
  outputSchema: z.ZodType;
  run: (input: unknown) =&gt; Promise&lt;unknown&gt;;
};
</code></pre>
<p>Two details matter here. The output is parsed against its schema before it leaves <code>run</code>, so an agent never gets a shape the schema did not promise. And <code>openWorld</code> is an explicit flag: it is <code>true</code> only when the handler reaches the network, such as the llms.txt generator reading a sitemap.</p>
<h2>A generated registry instead of a hand-kept list</h2>
<p>With 61 tools, a hand-maintained list of imports would drift. Benchline generates it instead. <code>pnpm gen:registry</code> runs as the <code>prebuild</code> step. It reads every <code>meta.ts</code> and <code>mcp.ts</code>, validates them, and writes the registry files.</p>
<p>The validation is strict. It checks the metadata against zod schemas, requires unique slugs, known categories, known Lucide icons and known related tools, and requires a <code>toolOrder</code> that lists every tool exactly once. A tool folder without an <code>mcp.ts</code> fails the script, so a page can't ship without its agent entry point.</p>
<p>The output includes <code>src/registry/mcp-registry.ts</code>, which simply imports every tool:</p>
<pre><code class="language-ts">// Generated by scripts/gen-registry.ts. Do not edit.
import { tool as llmsTxtGenerator } from "@/tools/llms-txt-generator/mcp";
import { tool as aiSeoChecker } from "@/tools/ai-seo-checker/mcp";
import { tools as ogImageGeneratorTools } from "@/tools/og-image-generator/mcp";
import { tool as jwtDecoder } from "@/tools/jwt-decoder/mcp";
// ...one line per tool
</code></pre>
<p>It also writes a lightweight <code>mcp-index.ts</code> holding just the name, title, description and <code>openWorld</code> flag, for pages that should list agent tools without loading every handler. Directory pages import metadata only. Each tool's UI is loaded with <code>next/dynamic</code>, so opening the directory doesn't download 61 tool workspaces.</p>
<h2>The stateless MCP server</h2>
<p>The MCP endpoint is a single Next.js route handler at <code>src/app/mcp/route.ts</code>:</p>
<pre><code class="language-ts">import { handleMcpRequest } from "@/lib/mcp/handler";

export function POST(request: Request) {
  return handleMcpRequest(request);
}

export function GET() {
  return new Response("Method not allowed", {
    status: 405,
    headers: { allow: "POST", "cache-control": "no-store" },
  });
}
</code></pre>
<p><code>DELETE</code> returns the same 405. Only <code>POST</code> is allowed, because there is no session to stream into or to close.</p>
<p>The handler builds a fresh <code>McpServer</code> and registers every tool from the generated registry:</p>
<pre><code class="language-ts">function createServer() {
  const server = new McpServer({ name: "benchline", version: "1.0.0" });
  for (const tool of mcpTools) {
    server.registerTool(
      tool.name,
      {
        title: tool.title,
        description: tool.description,
        inputSchema: tool.inputSchema,
        outputSchema: tool.outputSchema,
        annotations: {
          readOnlyHint: true,
          idempotentHint: true,
          openWorldHint: tool.openWorld,
        },
      },
      async (args) =&gt; {
        try {
          const structured = tool.outputSchema.parse(await tool.run(args));
          return {
            content: [{ type: "text" as const, text: JSON.stringify(structured) }],
            structuredContent: structured as Record&lt;string, unknown&gt;,
          };
        } catch (error) {
          return { isError: true, content: [{ type: "text" as const, text: messageOf(error) }] };
        }
      },
    );
  }
  return server;
}
</code></pre>
<p>Every tool is annotated read-only and idempotent, which is true: none of them write anything. A successful call returns <code>structuredContent</code> plus a text copy of the same JSON, for clients that only read text. A thrown error, including a zod validation error, becomes <code>isError: true</code> with a readable message instead of a crashed request.</p>
<p>Then each request gets its own server and transport:</p>
<pre><code class="language-ts">const server = createServer();
const transport = new WebStandardStreamableHTTPServerTransport({
  sessionIdGenerator: undefined,
  enableJsonResponse: true,
});
await server.connect(transport);
return transport.handleRequest(request);
</code></pre>
<p>This is the "stateless" part. <code>sessionIdGenerator: undefined</code> turns off sessions in the MCP TypeScript SDK, so there is no session id to store or route. <code>enableJsonResponse: true</code> answers with a plain JSON body instead of opening an SSE stream. Every request stands alone, so the server scales like any other route handler, and the README's curl examples work without an <code>initialize</code> call first:</p>
<pre><code class="language-bash">curl -sS -X POST https://benchline.fabwaseem.com/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
</code></pre>
<p>The web-standard transport (<code>WebStandardStreamableHTTPServerTransport</code>) takes a <code>Request</code> and returns a <code>Response</code>, which matches the App Router route handler signature exactly. There's no Node <code>req</code>/<code>res</code> adapter to write.</p>
<h2>Guards in front of the server</h2>
<p>Before the server is built, the handler checks two things.</p>
<p>First, the <code>Origin</code> header. A request without one, like curl, is fine. A request with one must match the request host, localhost, or the site URL:</p>
<pre><code class="language-ts">function originAllowed(request: Request) {
  const origin = request.headers.get("origin");
  if (!origin) return true;
  let url: URL;
  try {
    url = new URL(origin);
  } catch {
    return false;
  }
  const host = request.headers.get("host");
  if (host &amp;&amp; url.host === host) return true;
  if (url.hostname === "localhost" || url.hostname === "127.0.0.1") return true;
  return origin === SITE_URL;
}
</code></pre>
<p>Second, a rate limit of 60 requests a minute per address. The limiter is a small sliding window kept in memory:</p>
<pre><code class="language-ts">const hits = new Map&lt;string, number[]&gt;();

export function rateLimit(
  key: string,
  limit = 8,
  windowMs = 10 * 60 * 1000,
  now = Date.now(),
): { ok: true } | { ok: false; retryAfter: number } {
  const recent = (hits.get(key) ?? []).filter((time) =&gt; now - time &lt; windowMs);
  if (recent.length &gt;= limit) {
    const retryAfter = Math.max(1, Math.ceil((recent[0]! + windowMs - now) / 1000));
    hits.set(key, recent);
    return { ok: false, retryAfter };
  }
  recent.push(now);
  hits.set(key, recent);
  return { ok: true };
}
</code></pre>
<p>The MCP route calls it as <code>rateLimit(`mcp:${clientIp(request)}`, 60, 60_000)</code>, and a 429 comes back as a JSON-RPC error with a <code>retry-after</code> header. Being in memory, it is per instance, which is a fair trade for a free, keyless service with no database.</p>
<h2>Same function, third surface: a JSON API</h2>
<p>Not every client speaks MCP, so the same <code>tool.run</code> is exposed at <code>POST /api/v1/tools/&lt;slug&gt;</code>, with an OpenAPI 3.1 document at <code>/openapi.json</code> built from the zod input schemas. The route looks the tool up by slug and runs it:</p>
<pre><code class="language-ts">const { slug } = await context.params;
const tool = mcpToolBySlug(slug);
if (!tool) {
  return Response.json({ error: "No tool uses that slug." }, { status: 404, headers: { "cache-control": "no-store" } });
}
// ...parse the body, then:
const output = tool.outputSchema.parse(await tool.run(body));
return Response.json(output, { headers: { "cache-control": "no-store" } });
</code></pre>
<p>It has its own 60-a-minute bucket. Because both surfaces call one <code>run</code>, they can't disagree about what a tool does.</p>
<h2>When one tool needs more than one MCP call</h2>
<p>Most folders export a single <code>tool</code>. The Open Graph image generator is the exception, and the generated registry imports it as <code>tools</code> instead. An agent can't see the visual template picker a person uses on the page, so the generator is split into two calls. <code>list_og_templates</code> returns the template ids, categories, defaults and field schemas. <code>build_open_graph</code> then takes a template id, the fields that template uses, colors, a size and optional images, and returns a PNG plus the Next.js file. The page and the agent still share the same rendering code. The only difference is that the agent asks what it can choose from before choosing.</p>
<h2>File inputs for an agent with no disk</h2>
<p>A person drops a file onto the page and the browser does the work. An agent calling a remote server has no local disk on that server, so the image and PDF handlers accept a file in one of two forms, <code>url</code> or <code>data</code>, never both. <code>data</code> is standard or URL-safe base64, or a <code>data:</code> URI, capped at 10 MB once decoded. <code>url</code> has to be public <code>http</code> or <code>https</code>, and it goes through the same SSRF checks described below. The fetch times out after 15 seconds, and a declared or streamed body over 10 MB is refused. The file is read in memory for that one call and not written to disk, which keeps the "nothing is stored" promise true on the server as well as in the browser.</p>
<h2>Tools that touch the network</h2>
<p>According to the README, 52 of the 61 tools run entirely in the browser. The other nine have to fetch something public: the llms.txt generator, AI SEO checker, robots.txt checker, meta tags inspector, page to Markdown, DNS lookup, sitemap validator, HTTP headers and SSL checker.</p>
<p>A server that fetches URLs on request is an SSRF risk, so every fetch goes through a guard in <code>src/lib/net/ssrf.ts</code>. <code>parsePublicHttpUrl</code> allows only <code>http</code> and <code>https</code>, refuses credentials in the URL, allows only ports 80 and 443, and blocks names like <code>localhost</code>, <code>*.local</code> and <code>*.internal</code>. Then <code>assertPublicHost</code> resolves the host and checks every address it returns:</p>
<pre><code class="language-ts">for (const record of records) {
  if (isBlockedIp(record.address)) {
    throw new PublicUrlError("That host resolves to a private address.");
  }
}
</code></pre>
<p><code>isBlockedIp</code> refuses loopback, private ranges, link-local, CGNAT (<code>100.64.0.0/10</code>), multicast and the IPv6 equivalents. Redirects are followed by hand, up to four, and each hop is checked again, so a public URL can't redirect into a private one. The browser-facing routes for these nine tools also get a tighter limit of 8 requests per address every 10 minutes.</p>
<h2>Making it readable for agents and AI search</h2>
<p>The registry feeds more than the MCP server. <code>/llms.txt</code> and <code>/llms-full.txt</code> are generated from it, so the site's index for AI crawlers lists every tool page and every agent tool, with its HTTP endpoint, and notes which calls fetch a public URL. Each tool page also carries its own FAQ and how-to steps from <code>meta.ts</code>. Adding a tool updates the directory, the MCP server, the API, the OpenAPI document and llms.txt in one build.</p>
<h2>Testing every tool through the endpoint</h2>
<p>The test suite doesn't stop at the pure functions. <code>src/lib/mcp/endpoint.test.ts</code> lists every registered tool through the MCP endpoint, calls every tool through it, fetches a public PDF URL while refusing a private one, and rejects an unsupported protocol version and a foreign origin. There's also <code>scripts/live-mcp-smoke.mts</code>, which connects to the live server with the MCP client, calls <code>tools/list</code>, and then calls every listed tool with a sample input, waiting 1.2 seconds between calls to stay under the rate limit.</p>
<h2>Connecting it to your editor</h2>
<p>Since there's no API key, setup is one URL. In Cursor's <code>~/.cursor/mcp.json</code>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "benchline": {
      "url": "https://benchline.fabwaseem.com/mcp"
    }
  }
}
</code></pre>
<p>Claude Desktop uses the same block. VS Code takes <code>"type": "http"</code> with the same URL, and a client that can only launch local commands can wrap it with <code>npx -y mcp-remote https://benchline.fabwaseem.com/mcp</code>. The full setup page is at <a href="https://benchline.fabwaseem.com/mcp/guide">/mcp/guide</a>.</p>
<h2>What I'd keep if I started again</h2>
<ul>
<li><p><strong>Put the logic in a pure function first.</strong> The page, the MCP tool and the HTTP endpoint all became small once <code>lib/</code> existed.</p>
</li>
<li><p><strong>Generate the registry.</strong> A build-time check that fails when a tool has no <code>mcp.ts</code> keeps the two surfaces in step.</p>
</li>
<li><p><strong>Go stateless unless you need sessions.</strong> For read-only, idempotent tools, a new server per request with JSON responses is simpler to host and debug than session-bound streams.</p>
</li>
<li><p><strong>Treat every fetch as hostile.</strong> Resolve the host, check every address, and re-check every redirect.</p>
</li>
</ul>
<p>The stack is Next.js 16 with the App Router, React 19, strict TypeScript, Tailwind CSS v4, shadcn/ui on Base UI, zod 4, vitest and the MCP TypeScript SDK. It runs as a standalone Node 22 build in Docker. If you want to add a tool, the steps are in <a href="https://github.com/fabwaseem/benchline/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a>, and you can try the live directory at <a href="https://benchline.fabwaseem.com">benchline.fabwaseem.com</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Building an AI Automation Pipeline in Node.js with BullMQ: Queues, Retries and Rate Limits]]></title><description><![CDATA[Most AI automation demos are one await away from breaking. A webhook comes in, the handler calls a model, the response gets written to the database, and…]]></description><link>https://fabwaseem.hashnode.dev/building-an-ai-automation-pipeline-in-node-js-with-bullmq-queues-retries-and-rate-limits</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/building-an-ai-automation-pipeline-in-node-js-with-bullmq-queues-retries-and-rate-limits</guid><category><![CDATA[Node.js]]></category><category><![CDATA[AI]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Redis]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 18:07:58 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad442b71d93d6dd9f3742d8/15bbb57b-cf89-414e-949c-9aa6ff83f6d7.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most AI automation demos are one <code>await</code> away from breaking. A webhook comes in, the handler calls a model, the response gets written to the database, and everyone is happy until the provider returns a 429, the model wraps its JSON in a code fence, or the deploy restarts the process halfway through a batch.</p>
<p>This post walks through the pattern I use to move LLM calls out of request handlers and into a queue, using <strong>BullMQ</strong> on Redis with Node.js and TypeScript. The example is a support-ticket triage pipeline: each ticket goes to a model, comes back as a category, a priority and a summary, and gets saved. The same shape works for lead enrichment, document extraction, content moderation or any other "call a model for every row" job.</p>
<p>Everything below was type-checked against BullMQ 6 and run against a local Redis with a fake model that returns a 429, then a 503, then invalid JSON, then a valid answer.</p>
<h2>Why an AI pipeline needs a queue</h2>
<p>An LLM call is slower and less predictable than almost anything else your backend does. Four problems show up quickly:</p>
<ol>
<li><strong>Latency.</strong> A call can take seconds. Holding an HTTP request open for that long ties up the caller and times out behind proxies.</li>
<li><strong>Rate limits.</strong> Providers cap requests and tokens per minute. Ten parallel webhooks can trip a limit that one would not.</li>
<li><strong>Transient failures.</strong> 429s, 5xx responses and dropped connections happen, and most of them succeed on a second try.</li>
<li><strong>Bad output.</strong> Even with JSON mode, a response can fail your schema. That is a failed attempt, not a reason to crash.</li>
</ol>
<p>A queue answers all four. The handler adds a job and returns <code>202 Accepted</code>. Workers pull jobs at a controlled rate, retry with backoff, and park anything hopeless in a dead-letter queue where a human can look at it.</p>
<h2>The architecture</h2>
<pre><code>webhook ──► triageQueue (Redis) ──► Worker (concurrency 8, 50 req/min)
                                       │
                                       ├─ ok ───────────► saveTriage() upsert
                                       ├─ retryable ────► delayed, custom backoff
                                       └─ hopeless ─────► ticket-triage-dlq
</code></pre>
<p>Five small files: a Redis connection, the schemas, the model client, the queue, and the worker.</p>
<h2>1. The Redis connection</h2>
<pre><code class="language-ts">// connection.ts
import { Redis } from 'ioredis';

// Workers use blocking Redis commands, so ioredis must not give up on them.
export const connection = new Redis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
  maxRetriesPerRequest: null,
});
</code></pre>
<p><code>maxRetriesPerRequest: null</code> matters. BullMQ workers block on Redis while waiting for jobs, and the ioredis default would abort those commands after a few retries during a Redis blip.</p>
<h2>2. Schemas for the input and the model output</h2>
<pre><code class="language-ts">// schema.ts
import { z } from 'zod';

export const TicketJob = z.object({
  ticketId: z.string(),
  subject: z.string(),
  body: z.string().max(20_000),
});
export type TicketJob = z.infer&lt;typeof TicketJob&gt;;

// What we accept back from the model. Anything else is a failed attempt.
export const Triage = z.object({
  category: z.enum(['billing', 'bug', 'feature_request', 'account', 'other']),
  priority: z.enum(['low', 'normal', 'high', 'urgent']),
  summary: z.string().min(10).max(400),
});
export type Triage = z.infer&lt;typeof Triage&gt;;
</code></pre>
<p>Validating the job payload protects you from old jobs enqueued by an older version of the code. Validating the model output is the real point: an enum for <code>category</code> means downstream code never sees <code>"Billing issue"</code> or <code>"payments?"</code>.</p>
<h2>3. A model client that reports useful errors</h2>
<pre><code class="language-ts">// errors.ts
export class ModelHttpError extends Error {
  constructor(public status: number, message: string, public retryAfterMs?: number) {
    super(message);
    this.name = 'ModelHttpError';
  }
}

// Retry only what can succeed on a second try.
export function isRetryable(err: unknown): boolean {
  if (err instanceof ModelHttpError) {
    return err.status === 408 || err.status === 429 || err.status &gt;= 500;
  }
  return err instanceof Error &amp;&amp; /ECONN|ETIMEDOUT|fetch failed|aborted/i.test(err.message);
}
</code></pre>
<pre><code class="language-ts">// model.ts
import { ModelHttpError } from './errors.js';

// Any OpenAI-compatible chat completions endpoint works here.
export async function callModel(prompt: string, signal: AbortSignal): Promise&lt;string&gt; {
  const res = await fetch(`${process.env.LLM_BASE_URL}/chat/completions`, {
    method: 'POST',
    signal,
    headers: {
      'content-type': 'application/json',
      authorization: `Bearer ${process.env.LLM_API_KEY}`,
    },
    body: JSON.stringify({
      model: process.env.LLM_MODEL,
      response_format: { type: 'json_object' },
      messages: [
        { role: 'system', content: 'You triage support tickets. Reply with JSON only.' },
        { role: 'user', content: prompt },
      ],
    }),
  });

  if (!res.ok) {
    const retryAfter = Number(res.headers.get('retry-after'));
    throw new ModelHttpError(
      res.status,
      `model returned ${res.status}`,
      Number.isFinite(retryAfter) &amp;&amp; retryAfter &gt; 0 ? retryAfter * 1000 : undefined,
    );
  }
  const json = (await res.json()) as { choices: { message: { content: string } }[] };
  return json.choices[0].message.content;
}
</code></pre>
<p>The important part is that the error carries the HTTP status and the <code>Retry-After</code> value. The worker needs both to decide whether and when to try again. (<code>Retry-After</code> can also be an HTTP date; this version only handles the seconds form, which is what model APIs usually send.)</p>
<h2>4. The queue, with idempotent enqueueing</h2>
<pre><code class="language-ts">// queue.ts
import { Queue } from 'bullmq';
import { connection } from './connection.js';
import type { TicketJob } from './schema.js';

export const triageQueue = new Queue&lt;TicketJob&gt;('ticket-triage', {
  connection,
  defaultJobOptions: {
    attempts: 6,
    backoff: { type: 'llm' }, // custom strategy, defined on the worker
    removeOnComplete: { age: 24 * 3600, count: 1000 },
    removeOnFail: { age: 7 * 24 * 3600 },
  },
});

export const deadLetterQueue = new Queue('ticket-triage-dlq', { connection });

export async function enqueueTicket(ticket: TicketJob) {
  // The ticket id doubles as the job id, so a webhook delivered twice
  // does not create two jobs while the first one still exists.
  return triageQueue.add('triage', ticket, { jobId: `ticket-${ticket.ticketId}` });
}
</code></pre>
<p>Webhook providers retry deliveries, so duplicates are normal. With a custom <code>jobId</code>, BullMQ ignores an <code>add</code> for an id that already exists. In my test, enqueueing the same ticket twice returned the same job id and the queue held one job.</p>
<p>One caveat: once a job is removed by <code>removeOnComplete</code>, the same id can be added again. That is why the save step below is an upsert as well. Deduplication in the queue cuts wasted model calls; idempotency in the database guarantees correctness.</p>
<p><code>removeOnComplete</code> and <code>removeOnFail</code> keep Redis from growing forever. Keep failed jobs longer than completed ones, because they are the ones you will want to inspect.</p>
<h2>5. The worker: retries, backoff, rate limits</h2>
<pre><code class="language-ts">// worker.ts
import { Worker, UnrecoverableError, type Job } from 'bullmq';
import { connection } from './connection.js';
import { ModelHttpError, isRetryable } from './errors.js';
import { callModel } from './model.js';
import { TicketJob, Triage } from './schema.js';
import { deadLetterQueue } from './queue.js';
import { saveTriage } from './store.js';

const BASE_MS = 2_000;
const MAX_MS = 5 * 60_000;

function llmBackoff(attemptsMade: number, _type?: string, err?: Error): number {
  if (err instanceof ModelHttpError &amp;&amp; err.retryAfterMs) {
    return Math.min(err.retryAfterMs, MAX_MS); // the provider told us when
  }
  const ceiling = Math.min(MAX_MS, BASE_MS * 2 ** (attemptsMade - 1));
  return Math.round(Math.random() * ceiling); // full jitter
}

async function processTicket(job: Job&lt;TicketJob&gt;): Promise&lt;Triage&gt; {
  const ticket = TicketJob.parse(job.data);

  const controller = new AbortController();
  const timer = setTimeout(() =&gt; controller.abort(), 30_000);
  let raw: string;
  try {
    raw = await callModel(
      `Subject: ${ticket.subject}\n\n${ticket.body}\n\nReturn {"category","priority","summary"}.`,
      controller.signal,
    );
  } catch (err) {
    if (!isRetryable(err)) {
      throw new UnrecoverableError(err instanceof Error ? err.message : String(err));
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    throw new Error('model returned invalid JSON'); // retryable: sampling varies
  }
  const result = Triage.safeParse(parsed);
  if (!result.success) {
    throw new Error(`schema mismatch: ${result.error.issues[0]?.message}`);
  }

  await job.updateProgress(90);
  await saveTriage(ticket.ticketId, result.data); // idempotent upsert
  return result.data;
}

export const worker = new Worker&lt;TicketJob, Triage&gt;('ticket-triage', processTicket, {
  connection,
  concurrency: 8,
  limiter: { max: 50, duration: 60_000 }, // stay under the provider's RPM
  settings: { backoffStrategy: llmBackoff },
});
</code></pre>
<p>A few decisions worth calling out.</p>
<p><strong>Classify every error.</strong> A 400 or 401 will fail the same way six times, so the worker wraps it in <code>UnrecoverableError</code>, and BullMQ skips the remaining attempts. A 429, a 5xx or a network reset is thrown as a normal error and retried.</p>
<p><strong>Respect <code>Retry-After</code>, otherwise use full jitter.</strong> When the provider says "wait one second", the custom strategy waits one second. Otherwise it picks a random delay between zero and an exponentially growing ceiling, capped at five minutes. The randomness matters. Without it, every job that hit the same 429 retries at the same instant and hits it again. BullMQ calls a custom <code>backoffStrategy</code> when the job's backoff <code>type</code> is not one of the built-in <code>fixed</code> or <code>exponential</code> types, which is why the queue sets <code>type: 'llm'</code>. Returning <code>-1</code> from the strategy stops retrying.</p>
<p><strong>Treat bad output as a retryable failure.</strong> Invalid JSON or a schema mismatch is thrown as a plain error. A second sample often comes back clean. If it keeps failing, the job runs out of attempts and lands in the dead-letter queue instead of writing garbage to the database.</p>
<p><strong>Rate-limit at the worker, not in your code.</strong> <code>limiter: { max: 50, duration: 60_000 }</code> caps processing at 50 jobs per minute across every worker on the same queue, because the limit is tracked in Redis. Set it a little below your provider's limit. <code>concurrency: 8</code> controls how many calls one process has in flight.</p>
<p><strong>Always set a timeout.</strong> A hung request holds a concurrency slot forever. The <code>AbortController</code> turns it into an <code>aborted</code> error after 30 seconds, which <code>isRetryable</code> treats as transient.</p>
<h2>6. A dead-letter queue for jobs that never succeed</h2>
<pre><code class="language-ts">worker.on('failed', async (job, err) =&gt; {
  if (!job) return;
  const outOfAttempts = job.attemptsMade &gt;= (job.opts.attempts ?? 1);
  if (outOfAttempts || err.name === 'UnrecoverableError') {
    await deadLetterQueue.add('dead', {
      jobId: job.id,
      data: job.data,
      reason: err.message,
      attemptsMade: job.attemptsMade,
    });
  }
});
</code></pre>
<p>The worker's <code>failed</code> event fires on every failed attempt, including ones that will be retried, so the handler checks whether this was the last one. Only final failures go to the DLQ. From there you can alert, show them in an admin page, or re-enqueue them after fixing a prompt.</p>
<h2>What the test run showed</h2>
<p>I ran the pipeline against a local Redis with <code>fetch</code> replaced by a scripted fake:</p>
<ul>
<li>Ticket 1 got a 429 with <code>Retry-After: 1</code>, then a 503, then a response that was not JSON, then a valid answer. It succeeded on the fourth attempt, about nine seconds after it was enqueued, and was saved once.</li>
<li>Ticket 2 got a 400. It failed after a single call, with no retries, and landed in the dead-letter queue with the reason <code>model returned 400</code>.</li>
<li>Enqueueing ticket 1 twice produced one job.</li>
</ul>
<p>That is the behavior you want in production: transient errors absorbed, permanent errors surfaced quickly, and no duplicate work.</p>
<h2>Production checklist</h2>
<ul>
<li>Run workers as a separate process from your web server so a slow batch never affects request latency.</li>
<li>Call <code>await worker.close()</code> on <code>SIGTERM</code> so in-flight jobs finish before a deploy kills the process.</li>
<li>Put <code>Retry-After</code> handling and error classification in one place, like <code>isRetryable</code>, and test it.</li>
<li>Log <code>job.id</code>, <code>attemptsMade</code> and the error on every failure. Track DLQ size as a metric.</li>
<li>Make every side effect idempotent: database upserts keyed by a business id, and an idempotency key on any external API that supports one.</li>
<li>Version your prompts, and store the prompt version with each result, so you can re-run a slice of jobs after a prompt change.</li>
</ul>
<h2>Wrapping up</h2>
<p>The model call is the easy part of AI automation. The work is everything around it: deciding what to retry, how long to wait, how fast to go, and where failures end up. A queue such as BullMQ gives you those controls for the cost of a Redis instance and a few hundred lines of TypeScript.</p>
<p>If you are building agents rather than batch pipelines, my posts on <a href="/blog/mcp-vs-api">MCP vs API</a> and <a href="/blog/build-mcp-server-nextjs">building an MCP server in Next.js</a> cover the other side of the same problem.</p>
]]></content:encoded></item><item><title><![CDATA[pgvector vs Pinecone vs Qdrant: Benchmarked for RAG in 2026]]></title><description><![CDATA[TL;DR Start with pgvector if you already run Postgres: on 100,000 OpenAI embeddings it hit 0.948 recall@10 at 1.45 ms with defaults and 0.988 at 4.0 ms…]]></description><link>https://fabwaseem.hashnode.dev/pgvector-vs-pinecone-vs-qdrant-benchmarked-for-rag-in-2026</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/pgvector-vs-pinecone-vs-qdrant-benchmarked-for-rag-in-2026</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[AI]]></category><category><![CDATA[RAG ]]></category><category><![CDATA[vector database]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 04:43:27 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792255904/fabwaseem/blog/pgvector-vs-pinecone-vs-qdrant/cover.jpg" alt="pgvector vs Pinecone vs Qdrant cover: recall@10 and latency for 100k OpenAI embeddings, pgvector 0.988 at 4.0 ms and Qdrant 0.985 at 2.6 ms" /></p>
<p><strong>TL;DR</strong></p>
<p>Start with pgvector if you already run Postgres: on 100,000 OpenAI embeddings it hit 0.948 recall@10 at 1.45 ms with defaults and 0.988 at 4.0 ms with ef_search 200. Qdrant 1.19 hit 0.985 at 2.6 ms, about 1.5x faster at equal recall, and handled filters best (0.993 recall at 2.1 ms). Enable hnsw.iterative_scan for filtered pgvector queries. Pinecone is serverless and eventually consistent, with a $50/month Standard minimum.</p>
<p><strong>pgvector</strong> is a PostgreSQL extension that stores embeddings in a <code>vector</code> column and searches them with HNSW or IVFFlat indexes, next to your normal tables. <strong>Qdrant</strong> is an open-source vector database you run yourself or use as a managed cloud. <strong>Pinecone</strong> is a fully managed, serverless vector database you only reach through an API.</p>
<p>For a RAG app, the real question is whether you need a separate vector database at all. I loaded the same 100,000 OpenAI embeddings (1,536 dimensions) into pgvector 0.8.7 on PostgreSQL 18.6 and into Qdrant 1.19.2 on one machine. Then I measured recall, latency, build time and filtered search. Pinecone is covered from its official docs and limits only, because I didn't run it and I won't invent numbers for it.</p>
<h2>TL;DR: pgvector vs Pinecone vs Qdrant, which should I use?</h2>
<p>Start with <strong>pgvector</strong> if you already run Postgres and have up to a few million vectors. One database, SQL joins, transactions and backups you already have. Choose <strong>Qdrant</strong> when vector search is the core workload and you need the best recall per millisecond or heavy filtering. Choose <strong>Pinecone</strong> when you want zero infrastructure and accept a managed service with usage pricing.</p>
<table>
<thead>
<tr>
<th></th>
<th>pgvector</th>
<th>Qdrant</th>
<th>Pinecone</th>
</tr>
</thead>
<tbody><tr>
<td>What it is</td>
<td>Postgres extension</td>
<td>Vector database (open source)</td>
<td>Managed vector database (closed)</td>
</tr>
<tr>
<td>Hosting</td>
<td>Anywhere Postgres runs</td>
<td>Self-host or Qdrant Cloud</td>
<td>Pinecone's cloud only</td>
</tr>
<tr>
<td>Index</td>
<td>HNSW, IVFFlat</td>
<td>HNSW per segment</td>
<td>Managed (serverless, object storage)</td>
</tr>
<tr>
<td>Max indexed dims</td>
<td>2,000 <code>vector</code>, 4,000 <code>halfvec</code></td>
<td>1,536 tested here</td>
<td>20,000 dense</td>
</tr>
<tr>
<td>Filtering</td>
<td>SQL <code>WHERE</code> (post-filter, iterative scans)</td>
<td>Payload indexes + filter-aware HNSW</td>
<td>Metadata filters (40 KB per record)</td>
</tr>
<tr>
<td>Consistency</td>
<td>Transactional (ACID)</td>
<td>Upserts readable after <code>wait=true</code></td>
<td>Eventually consistent</td>
</tr>
<tr>
<td>Recall@10 at ~2.6 ms, my test</td>
<td>0.978 (ef_search 100)</td>
<td>0.985 (hnsw_ef 40)</td>
<td>not tested</td>
</tr>
<tr>
<td>Cost to start</td>
<td>Your Postgres</td>
<td>Free binary / free cloud tier</td>
<td>Free Starter; Standard $50/month minimum</td>
</tr>
</tbody></table>
<h2>Do I need a vector database for RAG?</h2>
<p>Usually not at first. If your app already uses PostgreSQL, pgvector adds vector search without a second system to deploy, secure, back up and keep in sync. In my test it answered top-10 queries over 100,000 OpenAI embeddings in 1.5-4 ms, with 95-99% recall. A dedicated database starts to pay off at very large scale, under heavy filtering, or when search load needs to scale separately.</p>
<p>The hidden cost of a separate vector store is synchronisation. Every document insert, update and delete has to reach two systems, and a failed write leaves them disagreeing. With pgvector the embedding sits in the same row and the same transaction as the document it describes.</p>
<h2>What is pgvector?</h2>
<p>pgvector is an open-source PostgreSQL extension that adds <code>vector</code>, <code>halfvec</code>, <code>bit</code> and <code>sparsevec</code> types, distance operators like <code>&lt;=&gt;</code> (cosine) and <code>&lt;-&gt;</code> (L2), and approximate HNSW and IVFFlat indexes. Per the <a href="https://github.com/pgvector/pgvector">pgvector README</a>, it "performs exact nearest neighbor search, which provides perfect recall" by default. An index trades a little recall for speed.</p>
<pre><code class="language-sql">CREATE EXTENSION vector;

CREATE TABLE documents (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tenant_id int NOT NULL,
  content   text NOT NULL,
  embedding vector(1536) NOT NULL
);

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);

-- top 5 chunks for a question, inside one tenant
SELECT id, content
FROM documents
WHERE tenant_id = $1
ORDER BY embedding &lt;=&gt; $2
LIMIT 5;
</code></pre>
<p>Index limits matter for model choice. HNSW can index a <code>vector</code> of "up to 2,000 dimensions" and a <code>halfvec</code> of "up to 4,000 dimensions". A 3,072-dimension model such as <code>text-embedding-3-large</code> needs a <code>halfvec</code> index or a shorter output size.</p>
<h2>What are Qdrant and Pinecone?</h2>
<p><strong>Qdrant</strong> is an open-source vector search engine written in Rust. It stores points (vectors plus a JSON payload), builds an HNSW index per segment, and adds payload indexes so filters run inside the graph search. You can run a single binary, Docker or Qdrant Cloud.</p>
<p><strong>Pinecone</strong> is a managed, serverless vector database. Per its <a href="https://docs.pinecone.io/guides/get-started/database-architecture">architecture docs</a>, "all your vector data is stored in highly efficient, distributed object storage." Pinecone "separates write and read operations into different paths, with each scaling independently based on demand." There is no self-hosted version.</p>
<h2>How I benchmarked pgvector and Qdrant</h2>
<ol>
<li><strong>Data.</strong> The first 100,000 rows of the public <a href="https://huggingface.co/datasets/KShivendu/dbpedia-entities-openai-1M">DBpedia OpenAI embeddings dataset</a>: 1,536-dimension <code>text-embedding-ada-002</code> vectors of Wikipedia entities, a typical RAG shape. 1,000 held-out rows from the same dataset served as queries.</li>
<li><strong>Ground truth.</strong> Exact cosine top-10 for every query, computed with NumPy. Recall@10 is the share of those 10 that each engine returned.</li>
<li><strong>Machine.</strong> One 8-vCPU, 15 GB Linux box, everything on localhost, no Docker.</li>
<li><strong>pgvector.</strong> PostgreSQL 18.6 with pgvector 0.8.7, <code>shared_buffers = 2GB</code>, <code>maintenance_work_mem = 2GB</code>. HNSW with defaults (<code>m = 16</code>, <code>ef_construction = 64</code>), loaded with binary <code>COPY</code> through psycopg 3.</li>
<li><strong>Qdrant.</strong> The Qdrant 1.19.2 release binary, cosine distance, <code>m = 16</code> and <code>ef_construct = 64</code> to match pgvector, default optimizer settings (it made 4 segments). Loaded with the official Python client over gRPC from a single process.</li>
<li><strong>Queries.</strong> One client, sequential, 50 warm-up queries, then 1,000 timed queries. Latency includes the Python client round trip on both sides.</li>
<li><strong>Filters.</strong> Every vector got a random <code>category</code> from 0 to 9, about 10% of rows each. Filtered queries asked for the top 10 inside one category.</li>
</ol>
<h2>Is pgvector fast enough? Recall and latency results</h2>
<p>Yes, at this scale both engines are fast. pgvector reached 0.948 recall@10 at 1.45 ms median with default settings, and 0.988 at 4.0 ms with <code>hnsw.ef_search = 200</code>. Qdrant reached 0.985 recall at 2.6 ms median and 0.999 at 5.4 ms. At roughly equal recall (about 98.5-98.8%), Qdrant was about 1.5 times faster: 2.6 ms against 4.0 ms.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792255904/fabwaseem/blog/pgvector-vs-pinecone-vs-qdrant/diagram-recall.jpg" alt="pgvector vs Qdrant recall@10 vs median latency on 100,000 OpenAI embeddings: Qdrant 0.985 at 2.6 ms, pgvector 0.978 at 2.6 ms and 0.988 at 4.0 ms" /></p>
<table>
<thead>
<tr>
<th>Engine and setting</th>
<th>Recall@10</th>
<th>p50 latency</th>
<th>p95 latency</th>
<th>Queries/s (1 client)</th>
</tr>
</thead>
<tbody><tr>
<td>pgvector, <code>ef_search = 40</code> (default)</td>
<td>0.948</td>
<td>1.45 ms</td>
<td>2.16 ms</td>
<td>683</td>
</tr>
<tr>
<td>pgvector, <code>ef_search = 100</code></td>
<td>0.978</td>
<td>2.60 ms</td>
<td>3.70 ms</td>
<td>389</td>
</tr>
<tr>
<td>pgvector, <code>ef_search = 200</code></td>
<td>0.988</td>
<td>4.02 ms</td>
<td>5.82 ms</td>
<td>249</td>
</tr>
<tr>
<td>pgvector <code>halfvec</code> index, <code>ef_search = 100</code></td>
<td>0.973</td>
<td>2.33 ms</td>
<td>3.30 ms</td>
<td>433</td>
</tr>
<tr>
<td>Qdrant, <code>hnsw_ef = 40</code></td>
<td>0.985</td>
<td>2.63 ms</td>
<td>3.56 ms</td>
<td>368</td>
</tr>
<tr>
<td>Qdrant, <code>hnsw_ef = 100</code></td>
<td>0.997</td>
<td>3.73 ms</td>
<td>4.66 ms</td>
<td>255</td>
</tr>
<tr>
<td>Qdrant, <code>hnsw_ef = 200</code></td>
<td>0.999</td>
<td>5.37 ms</td>
<td>6.99 ms</td>
<td>176</td>
</tr>
<tr>
<td>pgvector exact (no index)</td>
<td>1.000</td>
<td>380 ms</td>
<td>445 ms</td>
<td>3</td>
</tr>
<tr>
<td>Qdrant exact (no index)</td>
<td>1.000</td>
<td>24.7 ms</td>
<td>32.6 ms</td>
<td>39</td>
</tr>
</tbody></table>
<p>Why does Qdrant get higher recall at the same <code>ef</code>? It splits the collection into segments (4 here) and searches each one with that <code>ef</code>, so it explores more candidates in total. Compare the engines at equal recall, not equal settings.</p>
<h3>How fast do they load and index?</h3>
<p>Qdrant indexed faster: 30.8 seconds from the first upload to a fully indexed collection, with the upload itself taking 20.2 s from one Python process. pgvector loaded the rows with <code>COPY</code> in 3.9 s, then needed 69.1 s to build the HNSW index with the default 2 parallel workers.</p>
<table>
<thead>
<tr>
<th></th>
<th>pgvector</th>
<th>Qdrant</th>
</tr>
</thead>
<tbody><tr>
<td>Load 100k vectors</td>
<td>3.9 s (<code>COPY</code>)</td>
<td>20.2 s (Python client, gRPC)</td>
</tr>
<tr>
<td>Build HNSW index</td>
<td>69.1 s</td>
<td>included, indexed at 30.8 s</td>
</tr>
<tr>
<td>Table / data size</td>
<td>798 MB</td>
<td>—</td>
</tr>
<tr>
<td>HNSW index size</td>
<td>781 MB (<code>vector</code>), 391 MB (<code>halfvec</code>)</td>
<td>—</td>
</tr>
<tr>
<td>Server memory after test</td>
<td>Postgres buffers + OS cache</td>
<td>1,293 MB RSS</td>
</tr>
</tbody></table>
<p>Use the <code>halfvec</code> trick in pgvector: indexing <code>embedding::halfvec(1536)</code> halved the index from 781 MB to 391 MB and cost about half a point of recall (0.973 against 0.978 at <code>ef_search = 100</code>). Query the index with the same cast:</p>
<pre><code class="language-sql">CREATE INDEX ON documents
  USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops);

SELECT id FROM documents
ORDER BY embedding::halfvec(1536) &lt;=&gt; $1::halfvec(1536)
LIMIT 10;
</code></pre>
<h2>How does filtered vector search compare?</h2>
<p>Qdrant handled filters best. With a payload index on <code>category</code>, filtered top-10 queries returned 0.993 recall at 2.1 ms median. pgvector gave three very different results depending on the plan Postgres picked, so you need to know how its filtering works.</p>
<table>
<thead>
<tr>
<th>Filtered query (10% of rows match)</th>
<th>Rows returned (of 10)</th>
<th>Recall@10</th>
<th>p50</th>
</tr>
</thead>
<tbody><tr>
<td>pgvector, planner's choice (B-tree on category, exact sort)</td>
<td>10</td>
<td>1.000</td>
<td>44.2 ms</td>
</tr>
<tr>
<td>pgvector, HNSW, <code>iterative_scan = off</code></td>
<td>3.92</td>
<td>0.389</td>
<td>1.77 ms</td>
</tr>
<tr>
<td>pgvector, HNSW, <code>iterative_scan = strict_order</code></td>
<td>10</td>
<td>0.915</td>
<td>3.62 ms</td>
</tr>
<tr>
<td>pgvector, HNSW, <code>iterative_scan = relaxed_order</code></td>
<td>10</td>
<td>0.953</td>
<td>3.37 ms</td>
</tr>
<tr>
<td>Qdrant, payload index</td>
<td>10</td>
<td>0.993</td>
<td>2.11 ms</td>
</tr>
</tbody></table>
<h3>Why does pgvector return fewer rows with a filter?</h3>
<p>Because HNSW filters after the index scan. The README explains: "If a condition matches 10% of rows, with HNSW and the default <code>hnsw.ef_search</code> of 40, only 4 rows will match on average." I measured 3.92. Since pgvector 0.8.0, iterative index scans keep walking the graph until enough rows match.</p>
<pre><code class="language-sql">-- once per session or per transaction (SET LOCAL)
SET hnsw.iterative_scan = relaxed_order;

SELECT id, content FROM documents
WHERE tenant_id = $1
ORDER BY embedding &lt;=&gt; $2
LIMIT 10;
</code></pre>
<p>Postgres often picks the exact plan by itself for a selective filter. In my test it used the <code>category</code> B-tree and sorted about 10,000 rows exactly: perfect recall, but 44 ms instead of about 3 ms. That is fine for one tenant with a few thousand chunks. For large tenants, check <code>EXPLAIN</code> and turn on iterative scans. If one filter dominates, consider a partial index or partitioning by tenant.</p>
<h2>What does Pinecone offer that pgvector and Qdrant don't?</h2>
<p>Fully managed, serverless scaling with nothing to run. Reads and writes scale separately, storage lives in object storage, and you never tune <code>ef</code> or <code>m</code>. The trade-offs are documented:</p>
<ul>
<li><strong>Eventual consistency.</strong> The <a href="https://docs.pinecone.io/reference/api/known-limitations">known limitations</a> page says "Pinecone is eventually consistent, so there can be a slight delay before upserted records are available to query."</li>
<li><strong>Operation limits.</strong> Per the <a href="https://docs.pinecone.io/reference/api/database-limits/operation-limits">operation limits</a>: dense vectors up to 20,000 dimensions, 40 KB of filterable metadata per record, 1,000 records or 2 MB per upsert batch, and <code>top_k</code> up to 10,000.</li>
<li><strong>Plans.</strong> The <a href="https://docs.pinecone.io/reference/api/database-limits/object-limits">object limits</a> give the free Starter plan 2 GB of serverless storage and 5 indexes per project, all in AWS <code>us-east-1</code>. <a href="https://www.pinecone.io/pricing/">Pinecone pricing</a> lists the Standard plan at a $50/month minimum.</li>
<li><strong>Network hop.</strong> Every query leaves your VPC. Expect your cloud region's round-trip time on top of search time. My localhost latencies are not comparable to Pinecone and I did not measure it.</li>
</ul>
<h2>When should I choose each one?</h2>
<p>Decide by what you already run and what dominates your workload. The pgvector default is strong for product teams. Move when you measure a reason, not before.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792255906/fabwaseem/blog/pgvector-vs-pinecone-vs-qdrant/diagram-decision.jpg" alt="Decision guide: choose pgvector if you already run Postgres, Qdrant when search is the product, Pinecone for zero infrastructure" /></p>
<ol>
<li><strong>Choose pgvector</strong> when your data lives in Postgres, you need joins, row-level security or transactions with your embeddings, and you have up to a few million vectors per index. Add <a href="https://github.com/timescale/pgvectorscale">pgvectorscale</a> (StreamingDiskANN) if the index outgrows RAM.</li>
<li><strong>Choose Qdrant</strong> when search is the product: tens of millions of vectors, many filters, multi-vector or hybrid search, or quantization to cut memory. Its <a href="https://qdrant.tech/documentation/guides/quantization/">quantization guide</a> says scalar quantization achieves "4x compression with minimal accuracy loss."</li>
<li><strong>Choose Pinecone</strong> when nobody on the team should run a database, traffic is spiky, and a usage bill plus eventual consistency are acceptable.</li>
</ol>
<h2>How do I move from pgvector to Qdrant later?</h2>
<p>Keep embeddings and IDs portable from day one. Use the same primary key in both systems, store the embedding model name and dimensions with each row, and write a re-sync job that streams rows by ID. Qdrant accepts integer or UUID point IDs, so a <a href="https://www.fabwaseem.com/blog/uuidv7-postgres">UUIDv7 primary key</a> maps across directly.</p>
<p>Then run both side by side, compare recall on a fixed set of real queries, and switch reads before you stop writing to Postgres.</p>
<h2>Common mistakes with vector search in production</h2>
<ul>
<li><strong>Indexing 3,072-dimension vectors as <code>vector</code>.</strong> HNSW on <code>vector</code> stops at 2,000 dimensions. Use <code>halfvec</code> or a smaller output dimension.</li>
<li><strong>Forgetting iterative scans.</strong> Filtered HNSW queries in pgvector can silently return fewer than <code>LIMIT</code> rows.</li>
<li><strong>Comparing engines at equal settings.</strong> <code>ef</code> means different work in different engines. Compare at equal recall.</li>
<li><strong>Measuring recall on random vectors.</strong> Use real embeddings from your model. Random data hides graph quality.</li>
<li><strong>Creating Qdrant payload indexes after loading.</strong> The <a href="https://qdrant.tech/documentation/concepts/indexing/">indexing docs</a> say payload indexes should be created before ingesting data, so filter-aware HNSW edges get built.</li>
<li><strong>Ignoring re-embedding cost.</strong> Switching embedding models means re-embedding everything, whichever store you use.</li>
</ul>
<p>If you want a working starting point, my open-source <a href="https://www.fabwaseem.com/blog/nextjs-ai-starter-kit">Next.js AI starter kit, Forgekit</a>, ships RAG on pgvector. For self-hosting Postgres or Qdrant next to your app, see <a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify">self-hosting Next.js with Coolify</a>.</p>
<h2>Key takeaways</h2>
<ul>
<li>With 100,000 OpenAI embeddings, pgvector 0.8.7 hit 0.948 recall@10 at 1.45 ms with defaults and 0.988 at 4.0 ms with ef_search 200.</li>
<li>Qdrant 1.19.2 hit 0.985 recall at 2.6 ms, about 1.5x faster than pgvector at roughly equal recall.</li>
<li>pgvector's HNSW filters after scanning: a 10% filter returned only 3.92 of 10 rows until iterative scans were enabled.</li>
<li>Qdrant's payload index gave the best filtered search in my test: 0.993 recall at 2.1 ms.</li>
<li>A halfvec HNSW index halved pgvector's index size (781 MB to 391 MB) for about half a point of recall.</li>
<li>Pinecone is serverless and eventually consistent, supports 20,000-dimension vectors, and its Standard plan has a $50/month minimum.</li>
<li>Start with pgvector if you already run Postgres; move to Qdrant or Pinecone when measurements show a need.</li>
</ul>
<h2>Questions</h2>
<h3>Is pgvector as good as Pinecone or Qdrant?</h3>
<p>For most RAG apps up to a few million vectors, yes. In my test of 100,000 OpenAI embeddings, pgvector reached 0.988 recall@10 at 4.0 ms median, and Qdrant reached 0.985 at 2.6 ms. Qdrant was about 1.5 times faster at equal recall and better at filtered search, but pgvector keeps embeddings in the same transactional database as your data.</p>
<h3>Do I need a vector database for RAG?</h3>
<p>Not usually at the start. If your app already uses PostgreSQL, the pgvector extension adds vector columns and HNSW indexes without a second system to deploy, back up and keep in sync. Consider a dedicated vector database when you reach tens of millions of vectors, need heavy filtering at low latency, or need to scale search separately.</p>
<h3>Why does pgvector return fewer results than my LIMIT?</h3>
<p>Because pgvector's HNSW index applies WHERE filters after scanning the index. With a filter matching 10 percent of rows and the default ef search of 40, about 4 rows match on average; I measured 3.92. Set hnsw.iterative scan to relaxed order or strict order, available since pgvector 0.8.0, so the scan continues until enough rows match.</p>
<h3>How many dimensions can pgvector index?</h3>
<p>pgvector's HNSW and IVFFlat indexes support the vector type up to 2,000 dimensions, halfvec up to 4,000 and bit up to 64,000. For a 3,072-dimension model such as text-embedding-3-large, index the column as halfvec or request a smaller output dimension. Pinecone supports dense vectors up to 20,000 dimensions.</p>
<h3>Is Qdrant faster than pgvector?</h3>
<p>In my single-machine test, yes, at equal recall. Qdrant 1.19.2 returned 0.985 recall@10 at 2.6 ms median against pgvector's 0.988 at 4.0 ms, and indexed 100,000 vectors in about 31 seconds against pgvector's 69-second HNSW build. With default settings, pgvector was faster per query but at lower recall, 0.948 at 1.45 ms.</p>
<h3>How much does Pinecone cost?</h3>
<p>Pinecone has a free Starter plan with 2 GB of serverless storage and up to 5 indexes per project, all in AWS us-east-1. Its Standard plan for production has a $50 per month minimum, after which you pay as you go for usage, according to Pinecone's pricing page. Qdrant's open-source build and pgvector are free to self-host.</p>
<h3>What is halfvec in pgvector?</h3>
<p>halfvec is pgvector's half-precision vector type, storing each dimension in 2 bytes instead of 4. Indexing an expression like embedding::halfvec(1536) halves the HNSW index size; in my test it shrank from 781 MB to 391 MB with recall dropping from 0.978 to 0.973. It also raises the indexable limit to 4,000 dimensions.</p>
<h3>Is Pinecone eventually consistent?</h3>
<p>Yes. Pinecone's documentation states that it is eventually consistent, so there can be a slight delay before upserted records can be queried, and suggests checking index stats to confirm counts. pgvector writes are transactional and visible on commit, and Qdrant upserts sent with wait=true are applied before the call returns.</p>
<p>Originally published at <a href="https://www.fabwaseem.com/blog/pgvector-vs-pinecone-vs-qdrant">https://www.fabwaseem.com/blog/pgvector-vs-pinecone-vs-qdrant</a></p>
]]></content:encoded></item><item><title><![CDATA[Drizzle vs Prisma in 2026: Benchmarks, Drizzle 1.0, Prisma 8 and Which to Choose]]></title><description><![CDATA[TL;DR Pick Drizzle if you think in SQL, deploy to serverless or edge, and want the smallest install and fastest cold start (30 MB and about 35 ms bundled…]]></description><link>https://fabwaseem.hashnode.dev/drizzle-vs-prisma-in-2026-benchmarks-drizzle-1-0-prisma-8-and-which-to-choose</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/drizzle-vs-prisma-in-2026-benchmarks-drizzle-1-0-prisma-8-and-which-to-choose</guid><category><![CDATA[prisma]]></category><category><![CDATA[drizzle]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[orm]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 04:42:45 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792248749/fabwaseem/blog/drizzle-vs-prisma/cover.jpg" alt="Drizzle vs Prisma cover: a bench.ts panel with deep-page timings for Prisma 7, Drizzle 0.45, Drizzle 1.0, Prisma 8 RC and keyset pagination on PostgreSQL 17" /></p>
<p><strong>TL;DR</strong></p>
<p>Pick Drizzle if you think in SQL, deploy to serverless or edge, and want the smallest install and fastest cold start (30 MB and about 35 ms bundled in my test). Pick Prisma 7 for a generated client and guided migrations. Simple queries were within 0.1 ms of each other. Deep OFFSET pages with nested posts were 6-19x slower in single-query mode for both ORMs; keyset pagination fixed it. Drizzle 1.0 and Prisma 8 are still release candidates.</p>
<p><strong>Drizzle vs Prisma</strong> comes down to how you want to write data access in TypeScript. Drizzle is a thin, SQL-shaped query builder: your schema is TypeScript and every query maps closely to the SQL it sends. Prisma is a higher-level ORM: you write a schema file and get a generated, model-based client. Both are type-safe, and both work well with PostgreSQL and Next.js.</p>
<p>In October 2026 both are in the middle of a major version. Drizzle 1.0 is a release candidate while 0.45.4 is still npm <code>latest</code>. Prisma 8 is a release candidate that npm <code>latest</code> already installs. So I benchmarked all five combinations against the same PostgreSQL 17 database. On simple queries the gap was a tenth of a millisecond. On one common query pattern it was 6-19x, and the cause was the shape of the SQL, not the ORM.</p>
<h2>TL;DR: Drizzle or Prisma in 2026?</h2>
<p>Pick <strong>Drizzle</strong> if you think in SQL, deploy to serverless or edge, and want the smallest install and fastest cold start: 30 MB and about 35 ms bundled in my test. Pick <strong>Prisma 7</strong> if you want the most guided workflow, a generated client and Prisma Migrate. Wait for GA before moving production to Drizzle 1.0 or Prisma 8.</p>
<table>
<thead>
<tr>
<th></th>
<th>Drizzle</th>
<th>Prisma</th>
</tr>
</thead>
<tbody><tr>
<td>Stable line</td>
<td>0.45.4 (npm <code>latest</code>)</td>
<td>7.10.1 (npm <code>prev</code>)</td>
</tr>
<tr>
<td>Next major</td>
<td>1.0.0-rc.4 (npm <code>rc</code>)</td>
<td>8.0.0-rc (npm <code>latest</code>)</td>
</tr>
<tr>
<td>Schema lives in</td>
<td>TypeScript (<code>pgTable</code>)</td>
<td><code>schema.prisma</code> (7) or <code>contract.prisma</code> (8)</td>
</tr>
<tr>
<td>Code generation</td>
<td>None</td>
<td><code>prisma generate</code> (7), <code>prisma contract emit</code> (8)</td>
</tr>
<tr>
<td>Query style</td>
<td>SQL builder + relational <code>db.query</code></td>
<td>Model client (7), chained <code>db.orm</code> (8)</td>
</tr>
<tr>
<td>Migrations</td>
<td>drizzle-kit <code>generate</code> / <code>migrate</code> / <code>push</code></td>
<td>Prisma Migrate</td>
</tr>
<tr>
<td>Production install (my test)</td>
<td>21-30 MB, 18 packages</td>
<td>329 MB (7), 130 MB (8)</td>
</tr>
<tr>
<td>Bundled cold start (my test)</td>
<td>~35 ms</td>
<td>~100 ms (7), ~370 ms (8 RC)</td>
</tr>
</tbody></table>
<h2>What is the difference between Drizzle and Prisma?</h2>
<p>Drizzle defines tables in TypeScript and gives you a typed SQL builder, so <code>db.select().from(users).where(...)</code> reads like the SQL it sends, with no generate step. Prisma defines models in its own schema language, generates a client and hides SQL behind model methods. Drizzle is closer to the database; Prisma is closer to your domain model.</p>
<p>The same table in each:</p>
<pre><code class="language-ts">import { defineRelations } from "drizzle-orm";
import { boolean, index, integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";

export const users = pgTable("User", {
  id: serial().primaryKey(),
  email: text().notNull().unique(),
  name: text(),
  createdAt: timestamp({ precision: 3, mode: "date" }).notNull().defaultNow(),
});

export const posts = pgTable("Post", {
  id: serial().primaryKey(),
  title: text().notNull(),
  published: boolean().notNull().default(false),
  views: integer().notNull().default(0),
  authorId: integer().notNull().references(() =&gt; users.id, { onDelete: "cascade" }),
}, (t) =&gt; [index("Post_authorId_idx").on(t.authorId)]);

export const relations = defineRelations({ users, posts }, (r) =&gt; ({
  users: { posts: r.many.posts() },
  posts: { author: r.one.users({ from: r.posts.authorId, to: r.users.id }) },
}));
</code></pre>
<pre><code class="language-prisma">model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  posts     Post[]
}

model Post {
  id        Int     @id @default(autoincrement())
  title     String
  published Boolean @default(false)
  views     Int     @default(0)
  authorId  Int
  author    User    @relation(fields: [authorId], references: [id], onDelete: Cascade)

  @@index([authorId])
}
</code></pre>
<h2>How do queries compare in Drizzle and Prisma?</h2>
<p>All four clients can fetch a user with their posts in one call. Drizzle 1.0 uses object filters in <code>db.query</code>, Prisma 7 uses <code>findMany</code> with <code>include</code>, and Prisma 8 chains <code>.include("posts")</code> on a collection. The type-safety level is the same; the difference is whether the code looks like SQL or like method calls.</p>
<p>These are the exact queries from my benchmark, and all of them type-check under <code>strict</code>:</p>
<pre><code class="language-ts">// Drizzle 0.45 (RQB v1)
db.query.users.findMany({ with: { posts: true }, orderBy: (u, { asc }) =&gt; [asc(u.id)], offset, limit: 20 });

// Drizzle 1.0 (RQB v2)
db.query.users.findMany({ with: { posts: true }, orderBy: { id: "asc" }, offset, limit: 20 });

// Prisma 7
prisma.user.findMany({ include: { posts: true }, orderBy: { id: "asc" }, skip: offset, take: 20 });

// Prisma 8 RC
db.orm.public.User.include("posts").orderBy((u) =&gt; u.id.asc()).offset(offset).limit(20).all();
</code></pre>
<p>Drizzle also keeps its plain SQL builder (<code>db.select().from(posts).innerJoin(...)</code>) for anything the relational API does not cover. Prisma 7 falls back to <code>$queryRaw</code> or TypedSQL, and Prisma 8 has its own SQL builder. I covered Prisma 8's query API in detail in my <a href="https://www.fabwaseem.com/blog/prisma-8">Prisma 8 guide</a>.</p>
<h2>Is Drizzle faster than Prisma?</h2>
<p>For single-row reads and inserts, barely. In my test every client answered a primary-key lookup in 0.12-0.20 ms against local PostgreSQL, and over a network that gap disappears. The real difference was in nested pagination: with <code>OFFSET</code>, single-query JSON loading got 6-11x slower than Prisma 7's two-query default.</p>
<h3>Benchmark setup</h3>
<ul>
<li>PostgreSQL 17.11 on the same machine (8 vCPU Xeon, 15 GB RAM), Node.js 24.22.0, 1,000 users and 10,000 posts with an index on <code>Post.authorId</code>.</li>
<li>Each case: 200 warm-up calls, then 2,000 timed calls. Each client ran 3 times, with inserted rows deleted before each run. The table shows the median of the three per-run medians.</li>
<li>Versions: Prisma 7.10.1 with <code>@prisma/adapter-pg</code>, Drizzle 0.45.4, Drizzle 1.0.0-rc.4 (with and without <code>jit: true</code>), and Prisma 8 (<code>@prisma/orm-postgres</code> 8.0.0-rc.18).</li>
<li>"Deep page" means 20 users plus their posts at <code>OFFSET</code> 0 to 960.</li>
</ul>
<h3>Results (median, milliseconds)</h3>
<table>
<thead>
<tr>
<th>Query</th>
<th>Prisma 7.10.1</th>
<th>Drizzle 0.45.4</th>
<th>Drizzle 1.0 rc.4</th>
<th>Drizzle 1.0 <code>jit</code></th>
<th>Prisma 8 rc.18</th>
</tr>
</thead>
<tbody><tr>
<td>Find user by id</td>
<td>0.150</td>
<td>0.129</td>
<td><strong>0.121</strong></td>
<td>0.136</td>
<td>0.195</td>
</tr>
<tr>
<td>First page, 20 users + posts</td>
<td>0.730</td>
<td>0.719</td>
<td><strong>0.561</strong></td>
<td>0.617</td>
<td>1.185</td>
</tr>
<tr>
<td>Deep page, 20 users + posts</td>
<td><strong>0.749</strong></td>
<td>4.650</td>
<td>5.164</td>
<td>5.287</td>
<td>8.060</td>
</tr>
<tr>
<td>Insert one post</td>
<td>0.168</td>
<td>0.146</td>
<td><strong>0.139</strong></td>
<td>0.164</td>
<td>0.208</td>
</tr>
<tr>
<td>Process RSS after run</td>
<td>289 MB</td>
<td>251 MB</td>
<td>295 MB</td>
<td>296 MB</td>
<td>404 MB</td>
</tr>
</tbody></table>
<p>Two things stand out. Drizzle 1.0 was the fastest at nearly everything, but <code>jit: true</code> made no measurable difference here. Drizzle documents it as "just-in-time compiled row mappers," and mapping 20 rows is not where the time goes. And the deep-page row looks like a Prisma win, but it isn't one, as the next section shows.</p>
<h2>Why are nested queries with OFFSET slow?</h2>
<p>Drizzle and Prisma 8 load relations in one SQL statement, using a JSON subquery for each parent row. PostgreSQL applies <code>OFFSET</code> last, so it builds the JSON for every skipped row and then throws it away. At <code>OFFSET 960</code>, <code>EXPLAIN ANALYZE</code> showed the posts subquery running 980 times to return 20 users.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792248750/fabwaseem/blog/drizzle-vs-prisma/diagram-sql.jpg" alt="Why OFFSET is slow with single-query relation loading: PostgreSQL builds JSON posts for every skipped row before applying OFFSET, while two-query loading and keyset pagination only touch 20 users" /></p>
<p>This is the SQL Drizzle 0.45 generated (Drizzle 1.0 is nearly identical):</p>
<pre><code class="language-sql">select "users"."id", "users"."email", "users"."name", "users"."createdAt", "users_posts"."data" as "posts"
from "User" "users"
left join lateral (
  select coalesce(json_agg(json_build_array(...)), '[]'::json) as "data"
  from "Post" "users_posts" where "users_posts"."authorId" = "users"."id"
) "users_posts" on true
order by "users"."id" asc limit $1 offset $2
</code></pre>
<p>The PostgreSQL docs warn about this directly: "The rows skipped by an OFFSET clause still have to be computed inside the server; therefore a large OFFSET might be inefficient" (<a href="https://www.postgresql.org/docs/17/queries-limit.html">PostgreSQL 17, LIMIT and OFFSET</a>). Here, each skipped row includes a full posts subquery.</p>
<p>Prisma 7 avoided it only because it sent two queries: the 20 users, then <code>SELECT ... FROM "Post" WHERE "authorId" IN (...)</code>. When I turned on Prisma 7's <code>relationJoins</code> preview and set <code>relationLoadStrategy: "join"</code>, the deep page went from 0.79 ms to 14.8 ms, the slowest result in the whole test. Prisma's <a href="https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries">relation queries docs</a> say <code>join</code> uses "a combination of LATERAL JOINs and JSON aggregation", which is the same shape.</p>
<h3>How do I fix slow nested pagination?</h3>
<p>Use keyset (cursor) pagination instead of <code>OFFSET</code>: ask for "the 20 users after id X". PostgreSQL then seeks straight to the right index position and builds JSON for only 20 rows. In my test, Drizzle 1.0 deep pages dropped from 5.16 ms to 0.66 ms and Prisma 8 from 8.06 ms to 1.29 ms.</p>
<pre><code class="language-ts">// Drizzle 1.0
db.query.users.findMany({ with: { posts: true }, where: { id: { gt: afterId } }, orderBy: { id: "asc" }, limit: 20 });

// Prisma 8 RC
db.orm.public.User.where((u) =&gt; u.id.gt(afterId)).include("posts").orderBy((u) =&gt; u.id.asc()).limit(20).all();

// Prisma 7
prisma.user.findMany({ include: { posts: true }, cursor: { id: afterId }, skip: 1, take: 20, orderBy: { id: "asc" } });
</code></pre>
<p>Prisma's <a href="https://www.prisma.io/docs/orm/prisma-client/queries/pagination">pagination guide</a> recommends the same: use cursor-based pagination "when you care more about performance and consistency as the dataset grows." If you need page numbers, run the parent query first and load children with a second <code>IN (...)</code> query. That is exactly what Prisma 7's default does.</p>
<h2>Which has a faster cold start, Drizzle or Prisma?</h2>
<p>Drizzle, once bundled. With esbuild, importing the client and running the first query took about 35 ms with Drizzle, about 100 ms with Prisma 7 and about 370 ms with Prisma 8 RC. Unbundled, Drizzle was slower than Prisma 7, because Node loaded 425-528 separate Drizzle module files from <code>node_modules</code>.</p>
<table>
<thead>
<tr>
<th>Median of 5 process starts</th>
<th>Prisma 7.10.1</th>
<th>Drizzle 0.45.4</th>
<th>Drizzle 1.0 rc.4</th>
<th>Prisma 8 rc.18</th>
</tr>
</thead>
<tbody><tr>
<td>Unbundled: import + first query</td>
<td>55 + 89 ms</td>
<td>400 + 31 ms</td>
<td>699 + 18 ms</td>
<td>494 + 30 ms</td>
</tr>
<tr>
<td>esbuild bundle: import + first query</td>
<td>21 + 78 ms</td>
<td>17 + 19 ms</td>
<td>16 + 18 ms</td>
<td>342 + 26 ms</td>
</tr>
<tr>
<td>Bundle size</td>
<td>5.0 MB</td>
<td>448 KB</td>
<td>476 KB</td>
<td>1.8 MB</td>
</tr>
</tbody></table>
<p>Next.js and most serverless builds bundle server code, so the bundled row is the one that usually applies. Prisma 7's import is fast, but its first query costs about 78 ms; the client ships its query compiler as base64-encoded WebAssembly, which is the likely cost of that first call. Prisma 8 RC's time goes into startup itself. A CPU profile showed the top functions inside arktype's <code>@ark/schema</code> and <code>@ark/util</code>, which Prisma 8 uses for runtime validation. That is RC behaviour and may change before GA.</p>
<h2>How big is Drizzle vs Prisma in node_modules?</h2>
<p>Drizzle installed in 21 MB (0.45.4) or 30 MB (1.0 RC) with 18 packages, including <code>pg</code>. With <code>npm ci --omit=dev</code>, Prisma 7 came to 329 MB and 345 packages, and Prisma 8 RC to 130 MB and 118 packages. For Docker images and serverless size limits, that is the clearest difference between them.</p>
<p>Where the Prisma weight comes from, according to <code>npm ls</code> and <code>du</code>:</p>
<ul>
<li><strong>Prisma 7:</strong> <code>@prisma/client</code> (72 MB) lists <code>prisma</code> and <code>typescript</code> as optional peer dependencies. My project had both as devDependencies, so npm kept them in the production tree, along with the CLI, <code>@prisma/studio-core</code> (43 MB) and <code>@prisma/dev</code>.</li>
<li><strong>Prisma 8 RC:</strong> <code>@prisma/orm-postgres</code> depends on <code>@prisma/orm-toolchain</code>, which brings <code>typescript</code>, <code>prettier</code> and rolldown into a production install.</li>
</ul>
<p>If image size matters, bundle the server and copy only the output. My <a href="https://www.fabwaseem.com/blog/nextjs-ai-starter-kit">Next.js AI starter kit</a> uses Drizzle partly for this reason.</p>
<h2>How do migrations compare?</h2>
<p>Both generate SQL migration files from your schema. drizzle-kit diffs your TypeScript schema and writes plain SQL, and <code>push</code> can sync a dev database without a migration file. Prisma Migrate diffs <code>schema.prisma</code>, keeps history in <code>prisma/migrations</code> and manages a shadow database for <code>migrate dev</code>. Both let you edit the generated SQL before applying it.</p>
<p>Drizzle 1.0 changed the folder layout. <code>npx drizzle-kit generate --name init</code> created this:</p>
<pre><code class="language-text">drizzle/
└── 20261017144733_init/
    ├── migration.sql
    └── snapshot.json
</code></pre>
<p>There is no <code>meta/_journal.json</code> any more. The <a href="https://orm.drizzle.team/docs/upgrade-v1">Drizzle v1 upgrade guide</a> says this removes "potential Git conflicts with the journal file," and <code>drizzle-kit generate</code> now runs commutativity checks that flag migrations on two branches that change the same thing. Run <code>npx drizzle-kit up</code> once to convert an existing 0.x migrations folder.</p>
<p>Prisma Migrate is still the more guided tool: <code>prisma migrate dev</code> uses a shadow database to detect drift and offers to reset the dev database when history and schema disagree. One Prisma 7 change to know: seeding no longer runs automatically during <code>migrate dev</code> or <code>migrate reset</code>; you run <code>npx prisma db seed</code> yourself. See the <a href="https://www.prisma.io/docs/orm/prisma-migrate">Prisma Migrate docs</a>.</p>
<h2>What changed in Drizzle 1.0?</h2>
<p>Drizzle 1.0 removes the old relational query API (RQB v1) and replaces it with <code>defineRelations()</code> and RQB v2. Filters and ordering become plain objects. Validators move into the package as <code>drizzle-orm/zod</code> and similar, <code>getTableColumns</code> becomes <code>getColumns</code>, and drizzle-kit drops the migration journal. It also adds MSSQL and CockroachDB dialects.</p>
<p>From the official <a href="https://orm.drizzle.team/docs/v0-v1-changes">v0 to v1 changes</a> page:</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>0.x</th>
<th>1.0</th>
</tr>
</thead>
<tbody><tr>
<td>Relations</td>
<td><code>relations()</code> per table</td>
<td>One <code>defineRelations()</code> call</td>
</tr>
<tr>
<td>Filters</td>
<td><code>where: (u, { eq }) =&gt; eq(u.id, 1)</code></td>
<td><code>where: { id: 1 }</code></td>
</tr>
<tr>
<td>Casing</td>
<td><code>drizzle({ casing: "snake_case" })</code></td>
<td><code>snakeCase.table(...)</code></td>
</tr>
<tr>
<td>Validation</td>
<td><code>drizzle-zod</code> package</td>
<td><code>drizzle-orm/zod</code></td>
</tr>
<tr>
<td>Migrations</td>
<td><code>meta/_journal.json</code></td>
<td>One folder per migration</td>
</tr>
<tr>
<td>push / pull</td>
<td><code>public</code> schema only by default</td>
<td>All schemas by default</td>
</tr>
<tr>
<td>Introspection</td>
<td>~10 s</td>
<td>under 1 s (Drizzle's figure)</td>
</tr>
</tbody></table>
<p>The <a href="https://orm.drizzle.team/docs/relations-v1-v2">relations v1 to v2 guide</a> covers the query migration step by step. As of October 17, 2026, npm <code>latest</code> is still 0.45.4 (published October 8) and <code>rc</code> is 1.0.0-rc.4 (June 27).</p>
<h2>Should I use Prisma 8 or Prisma 7?</h2>
<p>For production today, Prisma 7. Prisma 8 is a rewrite with a new contract file, a new runtime package and a chained query API, and it is still a release candidate. In my test it was also the slowest to start and the heaviest per process. Prisma 8 is worth trying on a branch now; plan the switch after GA.</p>
<p>Note that <code>npm install prisma</code> already installs Prisma 8, so pin <code>prisma@7</code> and <code>@prisma/client@7</code> in existing projects. The full migration path, with the contract and the new client, is in my <a href="https://www.fabwaseem.com/blog/prisma-8">Prisma 8 guide</a>.</p>
<h2>Which should I choose for Next.js?</h2>
<p>Both work in Next.js 16 server components, route handlers and server actions. Choose Drizzle for Vercel functions, edge routes or small Docker images, where cold start and install size matter. Choose Prisma 7 when a team wants one schema file, generated types and Prisma Migrate's guardrails more than they want SQL control.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792248751/fabwaseem/blog/drizzle-vs-prisma/diagram-decision.jpg" alt="Decision chart for Drizzle vs Prisma in 2026: SQL-first, serverless and small images point to Drizzle; schema-first teams and guided migrations point to Prisma 7; Drizzle 1.0 and Prisma 8 wait for GA" /></p>
<table>
<thead>
<tr>
<th>Your situation</th>
<th>Pick</th>
</tr>
</thead>
<tbody><tr>
<td>You think in SQL and want queries that map one-to-one</td>
<td>Drizzle</td>
</tr>
<tr>
<td>Serverless, edge or tight cold-start budget</td>
<td>Drizzle</td>
</tr>
<tr>
<td>Small Docker image or function size limit</td>
<td>Drizzle</td>
</tr>
<tr>
<td>Team wants one schema file and a generated client</td>
<td>Prisma 7</td>
</tr>
<tr>
<td>Guided migrations with drift detection and a shadow DB</td>
<td>Prisma 7</td>
</tr>
<tr>
<td>Existing Prisma 6/7 app</td>
<td>Stay on Prisma 7, pin it, test 8 on a branch</td>
</tr>
<tr>
<td>New app starting this month</td>
<td>Drizzle 0.45 or Prisma 7, and plan the move to 1.0 / 8</td>
</tr>
<tr>
<td>Paginating nested data</td>
<td>Either: use keyset pagination, not deep <code>OFFSET</code></td>
</tr>
</tbody></table>
<p>Auth libraries matter here too. Better Auth 1.7 lists both <code>drizzle-orm</code> (0.45.2+ or 1.0 RC) and Prisma (5 to 7) as supported peers, but not Prisma 8 yet.</p>
<h2>Common Drizzle and Prisma mistakes</h2>
<ul>
<li><strong>Deep <code>OFFSET</code> with nested relations.</strong> Both ORMs build JSON for every skipped row. Use keyset pagination or a second query.</li>
<li><strong>Turning on Prisma <code>relationJoins</code> everywhere.</strong> In my test it was slower for paginated includes. Measure per query.</li>
<li><strong>Unpinned <code>prisma</code> installs.</strong> <code>latest</code> is Prisma 8 RC. Pin major versions in Prisma 7 projects.</li>
<li><strong>Expecting <code>jit: true</code> to fix slow queries.</strong> It speeds up row mapping, not the SQL.</li>
<li><strong>Running Drizzle unbundled on serverless.</strong> It loads hundreds of module files at startup. Bundle the server output.</li>
<li><strong>Upgrading to Drizzle 1.0 without <code>drizzle-kit up</code>.</strong> Old migration folders must be converted first.</li>
</ul>
<p><strong>Related:</strong> choosing primary keys for these schemas? In <a href="https://www.fabwaseem.com/blog/uuidv7-postgres">UUIDv7 in PostgreSQL 18</a> I benchmarked uuidv7() against UUIDv4 and bigint on 10M rows, with tested Prisma and Drizzle setups for a database-side <code>uuidv7()</code> default.</p>
<p><strong>Related:</strong> building the API layer with NestJS? My <a href="https://www.fabwaseem.com/blog/nestjs-12">NestJS 12 migration guide</a> covers the v11 to v12 upgrade, Zod validation (28% faster than class-validator in my test) and v11 vs v12 benchmarks.</p>
<h2>Key takeaways</h2>
<ul>
<li>Drizzle is a SQL-first TypeScript query builder with no code generation; Prisma is a schema-first ORM with a generated client. Both are fully type-safe.</li>
<li>On simple queries, all five clients I tested answered within 0.12-0.21 ms of local PostgreSQL, so raw speed rarely decides the choice.</li>
<li>Single-query relation loading plus a deep OFFSET was 6-19x slower than two queries, whether it came from Drizzle, Prisma 8 or Prisma 7 with relationJoins. Keyset pagination fixed it.</li>
<li>Bundled, Drizzle started in about 35 ms, Prisma 7 in about 100 ms and Prisma 8 RC in about 370 ms.</li>
<li>Production installs were 21-30 MB for Drizzle, 329 MB for Prisma 7 and 130 MB for Prisma 8 RC.</li>
<li>Drizzle 1.0 and Prisma 8 are both release candidates on October 17, 2026; use Drizzle 0.45 or Prisma 7 in production and pin your versions.</li>
</ul>
<h2>Questions</h2>
<h3>Is Drizzle better than Prisma?</h3>
<p>Neither is better for everyone. Drizzle suits developers who want SQL-shaped queries, no code generation, small installs and fast serverless cold starts. Prisma suits teams that want one schema file, a generated model client and guided migrations. Both are fully type-safe, and on simple queries their speed was within 0.1 ms in my test.</p>
<h3>Is Drizzle faster than Prisma?</h3>
<p>Slightly, on most queries. Drizzle 1.0 RC was fastest on lookups, first pages and inserts, but every client stayed between 0.12 and 0.21 ms on simple queries against local PostgreSQL. The bigger differences were cold start (Drizzle about 35 ms bundled vs about 100 ms for Prisma 7) and nested pagination with OFFSET.</p>
<h3>Why is my nested query with OFFSET slow?</h3>
<p>Drizzle, Prisma 8 and Prisma 7 with relationJoins load relations in one SQL statement, with a JSON subquery per parent row. PostgreSQL applies OFFSET last, so it builds JSON for every skipped row. At OFFSET 960 the posts subquery ran 980 times. Use keyset pagination (WHERE id lastId) or a second query.</p>
<h3>Is Drizzle 1.0 stable?</h3>
<p>Not yet. On October 17, 2026, npm latest for drizzle-orm is 0.45.4, published October 8, and the rc tag is 1.0.0-rc.4 from June 27. Drizzle 1.0 replaces relational queries v1 with defineRelations and RQB v2 and changes the migration folder layout, so test it on a branch first.</p>
<h3>Should I use Prisma 8 or Prisma 7?</h3>
<p>Use Prisma 7 in production for now. Prisma 8 is a release candidate with a new contract file, a new runtime package and a chained query API. In my test it also had the slowest cold start, about 370 ms bundled. Note that npm install prisma already installs Prisma 8, so pin prisma@7 in existing projects.</p>
<h3>Which is better for serverless, Drizzle or Prisma?</h3>
<p>Drizzle. Bundled with esbuild, importing Drizzle and running the first query took about 35 ms, against about 100 ms for Prisma 7 and about 370 ms for Prisma 8 RC. Drizzle's production install was 21 to 30 MB, while Prisma 7's came to 329 MB with npm ci --omit=dev.</p>
<h3>Does Drizzle have migrations like Prisma Migrate?</h3>
<p>Yes. drizzle-kit generate diffs your TypeScript schema into SQL migration files, drizzle-kit migrate applies them, and drizzle-kit push syncs a dev database directly. Drizzle 1.0 stores each migration in its own folder with no journal file and checks for conflicting migrations across branches. Prisma Migrate adds a shadow database and drift detection.</p>
<h3>Does Better Auth work with Drizzle and Prisma?</h3>
<p>Yes, with both. Better Auth 1.7.10 lists drizzle-orm 0.45.2 or newer, including the 1.0 release candidates, and Prisma 5, 6 and 7 as supported peer dependencies. Prisma 8 is not in its supported range yet, so a Better Auth app on Prisma should stay on Prisma 7 for now.</p>
<p>Originally published at <a href="https://www.fabwaseem.com/blog/drizzle-vs-prisma">https://www.fabwaseem.com/blog/drizzle-vs-prisma</a></p>
]]></content:encoded></item><item><title><![CDATA[Hono vs Express vs Fastify (and NestJS) in 2026: Real Benchmarks on Node 24, Node 26 and Bun]]></title><description><![CDATA[TL;DR On Node 24, Hono (73,067 req/s) and Fastify (71,786) tie on JSON and are both about 1.4× faster than Express 5 (50,744). Fastify wins route…]]></description><link>https://fabwaseem.hashnode.dev/hono-vs-express-vs-fastify-and-nestjs-in-2026-real-benchmarks-on-node-24-node-26-and-bun</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/hono-vs-express-vs-fastify-and-nestjs-in-2026-real-benchmarks-on-node-24-node-26-and-bun</guid><category><![CDATA[Node.js]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[performance]]></category><category><![CDATA[hono]]></category><category><![CDATA[Express.js]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 04:41:51 GMT</pubDate><content:encoded><![CDATA[<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792267466/fabwaseem/blog/hono-vs-express/cover.jpg" alt="Hono vs Express vs Fastify cover: Node 24 JSON benchmark bars with Hono 73.1k, Fastify 71.8k, Express 50.7k, NestJS 56.4k and 38.9k requests per second" /></p>
<p><strong>TL;DR</strong></p>
<p>On Node 24, Hono (73,067 req/s) and Fastify (71,786) tie on JSON and are both about 1.4× faster than Express 5 (50,744). Fastify wins route parameters and POST bodies, Hono wins plain JSON. NestJS adds 21 to 23% overhead; on the Fastify adapter it's 1.45 to 1.66× faster than on Express. Node 26 adds 6 to 12%, and on Bun Hono reaches 118,432 req/s with 14 ms startup. Pick Hono for new or multi-runtime APIs, Fastify for Node services with schemas, and keep Express if it already works.</p>
<p><strong>Hono vs Express</strong> in 2026 is a closer race than the benchmark charts you've seen suggest. On Node.js 24, Hono served 1.44× as many JSON requests per second as Express 5 in my tests, and Fastify was right next to it. But Fastify beat Hono on route parameters and POST bodies, and NestJS added 21 to 23% overhead on top of whichever engine it ran on. The biggest jump didn't come from the framework at all: moving Hono from Node to Bun gave another 1.6×.</p>
<p>I benchmarked seven setups (raw <code>node:http</code>, Hono, Express, Fastify with and without response schemas, and NestJS 12 on both its Express and Fastify adapters) across three workloads, two Node versions and Bun. Here's every number, how I got it, and which framework I'd pick for which job.</p>
<h2>TL;DR: Hono vs Express vs Fastify, which is fastest?</h2>
<p>On Node, <strong>Hono and Fastify are tied for first</strong>, and both are about 1.4× faster than Express. Hono won the plain JSON test, Fastify won route parameters and POST bodies. Express 5 is the slowest of the three but still served 50,000 requests per second on two CPU cores, which is more than most APIs will ever see. On Bun, Hono pulls clearly ahead at 118,000 requests per second.</p>
<table>
<thead>
<tr>
<th>Node 24.22, 2 cores, requests/s (median)</th>
<th>JSON</th>
<th>Route param</th>
<th>POST JSON body</th>
<th>Startup to first response</th>
<th>Install size</th>
</tr>
</thead>
<tbody><tr>
<td><code>node:http</code> (no framework)</td>
<td>78,870</td>
<td>73,049</td>
<td>41,917</td>
<td>76 ms</td>
<td>none</td>
</tr>
<tr>
<td>Hono 4.13.13</td>
<td>73,067</td>
<td>70,128</td>
<td>36,553</td>
<td>94 ms</td>
<td>3.9 MB, 2 packages</td>
</tr>
<tr>
<td>Fastify 5.12.7</td>
<td>71,786</td>
<td>76,746</td>
<td>38,936</td>
<td>153 ms</td>
<td>14.4 MB, 48 packages</td>
</tr>
<tr>
<td>Express 5.3.0</td>
<td>50,744</td>
<td>48,335</td>
<td>28,569</td>
<td>116 ms</td>
<td>4.3 MB, 65 packages</td>
</tr>
<tr>
<td>NestJS 12.1.3 + Fastify</td>
<td>56,434</td>
<td>59,499</td>
<td>33,418</td>
<td>425 ms</td>
<td>n/a</td>
</tr>
<tr>
<td>NestJS 12.1.3 + Express</td>
<td>38,893</td>
<td>35,829</td>
<td>23,011</td>
<td>320 ms</td>
<td>22.4 MB, 98 packages</td>
</tr>
</tbody></table>
<p>My pick: <strong>Hono</strong> for new APIs, especially anything that might run on Bun, Cloudflare Workers or another edge runtime. <strong>Fastify</strong> for Node-only services where you want schemas, plugins and the best throughput on real routes. <strong>Express</strong> when the team and middleware already exist, because it's fast enough. <strong>NestJS</strong> when the codebase is big enough that structure matters more than 20% of throughput, and then put it on the Fastify adapter.</p>
<h2>What are Hono, Express and Fastify?</h2>
<p>All three are HTTP frameworks for JavaScript and TypeScript, and they come from different eras.</p>
<ul>
<li><strong><a href="https://expressjs.com/">Express</a></strong> is the 2010 original and still the default. Express 5 finally shipped native promise support in route handlers and dropped a lot of legacy API. It has the largest middleware ecosystem by far: 145 million npm downloads in the week of October 6 to 12, 2026.</li>
<li><strong><a href="https://fastify.dev/">Fastify</a></strong> was built for speed on Node. It compiles JSON schemas into fast serializers and validators, has a strict plugin system with encapsulation, and logs with Pino by default. 15.4 million downloads that week.</li>
<li><strong><a href="https://hono.dev/">Hono</a></strong> is built on web standards: handlers take a <code>Request</code>-like context and return a <code>Response</code>. The same app runs on Node, Bun, Deno, Cloudflare Workers, AWS Lambda and Vercel. It's tiny and has zero dependencies. 74 million downloads that week, partly because other tools bundle it.</li>
<li><strong><a href="https://nestjs.com/">NestJS</a></strong> isn't a fourth HTTP engine. It's an application framework with modules, dependency injection and decorators that runs on top of Express (the default) or Fastify. I covered the latest release in my <a href="https://www.fabwaseem.com/blog/nestjs-12">NestJS 12 upgrade guide</a>.</li>
</ul>
<p>The code looks similar for a simple route. Here are the three handlers I benchmarked:</p>
<pre><code class="language-js">// Hono
app.get('/users/:id', c =&gt; c.json({ id: c.req.param('id'), fields: c.req.query('fields') ?? null }))

// Express 5
app.get('/users/:id', (req, res) =&gt; res.json({ id: req.params.id, fields: req.query.fields ?? null }))

// Fastify 5
app.get('/users/:id', async req =&gt; ({ id: req.params.id, fields: req.query.fields ?? null }))
</code></pre>
<h2>How I benchmarked them</h2>
<p>Benchmarks of web frameworks are easy to get wrong, so here's exactly what I ran.</p>
<ol>
<li><strong>Machine.</strong> An 8 vCPU Intel Xeon VM with 15 GB of RAM. Each server was pinned to 2 cores with <code>taskset</code>. The load generator, <a href="https://github.com/hatoo/oha">oha</a> 1.16.0, ran on the other 6 cores with 64 connections.</li>
<li><strong>Runtimes.</strong> Node.js 24.22.0, Node.js 26.12.0 and Bun 1.4.3.</li>
<li><strong>Versions.</strong> Hono 4.13.13 with <code>@hono/node-server</code> 2.1.5, Express 5.3.0, Fastify 5.12.7, and NestJS 12.1.3 with <code>@nestjs/platform-express</code> and <code>@nestjs/platform-fastify</code>. These were the latest npm releases on October 17, 2026.</li>
<li><strong>Workloads.</strong> <code>GET /json</code> returns <code>{"message":"Hello, World!"}</code>. <code>GET /users/42?fields=name</code> reads a route parameter and a query string. <code>POST /echo</code> parses a 907-byte JSON body and returns a count.</li>
<li><strong>Fair settings.</strong> No logging. ETags off in Express and in NestJS on Express, and <code>x-powered-by</code> off in Express. Fastify ran twice, once without schemas and once with response schemas. Every app returns the same JSON.</li>
<li><strong>Method.</strong> Each run started a fresh server, warmed it up for 4 seconds, then measured for 10 seconds. I ran 3 rounds per setup and report the median. Four setups had one noisy round, so I re-ran those with 5 rounds. That's 32 setups and more than 65 million measured requests, with a 100% success rate.</li>
</ol>
<p>Absolute numbers depend on hardware. The ratios between frameworks are what transfer to your servers.</p>
<h2>Is Hono faster than Express?</h2>
<p>Yes. On Node 24, Hono handled <strong>1.44× Express's throughput</strong> on plain JSON (73,067 vs 50,744 requests per second), 1.45× on route parameters and 1.28× on POST bodies. Latency followed: p99 on the JSON test was 1.50 ms for Hono against 2.12 ms for Express.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792267466/fabwaseem/blog/hono-vs-express/diagram-http.jpg" alt="Hono vs Express vs Fastify vs NestJS throughput on Node 24, Node 26 and Bun, requests per second" /></p>
<p>Two details matter more than the headline:</p>
<ul>
<li><strong>Hono came within 8% of raw <code>node:http</code></strong> on the JSON test. There's very little left to optimize in the framework itself.</li>
<li><strong>Hono's lead shrinks on POST.</strong> On Node, Hono goes through <code>@hono/node-server</code>, which turns Node's request into a web-standard <code>Request</code> before your handler sees it. Parsing a body through that layer costs more than Fastify's native parser. On Bun, where <code>Request</code> is native, the same Hono app did 80,749 POST requests per second, 2.2× its Node result.</li>
</ul>
<p>Express 5 pays for its flexible middleware pipeline and the extra properties it adds to every request and response. It's still not slow. Fifty thousand requests per second on two cores is about 4 billion requests a day. Your database will give out long before Express does.</p>
<h2>Hono vs Fastify: which is faster?</h2>
<p>It depends on the route. On Node 24:</p>
<table>
<thead>
<tr>
<th>Requests/s, Node 24</th>
<th>Hono</th>
<th>Fastify</th>
<th>Fastify + response schema</th>
<th>Winner</th>
</tr>
</thead>
<tbody><tr>
<td>JSON</td>
<td>73,067</td>
<td>71,786</td>
<td>74,567</td>
<td>tie (within 4%)</td>
</tr>
<tr>
<td>Route param + query</td>
<td>70,128</td>
<td>76,746</td>
<td>75,526</td>
<td>Fastify, +9%</td>
</tr>
<tr>
<td>POST JSON body</td>
<td>36,553</td>
<td>38,936</td>
<td>39,045</td>
<td>Fastify, +7%</td>
</tr>
</tbody></table>
<p>Fastify's router is a radix tree (<a href="https://github.com/delvedor/find-my-way">find-my-way</a>), and it beat even my hand-written <code>node:http</code> router on the route parameter test (76,746 vs 73,049), because my raw version builds a <code>URL</code> object per request. Adding response schemas, which Fastify compiles into a serializer with <a href="https://github.com/fastify/fast-json-stringify">fast-json-stringify</a>, was worth only about 4% on these small responses. The bigger value of schemas is validation and documentation, not speed.</p>
<p>So on Node, call it a draw. Pick between them on design: web standards and portability (Hono), or schemas, plugins and Node depth (Fastify).</p>
<h2>Fastify vs Express: is it worth switching in 2026?</h2>
<p>On raw speed, Fastify was <strong>1.41× faster on JSON, 1.59× on route parameters and 1.36× on POST</strong>. Its p99 latency was 25 to 40% lower too.</p>
<p>Whether that justifies a rewrite depends on where your time goes. If a typical request spends 20 ms in Postgres and 0.3 ms in the framework, cutting the framework's share in half changes nothing a user can feel. Switch when profiling shows the framework near the top, when you want schema validation built in, or when you're starting fresh anyway. Don't switch because of a chart, including this one.</p>
<h2>How much overhead does NestJS add?</h2>
<p>NestJS adds a layer of decorators, guards, pipes and interceptors on top of the engine. In my tests that cost <strong>23% of Express's throughput</strong> (38,893 vs 50,744 on JSON) and <strong>21% of Fastify's</strong> (56,434 vs 71,786). It also used more memory: about 100 MB idle versus 65 MB for plain Express.</p>
<p>The fix is easy and well documented in the <a href="https://docs.nestjs.com/techniques/performance">NestJS performance guide</a>: <strong>NestJS on Fastify was 1.45× faster than NestJS on Express on JSON and POST, and 1.66× faster on route parameters</strong>. It was also faster than plain Express on every test. If you use NestJS and none of your middleware is Express-specific, the Fastify adapter is the cheapest performance win available.</p>
<h2>Do Node 26 and Bun change the ranking?</h2>
<p>Node 26 made every framework faster without code changes. On the JSON test, Express and Fastify gained about 12%, NestJS 10%, Hono 6% and raw <code>node:http</code> 7%.</p>
<table>
<thead>
<tr>
<th>JSON requests/s</th>
<th>Node 24.22</th>
<th>Node 26.12</th>
<th>Bun 1.4.3</th>
</tr>
</thead>
<tbody><tr>
<td>Hono</td>
<td>73,067</td>
<td>77,454</td>
<td><strong>118,432</strong></td>
</tr>
<tr>
<td>Fastify</td>
<td>71,786</td>
<td>80,220</td>
<td>86,269</td>
</tr>
<tr>
<td>Express</td>
<td>50,744</td>
<td>56,627</td>
<td>72,479</td>
</tr>
<tr>
<td>NestJS + Express</td>
<td>38,893</td>
<td>42,923</td>
<td>not tested</td>
</tr>
</tbody></table>
<p>On <strong>Bun</strong>, the ranking changes. Hono uses Bun's native server directly, so it ran 1.62× faster than on Node 24 and opened a 37% lead over Fastify. Express and Fastify also got faster on Bun (1.43× and 1.20×) but they run through Bun's <code>node:http</code> compatibility layer instead of calling <code>Bun.serve</code> directly. If you plan to run on Bun, Hono is the obvious choice. I compared the runtimes themselves in <a href="https://www.fabwaseem.com/blog/bun-vs-node">Bun vs Node.js in 2026</a>.</p>
<h2>Memory, startup time and install size</h2>
<p>Throughput isn't the only cost. Startup matters for serverless and autoscaling, and install size matters for container images and cold deploys.</p>
<p><img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792267467/fabwaseem/blog/hono-vs-express/diagram-foot.jpg" alt="Startup time, memory and install size for Hono, Express, Fastify and NestJS on Node and Bun" /></p>
<table>
<thead>
<tr>
<th>Node 24</th>
<th>Startup to first 200</th>
<th>Peak RSS at startup</th>
<th>RSS idle</th>
<th>Peak RSS under JSON load</th>
</tr>
</thead>
<tbody><tr>
<td><code>node:http</code></td>
<td>76 ms</td>
<td>62 MB</td>
<td>58 MB</td>
<td>97 MB</td>
</tr>
<tr>
<td>Hono</td>
<td>94 ms</td>
<td>67 MB</td>
<td>63 MB</td>
<td>104 MB</td>
</tr>
<tr>
<td>Express</td>
<td>116 ms</td>
<td>75 MB</td>
<td>65 MB</td>
<td>97 MB</td>
</tr>
<tr>
<td>Fastify</td>
<td>153 ms</td>
<td>78 MB</td>
<td>72 MB</td>
<td>105 MB</td>
</tr>
<tr>
<td>NestJS + Express</td>
<td>320 ms</td>
<td>100 MB</td>
<td>98 MB</td>
<td>143 MB</td>
</tr>
<tr>
<td>NestJS + Fastify</td>
<td>425 ms</td>
<td>109 MB</td>
<td>106 MB</td>
<td>138 MB</td>
</tr>
<tr>
<td>Hono on Bun</td>
<td>14 ms</td>
<td>24 MB</td>
<td>20 MB</td>
<td>43 MB</td>
</tr>
</tbody></table>
<p>Startup is the time from launching the process to the first successful response, measured with hyperfine over 20 runs. Hono on Bun was ready in 14 ms, which is why it's popular for serverless functions. NestJS needed about 2.8 times as long as the plain framework underneath it, because it scans modules and builds its dependency graph at boot.</p>
<p>Install size, measured in a fresh folder with npm: Hono plus its Node adapter is 2 packages and 3.9 MB. Express 5 is 65 packages but only 4.3 MB. Fastify is 48 packages and 14.4 MB. A minimal NestJS app on Express (core, common, platform-express, reflect-metadata, rxjs) is 98 packages and 22.4 MB. If you ship Docker images, my <a href="https://www.fabwaseem.com/blog/nextjs-dockerfile">Next.js Dockerfile guide</a> has the same multi-stage tricks that keep a Node API image small.</p>
<h2>Which Node.js framework should I use in 2026?</h2>
<table>
<thead>
<tr>
<th>If you need...</th>
<th>Pick</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>A new API with the fewest moving parts</td>
<td>Hono</td>
<td>Within 8% of raw Node, zero dependencies, web-standard code</td>
</tr>
<tr>
<td>To run on Bun, Workers, Deno or Lambda</td>
<td>Hono</td>
<td>Same app everywhere; 118k req/s on Bun</td>
</tr>
<tr>
<td>The best Node throughput on real routes</td>
<td>Fastify</td>
<td>Won route params and POST; schemas and plugins built in</td>
</tr>
<tr>
<td>An existing team and Express middleware</td>
<td>Express 5</td>
<td>50k req/s is plenty; huge ecosystem; easy hiring</td>
</tr>
<tr>
<td>Structure for a large team (modules, DI)</td>
<td>NestJS on Fastify</td>
<td>1.45 to 1.66× faster than NestJS on Express</td>
</tr>
<tr>
<td>End-to-end typed clients</td>
<td>Hono (RPC) or Fastify with TypeBox</td>
<td>Types flow from routes to clients</td>
</tr>
</tbody></table>
<p>Two more points rarely show up in benchmark posts:</p>
<ul>
<li><strong>Ecosystem.</strong> Express still has the most middleware, tutorials and Stack Overflow answers. Fastify's plugin ecosystem covers most production needs. Hono's middleware set is smaller but growing, and anything built on web standards tends to just work.</li>
<li><strong>Typed clients.</strong> Hono's RPC mode lets a frontend import the API's types directly. If your frontend is Next.js, also check whether you need a separate API at all; I covered that in <a href="https://www.fabwaseem.com/blog/server-actions-vs-route-handlers">Server Actions vs Route Handlers</a>.</li>
</ul>
<h2>How do I migrate from Express to Hono or Fastify?</h2>
<p>You don't need a big-bang rewrite. Both frameworks can sit next to Express while you move routes.</p>
<pre><code class="language-js">// Fastify: run existing Express middleware during a migration
import Fastify from 'fastify'
import expressPlugin from '@fastify/express'
const app = Fastify()
await app.register(expressPlugin)
app.use(legacyExpressMiddleware)

// Hono: same handler shape on every runtime
import { Hono } from 'hono'
const app = new Hono()
app.get('/health', c =&gt; c.json({ ok: true }))
export default app // Bun, Workers, Deno; use @hono/node-server on Node
</code></pre>
<p>A safe order: move health checks and read-only routes first, measure them in production, then move the routes that write data. Keep the request and response shapes identical so clients never notice. Watch for Express habits that don't carry over: mutating <code>req</code> from middleware, <code>res.locals</code>, and error handlers with four arguments.</p>
<h2>Common mistakes when benchmarking web frameworks</h2>
<ul>
<li><strong>Running the load generator on the server's cores.</strong> Pin them apart, or you measure contention.</li>
<li><strong>Benchmarking only <code>/hello</code>.</strong> Hono won plain JSON, Fastify won routes with parameters and bodies. One endpoint gives you one answer.</li>
<li><strong>Leaving logging on.</strong> Fastify with a logger and Express with <code>morgan</code> are both much slower. Compare like with like.</li>
<li><strong>Trusting a single run.</strong> Four of my 32 setups had one round 10 to 20% below the others. Take the median of several runs.</li>
<li><strong>Comparing frameworks on different runtimes.</strong> "Hono is 2× faster than Express" is often Hono on Bun versus Express on Node.</li>
<li><strong>Ignoring where time actually goes.</strong> Profile one real request. The framework is rarely the bottleneck.</li>
</ul>
<p>Related: if your API is meant for AI agents, read <a href="https://www.fabwaseem.com/blog/mcp-vs-api">MCP vs API</a> before choosing a framework, because the protocol choice changes more than the framework does.</p>
<h2>Key takeaways</h2>
<ul>
<li>On Node 24, Hono (73,067 req/s) and Fastify (71,786) are tied on JSON, and both are about 1.4× faster than Express 5 (50,744).</li>
<li>Fastify won route parameters (+9% over Hono) and POST bodies (+7%). Hono won plain JSON and came within 8% of raw node:http.</li>
<li>NestJS costs about 21 to 23% throughput and about 2.8× the startup time. On the Fastify adapter it's 1.45 to 1.66× faster than on Express.</li>
<li>Node 26 sped every framework up by 6 to 12% with no code changes.</li>
<li>On Bun, Hono jumped to 118,432 req/s, 1.62× its Node result, with 14 ms startup and 43 MB under load.</li>
<li>Hono installs as 2 packages (3.9 MB). Express is 65 packages (4.3 MB), Fastify 48 (14.4 MB), and NestJS 98 (22.4 MB).</li>
<li>Even Express handled 50,000 requests per second on 2 cores. Pick on design and ecosystem first; the framework is rarely your bottleneck.</li>
</ul>
<h2>Questions</h2>
<h3>Is Hono faster than Express?</h3>
<p>Yes. In my October 2026 benchmark on Node 24, Hono 4.13.13 served 73,067 JSON requests per second against 50,744 for Express 5.3.0, which is 1.44× faster. It was 1.45× faster on route parameters and 1.28× faster on POST bodies, with lower p99 latency.</p>
<h3>Is Hono faster than Fastify?</h3>
<p>On Node they're tied. Hono won plain JSON by 2%, but Fastify won route parameters by 9% and POST bodies by 7%. On Bun, Hono pulls ahead: 118,432 requests per second versus 86,269 for Fastify, because Hono uses Bun's native server directly.</p>
<h3>Is Fastify faster than Express in 2026?</h3>
<p>Yes. Fastify 5.12.7 was 1.41× faster than Express 5.3.0 on JSON, 1.59× on route parameters and 1.36× on POST bodies, with 25 to 40% lower p99 latency. Express still handled about 50,000 requests per second on two cores, which is enough for most APIs.</p>
<h3>How much slower is NestJS than Express or Fastify?</h3>
<p>NestJS 12.1.3 cost about 23% of Express's throughput and 21% of Fastify's on the JSON test, used about 100 MB of memory at idle, and took about 2.8× as long to start. Using the Fastify adapter made NestJS 1.45 to 1.66× faster than on Express, and faster than plain Express on every test.</p>
<h3>Should I switch from Express to Hono or Fastify?</h3>
<p>Only if you have a reason beyond a benchmark. Switch when profiling shows the framework is a real cost, when you want built-in schema validation (Fastify), or when you need to run on Bun, Cloudflare Workers or other runtimes (Hono). If most request time is spent in the database, a faster framework won't be noticeable.</p>
<h3>Which Node.js framework should I use in 2026?</h3>
<p>Hono for new APIs and anything that may run on Bun, Workers, Deno or Lambda. Fastify for Node-only services that want schemas, plugins and the best throughput on real routes. Express 5 for teams with existing middleware and experience. NestJS on the Fastify adapter for large codebases that need modules and dependency injection.</p>
<h3>Does Node 26 make Express, Fastify and Hono faster?</h3>
<p>Yes. With no code changes, Node 26.12 improved JSON throughput over Node 24.22 by about 12% for Express and Fastify, 10% for NestJS, 7% for raw node:http and 6% for Hono.</p>
<h3>How did you benchmark Hono, Express and Fastify?</h3>
<p>Each server ran on 2 pinned CPU cores of an 8 vCPU Xeon VM, with oha 1.16.0 on the other cores at 64 connections. Every run started a fresh server, warmed up for 4 seconds and measured for 10 seconds. I report the median of 3 rounds, or 5 rounds for setups with a noisy round, across 32 setups on Node 24.22, Node 26.12 and Bun 1.4.3.</p>
<p>Originally published at <a href="https://www.fabwaseem.com/blog/hono-vs-express">https://www.fabwaseem.com/blog/hono-vs-express</a></p>
]]></content:encoded></item><item><title><![CDATA[Bun vs Node.js in 2026: Bun 1.4 vs Node 24 and 26, Benchmarked]]></title><description><![CDATA[TL;DR Use bun install and bun run today: they were 13x (cold) to 29x (warm) faster than npm and change nothing at runtime. Use the Bun runtime for new or…]]></description><link>https://fabwaseem.hashnode.dev/bun-vs-node-js-in-2026-bun-1-4-vs-node-24-and-26-benchmarked</link><guid isPermaLink="true">https://fabwaseem.hashnode.dev/bun-vs-node-js-in-2026-bun-1-4-vs-node-24-and-26-benchmarked</guid><category><![CDATA[Node.js]]></category><category><![CDATA[Bun]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[performance]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Waseem Anjum]]></dc:creator><pubDate>Sun, 18 Oct 2026 04:20:14 GMT</pubDate><content:encoded><![CDATA[<img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792258957/fabwaseem/blog/bun-vs-node/cover.jpg" alt="Bun vs Node in 2026 cover: Hono JSON API requests per second, Node 24 53,408, Node 26 60,431, Bun 1.4 98,145, plus startup, install and memory numbers" style="display:block;margin:0 auto" />

<p><strong>TL;DR</strong></p>
<p>Use bun install and bun run today: they were 13x (cold) to 29x (warm) faster than npm and change nothing at runtime. Use the Bun runtime for new or dependency-light APIs, CLIs and serverless: Bun 1.4.3 served 98,145 req/s on a Hono JSON API vs 53,408 on Node 24 and 60,431 on Node 26, and 1.48x Node 24 with a Postgres query per request, using 25-70% less memory. Keep large existing services on Node 26, which was 8-16% faster than Node 24 on most tests.</p>
<p><strong>Bun vs Node</strong> in 2026 comes down to this: <strong>Bun</strong> is a JavaScript runtime, package manager, bundler and test runner in one binary, built on JavaScriptCore and now written in Rust. <strong>Node.js</strong> is the V8-based runtime almost every server, library and hosting platform targets. Bun 1.4 reports Node.js 26 compatibility, so for many apps switching is now a runtime swap rather than a rewrite.</p>
<p>I benchmarked Bun 1.4.3 against Node.js 24.22 (the current LTS) and Node.js 26.12 (LTS from October 28) on one machine. I measured HTTP throughput with Hono, Express and plain <code>node:http</code>, a real Postgres-backed API, a large in-memory heap, startup, TypeScript execution, <code>npm install</code> vs <code>bun install</code>, <code>next build</code>, and native addons like sharp and bcrypt. Every number below comes from those runs.</p>
<h2>TL;DR: should I switch from Node to Bun?</h2>
<p>Use <strong>Bun as your package manager and script runner today</strong>: <code>bun install</code> was 13 to 29 times faster than npm in my tests and changes nothing at runtime. Consider <strong>Bun as your production runtime</strong> for new or dependency-light APIs, CLIs and serverless functions, where it was 1.4 to 1.8 times faster and used 25-70% less memory under load. Stay on <strong>Node.js</strong> for large existing services that depend on Node-specific APM, long-running stability or exotic native addons, and move to Node 26, which was 8-16% faster than Node 24 on most of my HTTP tests.</p>
<table>
<thead>
<tr>
<th></th>
<th>Node.js 24.22</th>
<th>Node.js 26.12</th>
<th>Bun 1.4.3</th>
</tr>
</thead>
<tbody><tr>
<td>Engine</td>
<td>V8</td>
<td>V8</td>
<td>JavaScriptCore</td>
</tr>
<tr>
<td>Status (Oct 2026)</td>
<td>LTS, maintenance from Oct 20</td>
<td>Current, LTS from Oct 28</td>
<td>Stable, 1.x</td>
</tr>
<tr>
<td>Hono JSON API, req/s</td>
<td>53,408</td>
<td>60,431</td>
<td>98,145</td>
</tr>
<tr>
<td>Express 5 JSON, req/s</td>
<td>35,764</td>
<td>38,530</td>
<td>55,382</td>
</tr>
<tr>
<td>Hono + Postgres, req/s</td>
<td>21,749</td>
<td>21,008</td>
<td>32,254 (pg), 33,694 (Bun.sql)</td>
</tr>
<tr>
<td>RSS under load, Hono JSON</td>
<td>105 MB</td>
<td>105 MB</td>
<td>36 MB</td>
</tr>
<tr>
<td>Startup (<code>-e 0</code>)</td>
<td>28.5 ms</td>
<td>26.3 ms</td>
<td>3.3 ms</td>
</tr>
<tr>
<td>Run a <code>.ts</code> file</td>
<td>62.6 ms</td>
<td>59.1 ms</td>
<td>3.8 ms</td>
</tr>
<tr>
<td>Install a Next.js app (cold)</td>
<td>15.4 s (npm)</td>
<td></td>
<td>1.15 s (bun)</td>
</tr>
<tr>
<td>Native addons tested</td>
<td>All work</td>
<td>All work</td>
<td>All work (sharp, better-sqlite3, bcrypt, argon2)</td>
</tr>
</tbody></table>
<h2>What is Bun, and how is it different from Node.js?</h2>
<p>Bun is an all-in-one toolkit: a JavaScript and TypeScript runtime, an npm-compatible package manager, a bundler and a Jest-style test runner in one executable. Node.js is just the runtime and leans on npm, tsc or a loader, a bundler and a test framework from the ecosystem. Bun runs on Apple's JavaScriptCore engine; Node runs on Google's V8.</p>
<p>Bun aims to be a drop-in replacement. It implements Node's built-in modules (<code>node:http</code>, <code>node:fs</code>, <code>node:crypto</code> and the rest), reads <code>package.json</code> and <code>node_modules</code> the same way, and runs N-API native addons. It also adds its own faster APIs such as <code>Bun.serve</code>, <code>Bun.sql</code> and <code>bun:sqlite</code>.</p>
<h2>What's new in Bun 1.4?</h2>
<p>Bun 1.4 shipped on August 20, 2026, and it is the first Bun written in Rust instead of Zig. Per the <a href="https://bun.com/blog/bun-v1.4">Bun 1.4 release post</a>, it adds "+1,517 tests from the Node.js test suite", fixes "over 2,900 issues", cuts idle CPU by 5x and memory by up to 35%, and "starts 50% faster on Linux." Bun now reports itself as Node.js 26 (<code>process.versions.modules</code> is 147).</p>
<p>It also absorbs common dependencies into the runtime: <code>Bun.Image</code> (image processing), <code>Bun.WebView</code> (headless browser), <code>Bun.markdown</code>, <code>Bun.cron()</code>, <code>Bun.Terminal</code>, plus <code>bun run --parallel</code> and <code>bun test --parallel</code>. Upgrading from 1.3 isn't free: the <a href="https://github.com/oven-sh/bun/issues/28792">breaking changes list</a> includes stricter TLS verification, YAML 1.2 parsing and no <code>.env</code> auto-loading when Bun is invoked as <code>node</code>. Vercel made 1.4 an explicit opt-in on its platform for the same reason.</p>
<h2>How I benchmarked Bun vs Node.js</h2>
<ol>
<li><p><strong>Machine.</strong> An 8 vCPU Intel Xeon VM with 15 GB RAM. Each server was pinned to 2 cores with <code>taskset</code>. The load generator, <a href="https://github.com/hatoo/oha">oha</a> 1.16.0, ran on the other 6 cores with 64 connections.</p>
</li>
<li><p><strong>Runtimes.</strong> Node.js 24.22.0, Node.js 26.12.0 and Bun 1.4.3, all official Linux x64 builds.</p>
</li>
<li><p><strong>Apps.</strong> The same files ran on every runtime: a <code>node:http</code> hello server, a <code>Bun.serve</code> hello server (Bun only), a Hono 4.13 app, an Express 5.3 app, and a Hono route that reads one row from PostgreSQL 18 through <code>pg</code> 8.23 (or <code>Bun.sql</code> on Bun) with a 10-connection pool, across 10,000 users at random IDs.</p>
</li>
<li><p><strong>Method.</strong> For every configuration: start the server, warm up for 4 seconds, measure for 10 seconds, read RSS from <code>/proc</code>. Each configuration ran 3 times and I report the median. Every request returned 200.</p>
</li>
<li><p><strong>CLI timings</strong> used <a href="https://github.com/sharkdp/hyperfine">hyperfine</a> 2.0.0 (40 runs for startup, 3 to 5 for installs and builds).</p>
</li>
</ol>
<p>Postgres ran on the same machine without pinning, so the database test also measures a shared CPU. That's closer to a small VPS than to a tuned cluster.</p>
<h2>Is Bun faster than Node.js?</h2>
<p>For HTTP servers, yes, clearly. On the Hono JSON route Bun served 98,145 requests per second against 53,408 on Node 24 and 60,431 on Node 26: 1.84 times Node 24 and 1.62 times Node 26. Bun's advantage held on Express (1.55x Node 24) and on the plain <code>node:http</code> module (1.42x), and <code>Bun.serve</code> reached 128,461 requests per second.</p>
<img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792258958/fabwaseem/blog/bun-vs-node/diagram-http.jpg" alt="Bun vs Node HTTP benchmark: requests per second for node:http, Hono JSON, Express and Hono with Postgres on Node 24.22, Node 26.12 and Bun 1.4.3; Bun leads every workload, 98,145 vs 53,408 req/s on Hono JSON" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Server (median of 3 x 10 s, 64 connections)</th>
<th>Node 24.22</th>
<th>Node 26.12</th>
<th>Bun 1.4.3</th>
<th>Bun vs Node 24</th>
</tr>
</thead>
<tbody><tr>
<td><code>node:http</code> hello</td>
<td>74,200 req/s</td>
<td>83,301 req/s</td>
<td>105,434 req/s</td>
<td>1.42x</td>
</tr>
<tr>
<td><code>Bun.serve</code> hello</td>
<td></td>
<td></td>
<td>128,461 req/s</td>
<td>1.73x</td>
</tr>
<tr>
<td>Hono, 10-item JSON</td>
<td>53,408 req/s</td>
<td>60,431 req/s</td>
<td>98,145 req/s</td>
<td>1.84x</td>
</tr>
<tr>
<td>Express 5, same JSON</td>
<td>35,764 req/s</td>
<td>38,530 req/s</td>
<td>55,382 req/s</td>
<td>1.55x</td>
</tr>
<tr>
<td>Hono + Postgres (<code>pg</code>)</td>
<td>21,749 req/s</td>
<td>21,008 req/s</td>
<td>32,254 req/s</td>
<td>1.48x</td>
</tr>
<tr>
<td>Hono + Postgres (<code>Bun.sql</code>)</td>
<td></td>
<td></td>
<td>33,694 req/s</td>
<td>1.55x</td>
</tr>
<tr>
<td>600k-entry in-memory cache</td>
<td>53,314 req/s</td>
<td>61,908 req/s</td>
<td>68,134 req/s</td>
<td>1.28x</td>
</tr>
</tbody></table>
<p>Latency followed throughput. On the Hono JSON route p99 was 2.10 ms on Node 24, 1.95 ms on Node 26 and 1.17 ms on Bun. On the Postgres route p99 was 4.75 ms, 6.95 ms and 3.98 ms, and 3.50 ms with <code>Bun.sql</code>.</p>
<p>Two results surprised me. First, Bun stayed about 1.5 times faster even with a real database query in every request, which is where many "it's all the database anyway" arguments end. My database was local and fast, though. Over a real network, round trips will shrink that gap. Second, Node 26 alone was 8-16% faster than Node 24 on four of the five HTTP tests (the Postgres route was a tie), so upgrading Node is the cheapest speedup available.</p>
<h2>Does Bun use less memory than Node.js?</h2>
<p>Yes. Idle, a <code>node:http</code> server used 58 MB RSS on Node 24 and 23 MB on Bun, while <code>Bun.serve</code> used 16 MB. Under load the Hono app held 105 MB on both Node versions and 36 MB on Bun. On the Postgres route Node 24 grew to 206 MB against 61 MB for Bun with <code>pg</code> and 56 MB with <code>Bun.sql</code>.</p>
<table>
<thead>
<tr>
<th>RSS (MB): idle / after 10 s load / peak</th>
<th>Node 24.22</th>
<th>Node 26.12</th>
<th>Bun 1.4.3</th>
</tr>
</thead>
<tbody><tr>
<td><code>node:http</code> hello</td>
<td>58 / 95 / 95</td>
<td>60 / 97 / 97</td>
<td>23 / 55 / 74</td>
</tr>
<tr>
<td>Hono JSON</td>
<td>70 / 105 / 105</td>
<td>66 / 105 / 105</td>
<td>28 / 36 / 45</td>
</tr>
<tr>
<td>Express JSON</td>
<td>65 / 97 / 97</td>
<td>66 / 96 / 96</td>
<td>40 / 72 / 81</td>
</tr>
<tr>
<td>Hono + Postgres</td>
<td>70 / 206 / 206</td>
<td>66 / 151 / 151</td>
<td>28 / 61 / 69</td>
</tr>
<tr>
<td>600k-entry cache</td>
<td>411 / 455 / 455</td>
<td>370 / 410 / 410</td>
<td>261 / 300 / 491</td>
</tr>
</tbody></table>
<p>The cache test is the exception worth reading. Bun started with less memory but briefly peaked higher (491 MB) than Node (455 MB). Bun's garbage collector is a known weak spot under large, long-lived heaps. A Bun pull request from this year described a GC "storm" that made allocation-heavy handlers run "at roughly half of Node.js" with a 150 MB live set. That <a href="https://github.com/oven-sh/bun/pull/35431">PR #35431</a> was closed without merging. In my 1.4.3 run with a bigger heap Bun was still 28% faster than Node 24, but test your own workload, not a hello world.</p>
<h2>How fast does Bun start compared to Node.js?</h2>
<p>Bun starts about 8 times faster. An empty script took a median 3.3 ms on Bun against 28.5 ms on Node 24 and 26.3 ms on Node 26. Running a TypeScript file took 3.8 ms on Bun against 62.6 ms on Node 24. Node's built-in type stripping uses a WebAssembly-based parser, and here it added about 34 ms over an empty script. <code>bun run</code> launched a <code>package.json</code> script in 3.1 ms against 95.1 ms for <code>npm run</code>.</p>
<table>
<thead>
<tr>
<th>Command (hyperfine median)</th>
<th>Node 24.22</th>
<th>Node 26.12</th>
<th>Bun 1.4.3</th>
</tr>
</thead>
<tbody><tr>
<td>Empty script (<code>-e 0</code>)</td>
<td>28.5 ms</td>
<td>26.3 ms</td>
<td>3.3 ms</td>
</tr>
<tr>
<td><code>hello.ts</code> (type annotations)</td>
<td>62.6 ms</td>
<td>59.1 ms</td>
<td>3.8 ms</td>
</tr>
<tr>
<td><code>import('express')</code></td>
<td>94.1 ms</td>
<td></td>
<td>65.3 ms</td>
</tr>
<tr>
<td>Run a no-op <code>package.json</code> script</td>
<td>95.1 ms (<code>npm run</code>)</td>
<td></td>
<td>3.1 ms (<code>bun run</code>)</td>
</tr>
</tbody></table>
<p>Startup matters for CLIs, cron jobs, serverless cold starts and test watchers. It barely matters for a long-running API server. Both runtimes run TypeScript directly now: <a href="https://nodejs.org/api/typescript.html">Node's type stripping</a> has been stable since v24.12.0 but only removes erasable syntax and ignores <code>tsconfig.json</code>, while Bun transpiles full TypeScript, including enums.</p>
<h2>Is bun install faster than npm?</h2>
<p>Much faster. On a Next.js 16.4.2 app with Tailwind, ESLint and TypeScript (458 packages), a cold install with empty caches and no lockfile took 15.4 seconds with npm and 1.15 seconds with Bun, 13 times faster. A CI-style install from a lockfile with a warm cache took 4.6 seconds with <code>npm ci</code> and 0.16 seconds with <code>bun install --frozen-lockfile</code>, 29 times faster.</p>
<table>
<thead>
<tr>
<th>Install, Next.js 16.4.2 app (458 packages)</th>
<th>npm 11.19</th>
<th>bun 1.4.3</th>
<th>Speedup</th>
</tr>
</thead>
<tbody><tr>
<td>Cold: no cache, no lockfile (median of 3)</td>
<td>15.4 s</td>
<td>1.15 s</td>
<td>13x</td>
</tr>
<tr>
<td>Warm cache + lockfile (median of 5)</td>
<td>4.6 s</td>
<td>0.16 s</td>
<td>29x</td>
</tr>
<tr>
<td>Peak memory, warm install</td>
<td>758 MB</td>
<td>13.5 MB</td>
<td></td>
</tr>
</tbody></table>
<p>This is the safest win in the whole comparison. <code>bun install</code> writes a normal <code>node_modules</code>, so you can keep running the app on Node. The cold number includes downloads over my server's network, so yours will vary.</p>
<p>bash</p>
<pre><code class="language-plaintext"># use Bun only as the package manager and script runner
bun install            # writes bun.lock and a normal node_modules
bun run build          # runs your package.json script with Node, as before
node server.js         # production stays on Node.js
</code></pre>
<h2>Does Next.js work with Bun?</h2>
<p>Yes. A Next.js 16.4.2 production build ran fine with <code>bun --bun next build</code>, in a median 3.03 seconds against 3.22 seconds on Node 24, about 6% faster. Turbopack does the heavy lifting in Rust either way, so the runtime matters less for builds than for serving requests. Without <code>--bun</code>, <code>bun run build</code> launches the Next.js CLI with Node because of its shebang.</p>
<p>bash</p>
<pre><code class="language-plaintext">bun install
bun --bun next build   # build with the Bun runtime
bun --bun next start   # serve with the Bun runtime
</code></pre>
<p>If you deploy with Docker, the base image changes from <code>node:24-slim</code> to <code>oven/bun</code>, and the rest of a <a href="https://www.fabwaseem.com/blog/nextjs-dockerfile">production Next.js Dockerfile</a> stays the same.</p>
<h2>Do native modules work in Bun?</h2>
<p>The common ones do. On Bun 1.4.3, sharp (image resize), better-sqlite3, bcrypt, argon2 and <code>pg</code> all installed from npm and passed the same smoke test as on Node 24. Bun runs N-API addons, which covers most popular native packages. Packages that pick a prebuilt binary by <code>NODE_MODULE_VERSION</code> need a build for 147, the ABI Bun 1.4 reports.</p>
<p>What still bites people is less about compatibility tables and more about operations: APM agents that patch Node internals, rare Node APIs, and memory behavior under months of traffic. The <a href="https://bun.com/docs/runtime/nodejs-compat">Bun Node.js compatibility page</a> lists module-by-module status.</p>
<h2>Is Bun faster at raw JavaScript?</h2>
<p>Sometimes. CPU-bound micro-benchmarks were mixed, so don't expect a blanket speedup. Bun was about 3 times faster at parsing small JSON and 1.4 times faster than Node 26 on a 67 KB document, but <code>JSON.stringify</code> tied Node 26. Node 26 made a big jump over Node 24 on JSON, 1.8 times faster on stringify.</p>
<table>
<thead>
<tr>
<th>Single core, ops/s (mean of 2 runs)</th>
<th>Node 24.22</th>
<th>Node 26.12</th>
<th>Bun 1.4.3</th>
</tr>
</thead>
<tbody><tr>
<td><code>JSON.parse</code>, 110 B</td>
<td>1,711,027</td>
<td>1,881,418</td>
<td>5,226,338</td>
</tr>
<tr>
<td><code>JSON.parse</code>, 67 KB</td>
<td>3,854</td>
<td>5,224</td>
<td>7,118</td>
</tr>
<tr>
<td><code>JSON.stringify</code>, 67 KB</td>
<td>6,539</td>
<td>11,634</td>
<td>11,644</td>
</tr>
<tr>
<td>SHA-256 of 1 KB (<code>node:crypto</code>)</td>
<td>751,142</td>
<td>751,912</td>
<td>853,054</td>
</tr>
<tr>
<td>Sort 100,000 numbers</td>
<td>55</td>
<td>60</td>
<td>90</td>
</tr>
<tr>
<td><code>Map</code> set + get, 100,000 keys</td>
<td>102</td>
<td>134</td>
<td>168</td>
</tr>
</tbody></table>
<p>I dropped one test: a regex replace on a constant string ran at 196 million operations per second on Bun, which means JavaScriptCore optimized the work away. That's the trouble with micro-benchmarks. Trust the HTTP and real-app numbers more than these.</p>
<h2>Should I use Bun in production in 2026?</h2>
<p>For new services, it's a reasonable default now. Bun 1.4 is fast, frugal and broadly compatible, and platforms such as <a href="https://vercel.com/changelog/bun-1-4-is-now-available-in-vercel-functions">Vercel Functions</a> run it. For existing Node services, migrate only when you can measure the benefit and you have tested memory over days, not minutes. Trigger.dev reported a <a href="https://trigger.dev/blog/firebun">5x throughput gain moving a service to Bun</a> and also hit a Bun-only memory leak on the way.</p>
<table>
<thead>
<tr>
<th>Situation</th>
<th>My pick</th>
</tr>
</thead>
<tbody><tr>
<td>New API, CLI or worker with mainstream dependencies</td>
<td>Bun (runtime + package manager)</td>
</tr>
<tr>
<td>Serverless or cron jobs where cold start dominates</td>
<td>Bun</td>
</tr>
<tr>
<td>Existing large Node service with APM and on-call</td>
<td>Node 26 + <code>bun install</code> in CI</td>
</tr>
<tr>
<td>Heavy use of exotic native addons or Node internals</td>
<td>Node.js</td>
</tr>
<tr>
<td>NestJS or Next.js app</td>
<td>Node in production for now, Bun for installs and scripts; test Bun in staging</td>
</tr>
</tbody></table>
<img src="https://res.cloudinary.com/wasicloudinary/image/upload/f_auto,q_auto,c_limit,w_1600/v1792258960/fabwaseem/blog/bun-vs-node/diagram-decision.jpg" alt="Bun or Node decision guide: use bun install everywhere, Bun runtime for new APIs, CLIs and serverless, Node 26 for large existing services with APM and native addons" style="display:block;margin:0 auto" />

<h2>How do I migrate a Node.js app to Bun?</h2>
<p>Move in three steps and keep a way back at each one. First swap the package manager, then run your tests on Bun, then switch the production runtime behind a canary.</p>
<p>bash</p>
<pre><code class="language-plaintext"># 1. package manager only (zero runtime risk)
bun install                 # creates bun.lock next to package-lock.json

# 2. run tests and scripts on Bun
bun run test                # your existing test script
bun --bun run test          # force the Bun runtime even for "node" shebangs

# 3. run the server on Bun, keep the code portable
bun src/server.ts
</code></pre>
<p>ts</p>
<pre><code class="language-plaintext">// portable entry point: Bun.serve on Bun, @hono/node-server on Node
import { Hono } from 'hono'

const app = new Hono()
app.get('/health', (c) =&gt; c.json({ ok: true }))

if (typeof Bun !== 'undefined') {
  Bun.serve({ port: 3000, fetch: app.fetch })
} else {
  const { serve } = await import('@hono/node-server')
  serve({ fetch: app.fetch, port: 3000 })
}
</code></pre>
<p>Frameworks built on Web standards, like Hono, make this painless because the same <code>fetch</code> handler runs on both. For database access, <code>pg</code> worked unchanged on Bun in my test, and <code>Bun.sql</code> is the built-in Postgres client that was 4% faster than <code>pg</code> on Bun in my test. If you're choosing an ORM, see <a href="https://www.fabwaseem.com/blog/drizzle-vs-prisma">Drizzle vs Prisma</a>. If you self-host, <a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify">Coolify</a> runs a Bun Dockerfile like any other.</p>
<h2>Common mistakes when comparing Bun and Node.js</h2>
<ul>
<li><p><strong>Benchmarking hello world only.</strong> <code>Bun.serve</code> vs <code>node:http</code> showed a 1.73x gap; Express showed 1.55x and a large-heap handler 1.28x.</p>
</li>
<li><p><strong>Comparing against an old Node.</strong> Node 26 was 8-16% faster than Node 24 on four of five HTTP tests. Compare against the Node you would actually run.</p>
</li>
<li><p><strong>Ignoring memory over time.</strong> A 10-second run says nothing about a slow leak. Watch RSS for days in staging.</p>
</li>
<li><p><strong>Running the load generator on the same cores.</strong> Pin the server and the load generator to separate CPUs, or the numbers mostly measure contention.</p>
</li>
<li><p><strong>Forgetting</strong> <code>--bun</code><strong>.</strong> <code>bun run</code> executes scripts whose CLI has a <code>#!/usr/bin/env node</code> shebang with Node unless you pass <code>--bun</code>.</p>
</li>
<li><p><strong>Switching runtime and dependencies at once.</strong> Swap the package manager first; it gives most of the day-to-day win with none of the runtime risk.</p>
</li>
</ul>
<p><strong>Related:</strong> I also ran a NestJS 12 app on Bun 1.4.3: it served 1.49 times Node 24's requests per second on a hello route and started in 264 ms vs 361 ms. See the <a href="https://www.fabwaseem.com/blog/nestjs-12">NestJS 12 migration guide</a>.</p>
<p><strong>Related:</strong> framework choice matters on Bun too. In <a href="https://www.fabwaseem.com/blog/hono-vs-express">Hono vs Express vs Fastify</a>, Hono served 118,432 JSON requests per second on Bun 1.4.3, 1.62× its Node 24 result, while Express and Fastify gained less.</p>
<h2>Key takeaways</h2>
<ul>
<li><p>Bun 1.4.3 served 1.84 times Node 24's requests per second on a Hono JSON API, and 1.48 times with a Postgres query in every request.</p>
</li>
<li><p>Bun used 25-70% less memory under load, for example 36 MB vs 105 MB RSS for the Hono app.</p>
</li>
<li><p>Bun started in 3.3 ms versus 26-29 ms for Node and ran a TypeScript file in 3.8 ms versus about 60 ms.</p>
</li>
<li><p><code>bun install</code> was 13 times faster than npm cold and 29 times faster with a warm cache and lockfile.</p>
</li>
<li><p>Node 26 alone was 8-16% faster than Node 24 on four of five HTTP tests, a free upgrade when it becomes LTS on October 28.</p>
</li>
<li><p>sharp, better-sqlite3, bcrypt, argon2, pg and a Next.js 16.4 build all worked on Bun 1.4.3.</p>
</li>
<li><p>Adopt Bun as package manager first, then as runtime for new services; keep large existing Node services on Node 26 until you've tested memory over days.</p>
</li>
</ul>
<h2>Questions</h2>
<h3>Is Bun faster than Node.js?</h3>
<p>For HTTP servers, yes. In my tests Bun 1.4.3 served 98,145 requests per second on a Hono JSON API against 53,408 on Node.js 24 and 60,431 on Node.js 26. With a Postgres query in every request Bun was 1.48 times faster than Node 24. CPU-bound micro-benchmarks were mixed, so the gap depends on the workload.</p>
<h3>Does Bun use less memory than Node.js?</h3>
<p>Usually. Under load my Hono app used 36 MB RSS on Bun versus 105 MB on Node 24 and 26, and the Postgres route used 61 MB versus 206 MB on Node 24. With a large in-memory cache Bun idled lower but briefly peaked higher, at 491 MB against 455 MB, so test memory with your own data.</p>
<h3>Is Bun production ready in 2026?</h3>
<p>For many services, yes. Bun 1.4 passes 1,517 more Node.js test-suite tests than 1.3, runs common native addons, and is offered on platforms such as Vercel Functions. Teams still report Bun-specific memory issues under long-running load, so canary it and watch memory for days before moving a large existing service.</p>
<h3>Is bun install faster than npm?</h3>
<p>Yes. On a Next.js 16.4.2 app with 458 packages, a cold install took 15.4 seconds with npm and 1.15 seconds with Bun. A lockfile install with a warm cache took 4.6 seconds with npm ci and 0.16 seconds with bun install --frozen-lockfile. It writes a normal node modules, so the app can still run on Node.</p>
<h3>Does Next.js work with Bun?</h3>
<p>Yes. A Next.js 16.4.2 production build succeeded with bun --bun next build in 3.03 seconds against 3.22 seconds on Node 24. Use the --bun flag, because bun run otherwise launches the Next.js CLI with Node due to its node shebang. Turbopack compiles in Rust either way, so builds gain little.</p>
<h3>Do native modules like sharp and bcrypt work in Bun?</h3>
<p>The popular ones do. On Bun 1.4.3, sharp, better-sqlite3, bcrypt, argon2 and pg installed from npm and passed the same smoke test as on Node 24. Bun runs N-API addons and reports NODE MODULE VERSION 147, so packages that select a prebuilt binary by ABI need a build for 147.</p>
<h3>Should I upgrade to Node 26 or switch to Bun?</h3>
<p>If you have a large existing service, upgrade to Node 26 first: it was 8 to 16 percent faster than Node 24 on most of my HTTP tests and becomes LTS on October 28, 2026. Switch the runtime to Bun when you start a new service or can measure a clear gain in staging.</p>
<h3>What changed in Bun 1.4?</h3>
<p>Bun 1.4, released August 20, 2026, is the first Bun written in Rust instead of Zig. It reports Node.js 26 compatibility, adds 1,517 Node.js test-suite tests, fixes over 2,900 issues, starts 50 percent faster on Linux, and adds built-ins such as Bun.Image, Bun.WebView, Bun.markdown and Bun.cron. It also has breaking changes worth reviewing.</p>
<p>Originally published at <a href="https://www.fabwaseem.com/blog/bun-vs-node">https://www.fabwaseem.com/blog/bun-vs-node</a></p>
]]></content:encoded></item></channel></rss>