[{"data":1,"prerenderedAt":1691},["ShallowReactive",2],{"writing-streaming-responses-vue":3},{"id":4,"title":5,"body":6,"category":1681,"date":1682,"description":1683,"extension":1684,"i18nKey":561,"meta":1685,"navigation":153,"path":1686,"readingTime":1687,"seo":1688,"stem":1689,"__hash__":1690},"writing\u002Fwriting\u002Fstreaming-responses-vue.md","Designing streaming responses in Vue",{"type":7,"value":8,"toc":1673},"minimark",[9,18,21,24,29,32,466,469,473,484,1300,1303,1324,1328,1331,1339,1342,1345,1351,1498,1501,1511,1525,1529,1536,1539,1601,1608,1612,1654,1658,1661,1669],[10,11,12,13,17],"p",{},"Streaming is the single highest-leverage UX decision in an AI product. The same model, the same latency, the same answer — but if the first token shows up in 400ms instead of the full answer showing up in 9 seconds, the product ",[14,15,16],"em",{},"feels"," an order of magnitude faster.",[10,19,20],{},"It's also where most chat UIs quietly fall apart: flickering markdown, layouts that jump, a scroll position that fights the user, and tool calls that render as raw JSON. None of these are model problems. They're frontend problems, and they're all fixable.",[10,22,23],{},"This is the setup I use in Nuxt 4 for Claude-powered chat features — from the server route to the last CSS detail.",[25,26,28],"h2",{"id":27},"the-transport-a-nitro-route-that-re-streams","The transport: a Nitro route that re-streams",[10,30,31],{},"Never call the model API from the browser. The key stays on the server, and a Nitro route re-streams the response to the client:",[33,34,39],"pre",{"className":35,"code":36,"language":37,"meta":38,"style":38},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u002F\u002F server\u002Fapi\u002Fchat.post.ts\nexport default defineEventHandler(async (event) => {\n  const { messages } = await readBody(event)\n  const { apiKey } = useRuntimeConfig().anthropic\n\n  const upstream = await fetch('https:\u002F\u002Fapi.anthropic.com\u002Fv1\u002Fmessages', {\n    method: 'POST',\n    headers: {\n      'x-api-key': apiKey,\n      'anthropic-version': '2023-06-01',\n      'content-type': 'application\u002Fjson'\n    },\n    body: JSON.stringify({\n      model: 'claude-sonnet-5',\n      max_tokens: 1024,\n      stream: true,\n      messages\n    })\n  })\n\n  setHeader(event, 'content-type', 'text\u002Fevent-stream')\n  setHeader(event, 'cache-control', 'no-cache')\n  return sendStream(event, upstream.body!)\n})\n","ts","",[40,41,42,51,90,122,148,155,186,206,216,233,254,274,280,301,318,332,346,352,360,368,373,402,431,458],"code",{"__ignoreMap":38},[43,44,47],"span",{"class":45,"line":46},"line",1,[43,48,50],{"class":49},"sHwdD","\u002F\u002F server\u002Fapi\u002Fchat.post.ts\n",[43,52,54,58,61,65,69,73,77,81,84,87],{"class":45,"line":53},2,[43,55,57],{"class":56},"s7zQu","export",[43,59,60],{"class":56}," default",[43,62,64],{"class":63},"s2Zo4"," defineEventHandler",[43,66,68],{"class":67},"sTEyZ","(",[43,70,72],{"class":71},"spNyl","async",[43,74,76],{"class":75},"sMK4o"," (",[43,78,80],{"class":79},"sHdIc","event",[43,82,83],{"class":75},")",[43,85,86],{"class":71}," =>",[43,88,89],{"class":75}," {\n",[43,91,93,96,99,102,105,108,111,114,117,119],{"class":45,"line":92},3,[43,94,95],{"class":71},"  const",[43,97,98],{"class":75}," {",[43,100,101],{"class":67}," messages",[43,103,104],{"class":75}," }",[43,106,107],{"class":75}," =",[43,109,110],{"class":56}," await",[43,112,113],{"class":63}," readBody",[43,115,68],{"class":116},"swJcz",[43,118,80],{"class":67},[43,120,121],{"class":116},")\n",[43,123,125,127,129,132,134,136,139,142,145],{"class":45,"line":124},4,[43,126,95],{"class":71},[43,128,98],{"class":75},[43,130,131],{"class":67}," apiKey",[43,133,104],{"class":75},[43,135,107],{"class":75},[43,137,138],{"class":63}," useRuntimeConfig",[43,140,141],{"class":116},"()",[43,143,144],{"class":75},".",[43,146,147],{"class":67},"anthropic\n",[43,149,151],{"class":45,"line":150},5,[43,152,154],{"emptyLinePlaceholder":153},true,"\n",[43,156,158,160,163,165,167,170,172,175,179,181,184],{"class":45,"line":157},6,[43,159,95],{"class":71},[43,161,162],{"class":67}," upstream",[43,164,107],{"class":75},[43,166,110],{"class":56},[43,168,169],{"class":63}," fetch",[43,171,68],{"class":116},[43,173,174],{"class":75},"'",[43,176,178],{"class":177},"sfazB","https:\u002F\u002Fapi.anthropic.com\u002Fv1\u002Fmessages",[43,180,174],{"class":75},[43,182,183],{"class":75},",",[43,185,89],{"class":75},[43,187,189,192,195,198,201,203],{"class":45,"line":188},7,[43,190,191],{"class":116},"    method",[43,193,194],{"class":75},":",[43,196,197],{"class":75}," '",[43,199,200],{"class":177},"POST",[43,202,174],{"class":75},[43,204,205],{"class":75},",\n",[43,207,209,212,214],{"class":45,"line":208},8,[43,210,211],{"class":116},"    headers",[43,213,194],{"class":75},[43,215,89],{"class":75},[43,217,219,222,225,227,229,231],{"class":45,"line":218},9,[43,220,221],{"class":75},"      '",[43,223,224],{"class":116},"x-api-key",[43,226,174],{"class":75},[43,228,194],{"class":75},[43,230,131],{"class":67},[43,232,205],{"class":75},[43,234,236,238,241,243,245,247,250,252],{"class":45,"line":235},10,[43,237,221],{"class":75},[43,239,240],{"class":116},"anthropic-version",[43,242,174],{"class":75},[43,244,194],{"class":75},[43,246,197],{"class":75},[43,248,249],{"class":177},"2023-06-01",[43,251,174],{"class":75},[43,253,205],{"class":75},[43,255,257,259,262,264,266,268,271],{"class":45,"line":256},11,[43,258,221],{"class":75},[43,260,261],{"class":116},"content-type",[43,263,174],{"class":75},[43,265,194],{"class":75},[43,267,197],{"class":75},[43,269,270],{"class":177},"application\u002Fjson",[43,272,273],{"class":75},"'\n",[43,275,277],{"class":45,"line":276},12,[43,278,279],{"class":75},"    },\n",[43,281,283,286,288,291,293,296,298],{"class":45,"line":282},13,[43,284,285],{"class":116},"    body",[43,287,194],{"class":75},[43,289,290],{"class":67}," JSON",[43,292,144],{"class":75},[43,294,295],{"class":63},"stringify",[43,297,68],{"class":116},[43,299,300],{"class":75},"{\n",[43,302,304,307,309,311,314,316],{"class":45,"line":303},14,[43,305,306],{"class":116},"      model",[43,308,194],{"class":75},[43,310,197],{"class":75},[43,312,313],{"class":177},"claude-sonnet-5",[43,315,174],{"class":75},[43,317,205],{"class":75},[43,319,321,324,326,330],{"class":45,"line":320},15,[43,322,323],{"class":116},"      max_tokens",[43,325,194],{"class":75},[43,327,329],{"class":328},"sbssI"," 1024",[43,331,205],{"class":75},[43,333,335,338,340,344],{"class":45,"line":334},16,[43,336,337],{"class":116},"      stream",[43,339,194],{"class":75},[43,341,343],{"class":342},"sfNiH"," true",[43,345,205],{"class":75},[43,347,349],{"class":45,"line":348},17,[43,350,351],{"class":67},"      messages\n",[43,353,355,358],{"class":45,"line":354},18,[43,356,357],{"class":75},"    }",[43,359,121],{"class":116},[43,361,363,366],{"class":45,"line":362},19,[43,364,365],{"class":75},"  }",[43,367,121],{"class":116},[43,369,371],{"class":45,"line":370},20,[43,372,154],{"emptyLinePlaceholder":153},[43,374,376,379,381,383,385,387,389,391,393,395,398,400],{"class":45,"line":375},21,[43,377,378],{"class":63},"  setHeader",[43,380,68],{"class":116},[43,382,80],{"class":67},[43,384,183],{"class":75},[43,386,197],{"class":75},[43,388,261],{"class":177},[43,390,174],{"class":75},[43,392,183],{"class":75},[43,394,197],{"class":75},[43,396,397],{"class":177},"text\u002Fevent-stream",[43,399,174],{"class":75},[43,401,121],{"class":116},[43,403,405,407,409,411,413,415,418,420,422,424,427,429],{"class":45,"line":404},22,[43,406,378],{"class":63},[43,408,68],{"class":116},[43,410,80],{"class":67},[43,412,183],{"class":75},[43,414,197],{"class":75},[43,416,417],{"class":177},"cache-control",[43,419,174],{"class":75},[43,421,183],{"class":75},[43,423,197],{"class":75},[43,425,426],{"class":177},"no-cache",[43,428,174],{"class":75},[43,430,121],{"class":116},[43,432,434,437,440,442,444,446,448,450,453,456],{"class":45,"line":433},23,[43,435,436],{"class":56},"  return",[43,438,439],{"class":63}," sendStream",[43,441,68],{"class":116},[43,443,80],{"class":67},[43,445,183],{"class":75},[43,447,162],{"class":67},[43,449,144],{"class":75},[43,451,452],{"class":67},"body",[43,454,455],{"class":75},"!",[43,457,121],{"class":116},[43,459,461,464],{"class":45,"line":460},24,[43,462,463],{"class":75},"}",[43,465,121],{"class":67},[10,467,468],{},"That's it on the server. The interesting work is on the client.",[25,470,472],{"id":471},"parsing-the-stream-without-tears","Parsing the stream without tears",[10,474,475,476,479,480,483],{},"Server-sent events arrive as text chunks, and a chunk boundary can land ",[14,477,478],{},"in the middle of a line",". If you split each chunk on ",[40,481,482],{},"\\n"," independently, you'll eventually parse half a JSON object and crash mid-answer. The fix is a persistent buffer: append every chunk, only consume complete lines.",[33,485,487],{"className":35,"code":486,"language":37,"meta":38,"style":38},"\u002F\u002F app\u002Fcomposables\u002FuseChatStream.ts\nexport const useChatStream = () => {\n  const text = ref('')\n  const status = ref\u003C'idle' | 'streaming' | 'done' | 'error'>('idle')\n  let controller: AbortController | undefined\n\n  const send = async (messages: ChatMessage[]) => {\n    controller = new AbortController()\n    status.value = 'streaming'\n    text.value = ''\n\n    const res = await fetch('\u002Fapi\u002Fchat', {\n      method: 'POST',\n      body: JSON.stringify({ messages }),\n      signal: controller.signal\n    })\n\n    const reader = res.body!.getReader()\n    const decoder = new TextDecoder()\n    let buffer = ''\n\n    while (true) {\n      const { done, value } = await reader.read()\n      if (done) break\n\n      buffer += decoder.decode(value, { stream: true })\n      const lines = buffer.split('\\n')\n      buffer = lines.pop() ?? '' \u002F\u002F keep the incomplete tail for the next chunk\n\n      for (const line of lines) {\n        if (!line.startsWith('data: ')) continue\n        const event = JSON.parse(line.slice(6))\n        if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {\n          text.value += event.delta.text\n        }\n      }\n    }\n    status.value = 'done'\n  }\n\n  const stop = () => {\n    controller?.abort()\n    status.value = 'done'\n  }\n\n  return { text, status, send, stop }\n}\n",[40,488,489,494,514,533,595,614,618,649,664,682,696,700,727,742,768,782,788,792,815,831,843,847,862,892,906,911,946,973,999,1004,1027,1059,1094,1145,1168,1174,1180,1186,1203,1209,1214,1230,1243,1260,1265,1270,1294],{"__ignoreMap":38},[43,490,491],{"class":45,"line":46},[43,492,493],{"class":49},"\u002F\u002F app\u002Fcomposables\u002FuseChatStream.ts\n",[43,495,496,498,501,504,507,510,512],{"class":45,"line":53},[43,497,57],{"class":56},[43,499,500],{"class":71}," const",[43,502,503],{"class":67}," useChatStream ",[43,505,506],{"class":75},"=",[43,508,509],{"class":75}," ()",[43,511,86],{"class":71},[43,513,89],{"class":75},[43,515,516,518,521,523,526,528,531],{"class":45,"line":92},[43,517,95],{"class":71},[43,519,520],{"class":67}," text",[43,522,107],{"class":75},[43,524,525],{"class":63}," ref",[43,527,68],{"class":116},[43,529,530],{"class":75},"''",[43,532,121],{"class":116},[43,534,535,537,540,542,544,547,549,552,554,557,559,562,564,566,568,571,573,575,577,580,582,585,587,589,591,593],{"class":45,"line":124},[43,536,95],{"class":71},[43,538,539],{"class":67}," status",[43,541,107],{"class":75},[43,543,525],{"class":63},[43,545,546],{"class":75},"\u003C",[43,548,174],{"class":75},[43,550,551],{"class":177},"idle",[43,553,174],{"class":75},[43,555,556],{"class":75}," |",[43,558,197],{"class":75},[43,560,561],{"class":177},"streaming",[43,563,174],{"class":75},[43,565,556],{"class":75},[43,567,197],{"class":75},[43,569,570],{"class":177},"done",[43,572,174],{"class":75},[43,574,556],{"class":75},[43,576,197],{"class":75},[43,578,579],{"class":177},"error",[43,581,174],{"class":75},[43,583,584],{"class":75},">",[43,586,68],{"class":116},[43,588,174],{"class":75},[43,590,551],{"class":177},[43,592,174],{"class":75},[43,594,121],{"class":116},[43,596,597,600,603,605,609,611],{"class":45,"line":150},[43,598,599],{"class":71},"  let",[43,601,602],{"class":67}," controller",[43,604,194],{"class":75},[43,606,608],{"class":607},"sBMFI"," AbortController",[43,610,556],{"class":75},[43,612,613],{"class":607}," undefined\n",[43,615,616],{"class":45,"line":157},[43,617,154],{"emptyLinePlaceholder":153},[43,619,620,622,625,627,630,632,635,637,640,643,645,647],{"class":45,"line":188},[43,621,95],{"class":71},[43,623,624],{"class":67}," send",[43,626,107],{"class":75},[43,628,629],{"class":71}," async",[43,631,76],{"class":75},[43,633,634],{"class":79},"messages",[43,636,194],{"class":75},[43,638,639],{"class":607}," ChatMessage",[43,641,642],{"class":116},"[]",[43,644,83],{"class":75},[43,646,86],{"class":71},[43,648,89],{"class":75},[43,650,651,654,656,659,661],{"class":45,"line":208},[43,652,653],{"class":67},"    controller",[43,655,107],{"class":75},[43,657,658],{"class":75}," new",[43,660,608],{"class":63},[43,662,663],{"class":116},"()\n",[43,665,666,669,671,674,676,678,680],{"class":45,"line":218},[43,667,668],{"class":67},"    status",[43,670,144],{"class":75},[43,672,673],{"class":67},"value",[43,675,107],{"class":75},[43,677,197],{"class":75},[43,679,561],{"class":177},[43,681,273],{"class":75},[43,683,684,687,689,691,693],{"class":45,"line":235},[43,685,686],{"class":67},"    text",[43,688,144],{"class":75},[43,690,673],{"class":67},[43,692,107],{"class":75},[43,694,695],{"class":75}," ''\n",[43,697,698],{"class":45,"line":256},[43,699,154],{"emptyLinePlaceholder":153},[43,701,702,705,708,710,712,714,716,718,721,723,725],{"class":45,"line":276},[43,703,704],{"class":71},"    const",[43,706,707],{"class":67}," res",[43,709,107],{"class":75},[43,711,110],{"class":56},[43,713,169],{"class":63},[43,715,68],{"class":116},[43,717,174],{"class":75},[43,719,720],{"class":177},"\u002Fapi\u002Fchat",[43,722,174],{"class":75},[43,724,183],{"class":75},[43,726,89],{"class":75},[43,728,729,732,734,736,738,740],{"class":45,"line":282},[43,730,731],{"class":116},"      method",[43,733,194],{"class":75},[43,735,197],{"class":75},[43,737,200],{"class":177},[43,739,174],{"class":75},[43,741,205],{"class":75},[43,743,744,747,749,751,753,755,757,760,762,764,766],{"class":45,"line":303},[43,745,746],{"class":116},"      body",[43,748,194],{"class":75},[43,750,290],{"class":67},[43,752,144],{"class":75},[43,754,295],{"class":63},[43,756,68],{"class":116},[43,758,759],{"class":75},"{",[43,761,101],{"class":67},[43,763,104],{"class":75},[43,765,83],{"class":116},[43,767,205],{"class":75},[43,769,770,773,775,777,779],{"class":45,"line":320},[43,771,772],{"class":116},"      signal",[43,774,194],{"class":75},[43,776,602],{"class":67},[43,778,144],{"class":75},[43,780,781],{"class":67},"signal\n",[43,783,784,786],{"class":45,"line":334},[43,785,357],{"class":75},[43,787,121],{"class":116},[43,789,790],{"class":45,"line":348},[43,791,154],{"emptyLinePlaceholder":153},[43,793,794,796,799,801,803,805,807,810,813],{"class":45,"line":354},[43,795,704],{"class":71},[43,797,798],{"class":67}," reader",[43,800,107],{"class":75},[43,802,707],{"class":67},[43,804,144],{"class":75},[43,806,452],{"class":67},[43,808,809],{"class":75},"!.",[43,811,812],{"class":63},"getReader",[43,814,663],{"class":116},[43,816,817,819,822,824,826,829],{"class":45,"line":362},[43,818,704],{"class":71},[43,820,821],{"class":67}," decoder",[43,823,107],{"class":75},[43,825,658],{"class":75},[43,827,828],{"class":63}," TextDecoder",[43,830,663],{"class":116},[43,832,833,836,839,841],{"class":45,"line":370},[43,834,835],{"class":71},"    let",[43,837,838],{"class":67}," buffer",[43,840,107],{"class":75},[43,842,695],{"class":75},[43,844,845],{"class":45,"line":375},[43,846,154],{"emptyLinePlaceholder":153},[43,848,849,852,854,857,860],{"class":45,"line":404},[43,850,851],{"class":56},"    while",[43,853,76],{"class":116},[43,855,856],{"class":342},"true",[43,858,859],{"class":116},") ",[43,861,300],{"class":75},[43,863,864,867,869,872,874,877,879,881,883,885,887,890],{"class":45,"line":433},[43,865,866],{"class":71},"      const",[43,868,98],{"class":75},[43,870,871],{"class":67}," done",[43,873,183],{"class":75},[43,875,876],{"class":67}," value",[43,878,104],{"class":75},[43,880,107],{"class":75},[43,882,110],{"class":56},[43,884,798],{"class":67},[43,886,144],{"class":75},[43,888,889],{"class":63},"read",[43,891,663],{"class":116},[43,893,894,897,899,901,903],{"class":45,"line":460},[43,895,896],{"class":56},"      if",[43,898,76],{"class":116},[43,900,570],{"class":67},[43,902,859],{"class":116},[43,904,905],{"class":56},"break\n",[43,907,909],{"class":45,"line":908},25,[43,910,154],{"emptyLinePlaceholder":153},[43,912,914,917,920,922,924,927,929,931,933,935,938,940,942,944],{"class":45,"line":913},26,[43,915,916],{"class":67},"      buffer",[43,918,919],{"class":75}," +=",[43,921,821],{"class":67},[43,923,144],{"class":75},[43,925,926],{"class":63},"decode",[43,928,68],{"class":116},[43,930,673],{"class":67},[43,932,183],{"class":75},[43,934,98],{"class":75},[43,936,937],{"class":116}," stream",[43,939,194],{"class":75},[43,941,343],{"class":342},[43,943,104],{"class":75},[43,945,121],{"class":116},[43,947,949,951,954,956,958,960,963,965,967,969,971],{"class":45,"line":948},27,[43,950,866],{"class":71},[43,952,953],{"class":67}," lines",[43,955,107],{"class":75},[43,957,838],{"class":67},[43,959,144],{"class":75},[43,961,962],{"class":63},"split",[43,964,68],{"class":116},[43,966,174],{"class":75},[43,968,482],{"class":67},[43,970,174],{"class":75},[43,972,121],{"class":116},[43,974,976,978,980,982,984,987,990,993,996],{"class":45,"line":975},28,[43,977,916],{"class":67},[43,979,107],{"class":75},[43,981,953],{"class":67},[43,983,144],{"class":75},[43,985,986],{"class":63},"pop",[43,988,989],{"class":116},"() ",[43,991,992],{"class":75},"??",[43,994,995],{"class":75}," ''",[43,997,998],{"class":49}," \u002F\u002F keep the incomplete tail for the next chunk\n",[43,1000,1002],{"class":45,"line":1001},29,[43,1003,154],{"emptyLinePlaceholder":153},[43,1005,1007,1010,1012,1015,1018,1021,1023,1025],{"class":45,"line":1006},30,[43,1008,1009],{"class":56},"      for",[43,1011,76],{"class":116},[43,1013,1014],{"class":71},"const",[43,1016,1017],{"class":67}," line",[43,1019,1020],{"class":75}," of",[43,1022,953],{"class":67},[43,1024,859],{"class":116},[43,1026,300],{"class":75},[43,1028,1030,1033,1035,1037,1039,1041,1044,1046,1048,1051,1053,1056],{"class":45,"line":1029},31,[43,1031,1032],{"class":56},"        if",[43,1034,76],{"class":116},[43,1036,455],{"class":75},[43,1038,45],{"class":67},[43,1040,144],{"class":75},[43,1042,1043],{"class":63},"startsWith",[43,1045,68],{"class":116},[43,1047,174],{"class":75},[43,1049,1050],{"class":177},"data: ",[43,1052,174],{"class":75},[43,1054,1055],{"class":116},")) ",[43,1057,1058],{"class":56},"continue\n",[43,1060,1062,1065,1068,1070,1072,1074,1077,1079,1081,1083,1086,1088,1091],{"class":45,"line":1061},32,[43,1063,1064],{"class":71},"        const",[43,1066,1067],{"class":67}," event",[43,1069,107],{"class":75},[43,1071,290],{"class":67},[43,1073,144],{"class":75},[43,1075,1076],{"class":63},"parse",[43,1078,68],{"class":116},[43,1080,45],{"class":67},[43,1082,144],{"class":75},[43,1084,1085],{"class":63},"slice",[43,1087,68],{"class":116},[43,1089,1090],{"class":328},"6",[43,1092,1093],{"class":116},"))\n",[43,1095,1097,1099,1101,1103,1105,1108,1111,1113,1116,1118,1121,1123,1125,1128,1130,1132,1134,1136,1139,1141,1143],{"class":45,"line":1096},33,[43,1098,1032],{"class":56},[43,1100,76],{"class":116},[43,1102,80],{"class":67},[43,1104,144],{"class":75},[43,1106,1107],{"class":67},"type",[43,1109,1110],{"class":75}," ===",[43,1112,197],{"class":75},[43,1114,1115],{"class":177},"content_block_delta",[43,1117,174],{"class":75},[43,1119,1120],{"class":75}," &&",[43,1122,1067],{"class":67},[43,1124,144],{"class":75},[43,1126,1127],{"class":67},"delta",[43,1129,144],{"class":75},[43,1131,1107],{"class":67},[43,1133,1110],{"class":75},[43,1135,197],{"class":75},[43,1137,1138],{"class":177},"text_delta",[43,1140,174],{"class":75},[43,1142,859],{"class":116},[43,1144,300],{"class":75},[43,1146,1148,1151,1153,1155,1157,1159,1161,1163,1165],{"class":45,"line":1147},34,[43,1149,1150],{"class":67},"          text",[43,1152,144],{"class":75},[43,1154,673],{"class":67},[43,1156,919],{"class":75},[43,1158,1067],{"class":67},[43,1160,144],{"class":75},[43,1162,1127],{"class":67},[43,1164,144],{"class":75},[43,1166,1167],{"class":67},"text\n",[43,1169,1171],{"class":45,"line":1170},35,[43,1172,1173],{"class":75},"        }\n",[43,1175,1177],{"class":45,"line":1176},36,[43,1178,1179],{"class":75},"      }\n",[43,1181,1183],{"class":45,"line":1182},37,[43,1184,1185],{"class":75},"    }\n",[43,1187,1189,1191,1193,1195,1197,1199,1201],{"class":45,"line":1188},38,[43,1190,668],{"class":67},[43,1192,144],{"class":75},[43,1194,673],{"class":67},[43,1196,107],{"class":75},[43,1198,197],{"class":75},[43,1200,570],{"class":177},[43,1202,273],{"class":75},[43,1204,1206],{"class":45,"line":1205},39,[43,1207,1208],{"class":75},"  }\n",[43,1210,1212],{"class":45,"line":1211},40,[43,1213,154],{"emptyLinePlaceholder":153},[43,1215,1217,1219,1222,1224,1226,1228],{"class":45,"line":1216},41,[43,1218,95],{"class":71},[43,1220,1221],{"class":67}," stop",[43,1223,107],{"class":75},[43,1225,509],{"class":75},[43,1227,86],{"class":71},[43,1229,89],{"class":75},[43,1231,1233,1235,1238,1241],{"class":45,"line":1232},42,[43,1234,653],{"class":67},[43,1236,1237],{"class":75},"?.",[43,1239,1240],{"class":63},"abort",[43,1242,663],{"class":116},[43,1244,1246,1248,1250,1252,1254,1256,1258],{"class":45,"line":1245},43,[43,1247,668],{"class":67},[43,1249,144],{"class":75},[43,1251,673],{"class":67},[43,1253,107],{"class":75},[43,1255,197],{"class":75},[43,1257,570],{"class":177},[43,1259,273],{"class":75},[43,1261,1263],{"class":45,"line":1262},44,[43,1264,1208],{"class":75},[43,1266,1268],{"class":45,"line":1267},45,[43,1269,154],{"emptyLinePlaceholder":153},[43,1271,1273,1275,1277,1279,1281,1283,1285,1287,1289,1291],{"class":45,"line":1272},46,[43,1274,436],{"class":56},[43,1276,98],{"class":75},[43,1278,520],{"class":67},[43,1280,183],{"class":75},[43,1282,539],{"class":67},[43,1284,183],{"class":75},[43,1286,624],{"class":67},[43,1288,183],{"class":75},[43,1290,1221],{"class":67},[43,1292,1293],{"class":75}," }\n",[43,1295,1297],{"class":45,"line":1296},47,[43,1298,1299],{"class":75},"}\n",[10,1301,1302],{},"Two details that matter here:",[1304,1305,1306,1316],"ul",{},[1307,1308,1309,1315],"li",{},[1310,1311,1312],"strong",{},[40,1313,1314],{},"decoder.decode(value, { stream: true })"," — without the flag, a multi-byte character (an emoji, an accented á) split across chunks becomes garbage.",[1307,1317,1318,1323],{},[1310,1319,1320],{},[40,1321,1322],{},"buffer = lines.pop()"," — the incomplete last line waits for the rest of itself. This is the line that separates a demo from a product.",[25,1325,1327],{"id":1326},"rendering-why-streamed-markdown-flickers","Rendering: why streamed markdown flickers",[10,1329,1330],{},"The naive approach re-parses the whole markdown string on every token:",[33,1332,1337],{"className":1333,"code":1335,"language":1336},[1334],"language-text","token arrives → text.value += token → markdown-it re-parses everything → v-html swaps the DOM\n","text",[40,1338,1335],{"__ignoreMap":38},[10,1340,1341],{},"At 60–100 tokens per second this means the entire answer's DOM is torn down and rebuilt dozens of times a second. Code blocks lose their syntax highlighting for a frame, images re-request, and the page visibly shimmers.",[10,1343,1344],{},"Three fixes, in order of effort:",[10,1346,1347,1350],{},[1310,1348,1349],{},"1. Throttle renders to animation frames."," Tokens can arrive faster than the screen refreshes. Accumulate them and flush once per frame:",[33,1352,1354],{"className":35,"code":1353,"language":37,"meta":38,"style":38},"let pending = ''\nlet scheduled = false\n\nconst push = (token: string) => {\n  pending += token\n  if (scheduled) return\n  scheduled = true\n  requestAnimationFrame(() => {\n    text.value += pending\n    pending = ''\n    scheduled = false\n  })\n}\n",[40,1355,1356,1368,1380,1384,1409,1419,1434,1444,1457,1470,1479,1488,1494],{"__ignoreMap":38},[43,1357,1358,1361,1364,1366],{"class":45,"line":46},[43,1359,1360],{"class":71},"let",[43,1362,1363],{"class":67}," pending ",[43,1365,506],{"class":75},[43,1367,695],{"class":75},[43,1369,1370,1372,1375,1377],{"class":45,"line":53},[43,1371,1360],{"class":71},[43,1373,1374],{"class":67}," scheduled ",[43,1376,506],{"class":75},[43,1378,1379],{"class":342}," false\n",[43,1381,1382],{"class":45,"line":92},[43,1383,154],{"emptyLinePlaceholder":153},[43,1385,1386,1388,1391,1393,1395,1398,1400,1403,1405,1407],{"class":45,"line":124},[43,1387,1014],{"class":71},[43,1389,1390],{"class":67}," push ",[43,1392,506],{"class":75},[43,1394,76],{"class":75},[43,1396,1397],{"class":79},"token",[43,1399,194],{"class":75},[43,1401,1402],{"class":607}," string",[43,1404,83],{"class":75},[43,1406,86],{"class":71},[43,1408,89],{"class":75},[43,1410,1411,1414,1416],{"class":45,"line":150},[43,1412,1413],{"class":67},"  pending",[43,1415,919],{"class":75},[43,1417,1418],{"class":67}," token\n",[43,1420,1421,1424,1426,1429,1431],{"class":45,"line":157},[43,1422,1423],{"class":56},"  if",[43,1425,76],{"class":116},[43,1427,1428],{"class":67},"scheduled",[43,1430,859],{"class":116},[43,1432,1433],{"class":56},"return\n",[43,1435,1436,1439,1441],{"class":45,"line":188},[43,1437,1438],{"class":67},"  scheduled",[43,1440,107],{"class":75},[43,1442,1443],{"class":342}," true\n",[43,1445,1446,1449,1451,1453,1455],{"class":45,"line":208},[43,1447,1448],{"class":63},"  requestAnimationFrame",[43,1450,68],{"class":116},[43,1452,141],{"class":75},[43,1454,86],{"class":71},[43,1456,89],{"class":75},[43,1458,1459,1461,1463,1465,1467],{"class":45,"line":218},[43,1460,686],{"class":67},[43,1462,144],{"class":75},[43,1464,673],{"class":67},[43,1466,919],{"class":75},[43,1468,1469],{"class":67}," pending\n",[43,1471,1472,1475,1477],{"class":45,"line":235},[43,1473,1474],{"class":67},"    pending",[43,1476,107],{"class":75},[43,1478,695],{"class":75},[43,1480,1481,1484,1486],{"class":45,"line":256},[43,1482,1483],{"class":67},"    scheduled",[43,1485,107],{"class":75},[43,1487,1379],{"class":342},[43,1489,1490,1492],{"class":45,"line":276},[43,1491,365],{"class":75},[43,1493,121],{"class":116},[43,1495,1496],{"class":45,"line":282},[43,1497,1299],{"class":75},[10,1499,1500],{},"The stream reads identically to the user, but you've cut renders from ~100\u002Fs to at most 60\u002Fs — and in practice far fewer.",[10,1502,1503,1506,1507,1510],{},[1310,1504,1505],{},"2. Only re-parse the tail."," Completed markdown blocks (separated by ",[40,1508,1509],{},"\\n\\n",") never change once closed. Parse them once, cache the HTML, and only re-parse the still-open final block. This drops re-parse cost from O(whole answer) to O(current paragraph).",[10,1512,1513,1516,1517,1520,1521,1524],{},[1310,1514,1515],{},"3. Never animate layout."," The blinking caret at the end of streamed text should be a CSS pseudo-element (",[40,1518,1519],{},"&::after"," with an opacity keyframe), not a character you append and remove — appending a real ",[40,1522,1523],{},"▍"," character to the string means every flush also shifts text.",[25,1526,1528],{"id":1527},"tool-calls-are-part-of-the-conversation","Tool calls are part of the conversation",[10,1530,1531,1532,1535],{},"When Claude decides to call a tool mid-answer, the stream switches from text deltas to ",[40,1533,1534],{},"tool_use"," blocks. The worst thing you can do is print raw JSON; the second worst is hiding it entirely, leaving a mysterious 3-second silence.",[10,1537,1538],{},"Treat the tool call as a first-class message segment with its own component:",[33,1540,1544],{"className":1541,"code":1542,"language":1543,"meta":38,"style":38},"language-vue shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u003CToolCallChip\n  :name=\"segment.toolName\"\n  :status=\"segment.status\"\n\u002F>\n","vue",[40,1545,1546,1553,1577,1596],{"__ignoreMap":38},[43,1547,1548,1550],{"class":45,"line":46},[43,1549,546],{"class":75},[43,1551,1552],{"class":116},"ToolCallChip\n",[43,1554,1555,1558,1561,1563,1566,1569,1571,1574],{"class":45,"line":53},[43,1556,1557],{"class":75},"  :",[43,1559,1560],{"class":71},"name",[43,1562,506],{"class":75},[43,1564,1565],{"class":75},"\"",[43,1567,1568],{"class":67},"segment",[43,1570,144],{"class":75},[43,1572,1573],{"class":67},"toolName",[43,1575,1576],{"class":75},"\"\n",[43,1578,1579,1581,1584,1586,1588,1590,1592,1594],{"class":45,"line":92},[43,1580,1557],{"class":75},[43,1582,1583],{"class":71},"status",[43,1585,506],{"class":75},[43,1587,1565],{"class":75},[43,1589,1568],{"class":67},[43,1591,144],{"class":75},[43,1593,1583],{"class":67},[43,1595,1576],{"class":75},[43,1597,1598],{"class":45,"line":124},[43,1599,1600],{"class":67},"\u002F>\n",[10,1602,1603,1604,1607],{},"A small chip — spinner while running, checkmark when done, expandable if the user wants the details. The mental model you're building for the user: ",[14,1605,1606],{},"the assistant is doing something, here's what, it's under control."," In my segments array, text blocks and tool blocks interleave in order, so the transcript reads like what actually happened.",[25,1609,1611],{"id":1610},"the-details-that-make-it-feel-calm","The details that make it feel calm",[1304,1613,1614,1620,1629,1638,1648],{},[1307,1615,1616,1619],{},[1310,1617,1618],{},"Auto-scroll with an escape hatch."," Follow the bottom while the user is at the bottom; the moment they scroll up, stop following and show a \"jump to latest\" pill. Fighting the user's scroll is the fastest way to make streaming feel hostile.",[1307,1621,1622,1628],{},[1310,1623,1624,1627],{},[40,1625,1626],{},"aria-live=\"polite\""," on the transcript"," — screen readers announce streamed content when the user is idle, instead of on every token.",[1307,1630,1631,1637],{},[1310,1632,1633,1634,144],{},"Respect ",[40,1635,1636],{},"prefers-reduced-motion"," Skip the caret blink and any typing embellishments; the text simply appears.",[1307,1639,1640,1643,1644,1647],{},[1310,1641,1642],{},"Design the stop button in from day one."," An ",[40,1645,1646],{},"AbortController"," on the fetch, a visible \"Stop\" affordance, and a state that renders the partial answer as valid — because a half-answer the user chose to keep is a feature, not an error.",[1307,1649,1650,1653],{},[1310,1651,1652],{},"Errors mid-stream keep the partial text."," Append a retry affordance under what already arrived. Throwing away 400 streamed words because the connection hiccuped at word 401 is rage-inducing.",[25,1655,1657],{"id":1656},"takeaways","Takeaways",[10,1659,1660],{},"Streaming UIs live or die on four things: a chunk parser that survives arbitrary split points, renders throttled to frames, markdown parsed incrementally instead of wholesale, and tool activity shown as status instead of silence. None of it is glamorous — and that's exactly why getting it right is a competitive advantage. Most teams ship the naive version.",[10,1662,1663,1664,144],{},"If you're building a streamed AI feature in Vue or Nuxt and want it to feel like a product instead of a demo, ",[1665,1666,1668],"a",{"href":1667},"\u002F#contact","that's exactly the work I do",[1670,1671,1672],"style",{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":38,"searchDepth":53,"depth":53,"links":1674},[1675,1676,1677,1678,1679,1680],{"id":27,"depth":53,"text":28},{"id":471,"depth":53,"text":472},{"id":1326,"depth":53,"text":1327},{"id":1527,"depth":53,"text":1528},{"id":1610,"depth":53,"text":1611},{"id":1656,"depth":53,"text":1657},"AI Product","2026-08-03","A no-flicker streaming chat UI in Nuxt — chunk parsing, smooth typing, graceful tool-call rendering, and the small decisions that make streamed AI output feel calm instead of jittery.","md",{},"\u002Fwriting\u002Fstreaming-responses-vue","9 min read",{"title":5,"description":1683},"writing\u002Fstreaming-responses-vue","Oinve955MFq48xXZLNazkgiFNsuQuWVOSy4HWiyiikQ",1785786127630]