[{"data":1,"prerenderedAt":835},["ShallowReactive",2],{"navigation":3,"/blog/nuxt-websockets-vs-vercel":42,"/blog/nuxt-websockets-vs-vercel-surround":830},[4],{"title":5,"path":6,"stem":7,"children":8,"page":41},"Blog","/blog","blog",[9,13,17,21,25,29,33,37],{"title":10,"path":11,"stem":12},"ESLint Without Prettier: One Tool for Formatting and Linting in TypeScript","/blog/eslint-best-practice","blog/eslint-best-practice",{"title":14,"path":15,"stem":16},"Why Your Startup Should Go Headless CMS from Day One","/blog/headless-cms-startups","blog/headless-cms-startups",{"title":18,"path":19,"stem":20},"How I Built a Token-Efficient Crawler for 900+ Shops with Crawl4AI","/blog/how-i-built-a-token-efficient-crawler","blog/how-i-built-a-token-efficient-crawler",{"title":22,"path":23,"stem":24},"Migrating a Live B2B Platform to AWS Without Stopping the Business","/blog/migrating-live-b2b-platform-aws","blog/migrating-live-b2b-platform-aws",{"title":26,"path":27,"stem":28},"Nuxt 4 WebSockets Are Elegant. Here's Why They Break on Vercel.","/blog/nuxt-websockets-vs-vercel","blog/nuxt-websockets-vs-vercel",{"title":30,"path":31,"stem":32},"Host Your Docker Container on Railway in 5 Minutes (8GB RAM for $5)","/blog/railway-smart-docker-host","blog/railway-smart-docker-host",{"title":34,"path":35,"stem":36},"Supabase vs Custom Auth: What I Recommend for Early-Stage SaaS","/blog/supabase-vs-custom-auth","blog/supabase-vs-custom-auth",{"title":38,"path":39,"stem":40},"How to Migrate WordPress to Strapi Headless CMS (With 1,000+ Posts)","/blog/wordpress-to-strapi-migration","blog/wordpress-to-strapi-migration",false,{"id":43,"title":26,"author":44,"body":48,"date":822,"description":823,"extension":824,"image":825,"meta":826,"minRead":267,"navigation":216,"path":27,"seo":827,"sitemap":828,"stem":28,"__hash__":829},"blog/blog/nuxt-websockets-vs-vercel.md",{"name":45,"avatar":46},"Niklas Grieger",{"src":47,"alt":45},"/profile.jpg",{"type":49,"value":50,"toc":813},"minimark",[51,68,71,76,83,150,270,273,325,331,392,395,399,417,424,428,435,523,545,568,572,575,679,685,689,692,695,714,725,728,746,750,757,766,769,773,809],[52,53,54,55,62,63,67],"p",{},"I needed realtime notifications on ",[56,57,61],"a",{"href":58,"rel":59},"https://gospendl.com",[60],"nofollow","gospendl.com",", a Bitcoin price comparison site built with Nuxt 4 and deployed on Vercel. A backend crawl job collects products from shops, uploads them to Algolia, and once that's done, any user currently sitting on ",[64,65,66],"code",{},"/search"," should immediately see a \"N new results for X found\" banner — no refresh needed.",[52,69,70],{},"Nuxt's WebSocket API looked like exactly the right tool. It turned out to be missing its Vercel adapter — the problem isn't that Vercel can't do WebSockets, it's that Nitro's Vercel preset doesn't implement the upgrade handshake yet.",[72,73,75],"h2",{"id":74},"what-nuxt-4-offers-and-how-clean-it-is","What Nuxt 4 Offers, and How Clean It Is",[52,77,78,79,82],{},"Nuxt 4 / Nitro 2.13 has native WebSocket support via ",[64,80,81],{},"crossws",". The setup is genuinely minimal — no separate WebSocket server, no extra dependency, no client SDK bindings.",[84,85,90],"pre",{"className":86,"code":87,"language":88,"meta":89,"style":89},"language-ts shiki shiki-themes github-light github-dark","// nuxt.config.ts\nexport default defineNuxtConfig({\n  nitro: {\n    experimental: { websocket: true },\n  },\n})\n","ts","",[64,91,92,101,119,125,138,144],{"__ignoreMap":89},[93,94,97],"span",{"class":95,"line":96},"line",1,[93,98,100],{"class":99},"sJ8bj","// nuxt.config.ts\n",[93,102,104,108,111,115],{"class":95,"line":103},2,[93,105,107],{"class":106},"szBVR","export",[93,109,110],{"class":106}," default",[93,112,114],{"class":113},"sScJk"," defineNuxtConfig",[93,116,118],{"class":117},"sVt8B","({\n",[93,120,122],{"class":95,"line":121},3,[93,123,124],{"class":117},"  nitro: {\n",[93,126,128,131,135],{"class":95,"line":127},4,[93,129,130],{"class":117},"    experimental: { websocket: ",[93,132,134],{"class":133},"sj4cs","true",[93,136,137],{"class":117}," },\n",[93,139,141],{"class":95,"line":140},5,[93,142,143],{"class":117},"  },\n",[93,145,147],{"class":95,"line":146},6,[93,148,149],{"class":117},"})\n",[84,151,153],{"className":86,"code":152,"language":88,"meta":89,"style":89},"// server/routes/ws.ts\nexport const peers = new Set\u003C{ send: (m: string) => void }>()\n\nexport default defineWebSocketHandler({\n  open(peer) { peers.add(peer) },\n  close(peer) { peers.delete(peer) },\n})\n",[64,154,155,160,212,218,229,249,265],{"__ignoreMap":89},[93,156,157],{"class":95,"line":96},[93,158,159],{"class":99},"// server/routes/ws.ts\n",[93,161,162,164,167,170,173,176,179,182,185,188,191,195,197,200,203,206,209],{"class":95,"line":103},[93,163,107],{"class":106},[93,165,166],{"class":106}," const",[93,168,169],{"class":133}," peers",[93,171,172],{"class":106}," =",[93,174,175],{"class":106}," new",[93,177,178],{"class":113}," Set",[93,180,181],{"class":117},"\u003C{ ",[93,183,184],{"class":113},"send",[93,186,187],{"class":106},":",[93,189,190],{"class":117}," (",[93,192,194],{"class":193},"s4XuR","m",[93,196,187],{"class":106},[93,198,199],{"class":133}," string",[93,201,202],{"class":117},") ",[93,204,205],{"class":106},"=>",[93,207,208],{"class":133}," void",[93,210,211],{"class":117}," }>()\n",[93,213,214],{"class":95,"line":121},[93,215,217],{"emptyLinePlaceholder":216},true,"\n",[93,219,220,222,224,227],{"class":95,"line":127},[93,221,107],{"class":106},[93,223,110],{"class":106},[93,225,226],{"class":113}," defineWebSocketHandler",[93,228,118],{"class":117},[93,230,231,234,237,240,243,246],{"class":95,"line":140},[93,232,233],{"class":113},"  open",[93,235,236],{"class":117},"(",[93,238,239],{"class":193},"peer",[93,241,242],{"class":117},") { peers.",[93,244,245],{"class":113},"add",[93,247,248],{"class":117},"(peer) },\n",[93,250,251,254,256,258,260,263],{"class":95,"line":146},[93,252,253],{"class":113},"  close",[93,255,236],{"class":117},[93,257,239],{"class":193},[93,259,242],{"class":117},[93,261,262],{"class":113},"delete",[93,264,248],{"class":117},[93,266,268],{"class":95,"line":267},7,[93,269,149],{"class":117},[52,271,272],{},"Broadcasting from anywhere in the backend is just:",[84,274,276],{"className":86,"code":275,"language":88,"meta":89,"style":89},"peers.forEach(p => p.send(JSON.stringify({ source: 'crawl.newData', meta: {...} })))\n",[64,277,278],{"__ignoreMap":89},[93,279,280,283,286,288,290,293,296,298,300,303,306,309,312,316,319,322],{"class":95,"line":96},[93,281,282],{"class":117},"peers.",[93,284,285],{"class":113},"forEach",[93,287,236],{"class":117},[93,289,52],{"class":193},[93,291,292],{"class":106}," =>",[93,294,295],{"class":117}," p.",[93,297,184],{"class":113},[93,299,236],{"class":117},[93,301,302],{"class":133},"JSON",[93,304,305],{"class":117},".",[93,307,308],{"class":113},"stringify",[93,310,311],{"class":117},"({ source: ",[93,313,315],{"class":314},"sZZnC","'crawl.newData'",[93,317,318],{"class":117},", meta: {",[93,320,321],{"class":106},"...",[93,323,324],{"class":117},"} })))\n",[52,326,327,328,187],{},"And on the frontend, with ",[64,329,330],{},"@vueuse/core",[84,332,334],{"className":86,"code":333,"language":88,"meta":89,"style":89},"const { open } = useWebSocket('/ws', { immediate: false, onMessage(...) })\nonMounted(() => open())\n",[64,335,336,376],{"__ignoreMap":89},[93,337,338,341,344,347,350,353,356,358,361,364,367,370,373],{"class":95,"line":96},[93,339,340],{"class":106},"const",[93,342,343],{"class":117}," { ",[93,345,346],{"class":133},"open",[93,348,349],{"class":117}," } ",[93,351,352],{"class":106},"=",[93,354,355],{"class":113}," useWebSocket",[93,357,236],{"class":117},[93,359,360],{"class":314},"'/ws'",[93,362,363],{"class":117},", { immediate: ",[93,365,366],{"class":133},"false",[93,368,369],{"class":117},", ",[93,371,372],{"class":113},"onMessage",[93,374,375],{"class":117},"(...) })\n",[93,377,378,381,384,386,389],{"class":95,"line":103},[93,379,380],{"class":113},"onMounted",[93,382,383],{"class":117},"(() ",[93,385,205],{"class":106},[93,387,388],{"class":113}," open",[93,390,391],{"class":117},"())\n",[52,393,394],{},"Compared to a typical Socket.IO setup — rooms, namespaces, adapters, Redis pub/sub configuration — this is radically simpler. One handler file, one module-level variable, and it worked locally on the first try.",[72,396,398],{"id":397},"the-break-on-deploy","The Break on Deploy",[52,400,401,404,405,408,409,412,413,416],{},[64,402,403],{},"pnpm dev"," ran perfectly. On Vercel, ",[64,406,407],{},"wss://gospendl.com/ws"," sent the ",[64,410,411],{},"Upgrade: websocket"," header, but ",[64,414,415],{},"101 Switching Protocols"," never came back. Chrome DevTools showed \"Provisional headers are shown\" with an empty response section. Backend Slack notifications for the crawl job kept arriving fine — the WebSocket broadcast just never reached any user.",[52,418,419,420,423],{},"My first guess was a cross-instance state problem: the in-memory ",[64,421,422],{},"peers"," Set only exists inside a single serverless instance, so if a user's WebSocket connection landed on instance A and the broadcast fired from instance B, the message would never reach them. That's a real problem for serverless WebSockets in general — but it wasn't what was happening here. The connection wasn't even being established in the first place.",[72,425,427],{"id":426},"root-cause","Root Cause",[52,429,430,431,434],{},"The Nitro Vercel preset runtime handler, in ",[64,432,433],{},"nitropack"," 2.13.1, is a plain HTTP Node listener:",[84,436,440],{"className":437,"code":438,"language":439,"meta":89,"style":89},"language-js shiki shiki-themes github-light github-dark","// node_modules/nitropack/dist/presets/vercel/runtime/vercel.mjs\nimport { toNodeListener } from \"h3\";\nconst handler = toNodeListener(nitroApp.h3App);\nconst listener = function(req, res) { ... return handler(req, res); };\nexport default listener;\n","js",[64,441,442,447,464,479,514],{"__ignoreMap":89},[93,443,444],{"class":95,"line":96},[93,445,446],{"class":99},"// node_modules/nitropack/dist/presets/vercel/runtime/vercel.mjs\n",[93,448,449,452,455,458,461],{"class":95,"line":103},[93,450,451],{"class":106},"import",[93,453,454],{"class":117}," { toNodeListener } ",[93,456,457],{"class":106},"from",[93,459,460],{"class":314}," \"h3\"",[93,462,463],{"class":117},";\n",[93,465,466,468,471,473,476],{"class":95,"line":121},[93,467,340],{"class":106},[93,469,470],{"class":133}," handler",[93,472,172],{"class":106},[93,474,475],{"class":113}," toNodeListener",[93,477,478],{"class":117},"(nitroApp.h3App);\n",[93,480,481,483,486,488,491,493,496,498,501,504,506,509,511],{"class":95,"line":127},[93,482,340],{"class":106},[93,484,485],{"class":113}," listener",[93,487,172],{"class":106},[93,489,490],{"class":106}," function",[93,492,236],{"class":117},[93,494,495],{"class":193},"req",[93,497,369],{"class":117},[93,499,500],{"class":193},"res",[93,502,503],{"class":117},") { ",[93,505,321],{"class":106},[93,507,508],{"class":106}," return",[93,510,470],{"class":113},[93,512,513],{"class":117},"(req, res); };\n",[93,515,516,518,520],{"class":95,"line":140},[93,517,107],{"class":106},[93,519,110],{"class":106},[93,521,522],{"class":117}," listener;\n",[52,524,525,526,529,530,533,534,537,538,541,542,544],{},"When Vercel Functions forwards the upgrade request, ",[64,527,528],{},"h3","'s ",[64,531,532],{},"toNodeListener"," never returns a ",[64,535,536],{},"101"," response — there's simply no code in the preset that handles WebSocket upgrades. A ",[64,539,540],{},"grep -rE \"websocket|upgrade\" presets/vercel/"," in ",[64,543,433],{}," comes back empty.",[52,546,547,548,551,552,554,555,557,558,560,561,564,565,567],{},"Setting ",[64,549,550],{},"experimental.websocket: true"," in Nitro activates ",[64,553,81],{}," — but ",[64,556,81],{}," doesn't have a Vercel adapter wired into the ",[64,559,433],{}," 2.x Vercel preset. Nitro 3 (currently ",[64,562,563],{},"3.0.260610-beta",") does have WebSocket support on Vercel per their docs — but Nuxt 4 is built on ",[64,566,433],{}," 2.x, not 3.",[72,569,571],{"id":570},"the-contrast-with-nextjs","The Contrast With Next.js",[52,573,574],{},"Vercel does support WebSockets in general, on Fluid Compute, since June 2026. For Next.js, there's an explicit escape hatch:",[84,576,578],{"className":86,"code":577,"language":88,"meta":89,"style":89},"// app/api/ws/route.ts\nimport { experimental_upgradeWebSocket } from '@vercel/functions'\n\nexport async function GET() {\n  return experimental_upgradeWebSocket((ws) => {\n    ws.on('message', (data) => ws.send(data))\n  })\n}\n",[64,579,580,585,597,601,616,637,668,673],{"__ignoreMap":89},[93,581,582],{"class":95,"line":96},[93,583,584],{"class":99},"// app/api/ws/route.ts\n",[93,586,587,589,592,594],{"class":95,"line":103},[93,588,451],{"class":106},[93,590,591],{"class":117}," { experimental_upgradeWebSocket } ",[93,593,457],{"class":106},[93,595,596],{"class":314}," '@vercel/functions'\n",[93,598,599],{"class":95,"line":121},[93,600,217],{"emptyLinePlaceholder":216},[93,602,603,605,608,610,613],{"class":95,"line":127},[93,604,107],{"class":106},[93,606,607],{"class":106}," async",[93,609,490],{"class":106},[93,611,612],{"class":113}," GET",[93,614,615],{"class":117},"() {\n",[93,617,618,621,624,627,630,632,634],{"class":95,"line":140},[93,619,620],{"class":106},"  return",[93,622,623],{"class":113}," experimental_upgradeWebSocket",[93,625,626],{"class":117},"((",[93,628,629],{"class":193},"ws",[93,631,202],{"class":117},[93,633,205],{"class":106},[93,635,636],{"class":117}," {\n",[93,638,639,642,645,647,650,653,656,658,660,663,665],{"class":95,"line":146},[93,640,641],{"class":117},"    ws.",[93,643,644],{"class":113},"on",[93,646,236],{"class":117},[93,648,649],{"class":314},"'message'",[93,651,652],{"class":117},", (",[93,654,655],{"class":193},"data",[93,657,202],{"class":117},[93,659,205],{"class":106},[93,661,662],{"class":117}," ws.",[93,664,184],{"class":113},[93,666,667],{"class":117},"(data))\n",[93,669,670],{"class":95,"line":267},[93,671,672],{"class":117},"  })\n",[93,674,676],{"class":95,"line":675},8,[93,677,678],{"class":117},"}\n",[52,680,681,684],{},[64,682,683],{},"experimental_upgradeWebSocket()"," is a Vercel runtime API that performs the upgrade at the function level. Nitro has no equivalent — the Vercel preset would need to integrate that API, and in v2 it doesn't. Vercel's own docs state that \"Nitro's native WebSocket support works on Vercel,\" which is true, but only once a project is running Nitro 3.",[72,686,688],{"id":687},"what-i-did-instead","What I Did Instead",[52,690,691],{},"I migrated to Server-Sent Events backed by Upstash Redis pub/sub.",[693,694],"sse-redis-architecture-diagram",{},[52,696,697,698,701,702,705,706,709,710,713],{},"The SSE endpoint (",[64,699,700],{},"server/api/events.get.ts",") gives every connecting client its own ",[64,703,704],{},"ioredis"," subscriber, subscribed to ",[64,707,708],{},"crawl:events:${VERCEL_ENV}"," — environment-scoped so dev, preview, and prod stay isolated from each other. The crawl webhook publishes via the ",[64,711,712],{},"@upstash/redis"," REST client, which works well here since it's a short-lived request rather than something needing a persistent TCP connection.",[52,715,716,717,720,721,724],{},"On the frontend, the swap was a single line: ",[64,718,719],{},"useWebSocket"," became ",[64,722,723],{},"useEventSource",", with the message shape unchanged.",[52,726,727],{},"Redis is the piece that actually solves the cross-instance problem I originally suspected: a publish from instance A and a subscribe on instance B both go through Redis, so it doesn't matter which serverless instance happens to be handling which request.",[52,729,730,731,734,735,738,739,742,743,745],{},"One small gotcha in the SSE setup: ",[64,732,733],{},"res.writeHead()"," plus ",[64,736,737],{},"res.flushHeaders()"," alone weren't enough to actually push the response to the client. An initial ",[64,740,741],{},"res.write(': connected\\n\\n')"," byte was necessary to get the response to flush at all — a quirk of how ",[64,744,528],{}," 1.15 behaves in Nitro's dev-mode request handling.",[72,747,749],{"id":748},"the-takeaway","The Takeaway",[52,751,752,753,756],{},"Nuxt/Nitro's WebSocket API is the nicest I've seen in a full-stack framework. One ",[64,754,755],{},"defineWebSocketHandler"," file, one config flag, done — no setup compromise compared to Socket.IO.",[52,758,759,760,762,763,305],{},"But it's a framework-native abstraction that needs a host-specific adapter underneath it. The Nuxt Vercel adapter, tied to the ",[64,761,433],{}," 2.x preset, doesn't implement the upgrade handshake — this isn't Vercel lacking WebSocket support, it's Nitro's Vercel preset not having caught up yet. Next.js ships an explicit escape hatch for exactly this case via ",[64,764,765],{},"@vercel/functions",[52,767,768],{},"On Vercel today, that means Nuxt 4 WebSockets work locally and nowhere else — not because Vercel can't do it, but because the adapter that would make it work doesn't exist yet. For production realtime, the options are Server-Sent Events plus a pub/sub layer like Redis, or deploying somewhere that supports a raw, long-lived Node server — Railway, Fly, Render.",[72,770,772],{"id":771},"sources","Sources",[774,775,776,785,796,804],"ul",{},[777,778,779,784],"li",{},[56,780,783],{"href":781,"rel":782},"https://vercel.com/docs/functions/websockets",[60],"Vercel WebSockets docs"," — states Nitro's native WebSocket support works on Vercel, but links to a Nitro WebSockets starter template built on Nitro 3, not Nuxt 4",[777,786,787,792,793],{},[56,788,791],{"href":789,"rel":790},"https://nitro.build/guide/websocket",[60],"Nitro WebSocket guide"," — Nitro 3 docs, shows ",[64,794,795],{},"features.websocket: true",[777,797,798,803],{},[56,799,802],{"href":800,"rel":801},"https://vercel.com/docs/frameworks/nitro",[60],"Vercel's Nitro docs"," — mentions Fluid Compute as the default, no WebSocket specifics",[777,805,806,808],{},[64,807,433],{}," Vercel preset source (v2.13.1) — no WebSocket handling code present",[810,811,812],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":89,"searchDepth":103,"depth":103,"links":814},[815,816,817,818,819,820,821],{"id":74,"depth":103,"text":75},{"id":397,"depth":103,"text":398},{"id":426,"depth":103,"text":427},{"id":570,"depth":103,"text":571},{"id":687,"depth":103,"text":688},{"id":748,"depth":103,"text":749},{"id":771,"depth":103,"text":772},"2026-07-13T00:00:00.000Z","A first-hand debugging story — Nuxt 4's native WebSocket API is the cleanest I've seen in a full-stack framework, but the Nitro Vercel preset never implements the upgrade handshake. Vercel supports WebSockets fine; Nuxt's adapter for it just doesn't exist yet.","md","/blog/nuxt-websockets-vercel-hero.png",{},{"title":26,"description":823},{"loc":27},"5DFflUlGb7_ODbOydqAR3VCbArxx5awtzjasz_OY3y8",[831,833],{"title":22,"path":23,"stem":24,"description":832,"children":-1},"A four-year migration moved a veterinary industry webshop off a legacy monolith onto an orchestrated AWS architecture, service by service, without ever pausing the live business.",{"title":30,"path":31,"stem":32,"description":834,"children":-1},"I needed 4GB+ RAM to self-host Crawl4AI for GoSpendl. Here's why Railway beat every alternative I looked at, and how I had it running in under 5 minutes with zero config.",1784038599008]