[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"article-fetch-already-streams-readablestream":44,"search-suggestions":209,"related-fetch-already-streams-readablestream":251,"code:js:true:bial1f":387,"code:js:true:1wz30xv":388,"comments-01a0901c-7fee-7408-989d-4ff29be7c8f7":389},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"title":47,"subtitle":48,"excerpt":49,"coverUrl":50,"locale":13,"readingMinutes":51,"publishedAt":52,"viewCount":53,"likeCount":19,"commentCount":19,"author":54,"vertical":59,"topic":60,"tags":63,"_count":75,"playground":77,"body":79,"bodyMd":192,"seo":193,"translationGroupId":196,"series":48,"podcastUrl":48,"verticalId":5,"thread":197,"assessments":199,"translations":205,"quiz":207},"01a0901c-7fee-7408-989d-4ff29be7c8f7","fetch-already-streams-readablestream","Your Fetch Already Streams. You're Buffering It Anyway.",null,"A chat UI that types out its reply letter by letter isn't proof it's streaming — it's often proof the opposite. fetch() delivers a real ReadableStream body, and most code drains it into one string before showing a single character.","\u002Fmedia\u002Fcovers\u002Ffetch-already-streams-readablestream.png",5,"2026-09-16T11:12:00.901Z",31,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},"javascript","JavaScript",[64,66,69,72],{"slug":61,"name":65,"color":48},"Javascript",{"slug":67,"name":68,"color":48},"webdev","Webdev",{"slug":70,"name":71,"color":48},"api","API",{"slug":73,"name":74,"color":48},"streams","Streams",{"assessments":76},1,{"slug":46,"title":78},"Buffered vs. streamed — a real ReadableStream race",{"blocks":80,"version":76},[81,85,88,91,96,99,105,108,111,114,117,120,123,126,130,133,136,139,143,146,149,152,155,158,161,164,167,170,173,176,179,182,185],{"id":82,"html":83,"type":84},"b1","\u003Cp>You build a chat feature. The reply should feel alive — words appearing one at a time, like the model is thinking out loud. You wire up a \u003Ccode>setInterval\u003C\u002Fcode> that reveals one character every 20ms over the response text, and it works. Reload the page, send a message, watch the words type themselves out. Ship it.\u003C\u002Fp>","paragraph",{"id":86,"html":87,"type":84},"b2","\u003Cp>Then someone asks why the &quot;typing&quot; doesn&#39;t start until the reply is already fully generated server-side. You watch the network tab. The request sits at &quot;pending&quot; for the entire generation time — three seconds, five seconds, however long the full answer takes — and only then does your beautiful typewriter animation begin, replaying an answer that finished being written a moment ago.\u003C\u002Fp>",{"id":89,"html":90,"type":84},"b3","\u003Cp>The animation was never streaming anything. It was a flipbook drawn after the fact.\u003C\u002Fp>",{"id":92,"html":93,"text":94,"type":95,"level":31},"b4","The obvious fix, and why it isn&#39;t one","The obvious fix, and why it isn't one","heading",{"id":97,"html":98,"type":84},"b5","\u003Cp>Here&#39;s the code that got you there:\u003C\u002Fp>",{"id":100,"code":101,"type":102,"language":103,"highlight":104},"b6","async function getReply(prompt) {\n  const res = await fetch(\"\u002Fapi\u002Fchat\", {\n    method: \"POST\",\n    body: JSON.stringify({ prompt }),\n  });\n  const { reply } = await res.json();\n  typewriter(reply); \u002F\u002F reveal one character at a time\n}","code","js",[],{"id":106,"html":107,"type":84},"b7","\u003Cp>It \u003Cem>looks\u003C\u002Fem> like streaming. Characters appear over time, in order, at a readable pace. But look at where the \u003Ccode>await\u003C\u002Fcode> sits: \u003Ccode>res.json()\u003C\u002Fcode> doesn&#39;t resolve until the server has sent every byte of the response and the browser has parsed the whole thing into an object. The entire reply exists in memory, complete, before \u003Ccode>typewriter()\u003C\u002Fcode> gets called at all. The animation is decoration bolted onto a request-response cycle that was never partial to begin with.\u003C\u002Fp>",{"id":109,"html":110,"type":84},"b8","\u003Cp>This is the trap: a fake typewriter effect and a real streaming response produce the \u003Cem>exact same visual result\u003C\u002Fem> on a fast connection. You can&#39;t tell them apart by looking at the finished page. You can only tell them apart by watching when the first pixel of text actually has something behind it — and by then it&#39;s a five-second bug already living in production.\u003C\u002Fp>",{"id":112,"html":113,"type":84},"b9","\u003C!-- playground:start -->",{"id":115,"html":116,"text":116,"type":95,"level":31},"b10","🎮 Try it yourself",{"id":118,"html":119,"type":84},"b11","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":121,"html":122,"type":84},"b12","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":124,"html":125,"type":84},"b13","\u003C!-- playground:end -->",{"id":127,"html":128,"text":129,"type":95,"level":31},"b14","What \u003Ccode>res.json()\u003C\u002Fcode> is quietly doing","What res.json() is quietly doing",{"id":131,"html":132,"type":84},"b15","\u003Cp>\u003Ccode>fetch()\u003C\u002Fcode> doesn&#39;t hand you a finished string. It hands you a \u003Ccode>Response\u003C\u002Fcode> object whose \u003Ccode>body\u003C\u002Fcode> property is a real, honest-to-spec \u003Ccode>ReadableStream&lt;Uint8Array&gt;\u003C\u002Fcode> — a stream of raw byte chunks arriving from the network over time, same as any other stream on the platform. You can read from it directly, chunk by chunk, the moment each one lands.\u003C\u002Fp>",{"id":134,"html":135,"type":84},"b16","\u003Cp>\u003Ccode>res.json()\u003C\u002Fcode> and \u003Ccode>res.text()\u003C\u002Fcode> are convenience wrappers. Under the hood they call \u003Ccode>getReader()\u003C\u002Fcode> on that same stream, read every chunk until the stream is done, concatenate the bytes, decode them, and \u003Cem>then\u003C\u002Fem> resolve. They&#39;re not lying about being async — they really do wait — but they wait for the entire body, every time, no matter how large it is or how long it takes to arrive. Calling one of them is an explicit choice to buffer, even when the whole reason you reached for a stream-shaped API was to avoid exactly that.\u003C\u002Fp>",{"id":137,"html":138,"type":84},"b17","\u003Cp>Read the chunks yourself instead, and nothing forces you to wait for the last one before using the first:\u003C\u002Fp>",{"id":140,"code":141,"type":102,"language":103,"highlight":142},"b18","async function getReply(prompt) {\n  const res = await fetch(\"\u002Fapi\u002Fchat\", {\n    method: \"POST\",\n    body: JSON.stringify({ prompt }),\n  });\n\n  const reader = res.body.getReader();\n  const decoder = new TextDecoder();\n\n  while (true) {\n    const { done, value } = await reader.read();\n    if (done) break;\n\n    \u002F\u002F value is a Uint8Array chunk — decode it and show it now, not later\n    const text = decoder.decode(value, { stream: true });\n    appendToChat(text);\n  }\n}",[],{"id":144,"html":145,"type":84},"b19","\u003Cp>\u003Ccode>reader.read()\u003C\u002Fcode> resolves as soon as \u003Cem>a\u003C\u002Fem> chunk shows up, not when they all have. The \u003Ccode>{ stream: true }\u003C\u002Fcode> flag on the decoder matters more than it looks: a multi-byte UTF-8 character can land split across two chunks, and without that flag \u003Ccode>TextDecoder\u003C\u002Fcode> would mangle the half it sees instead of holding it back to join with the rest. Now the first words appear the instant the server writes them, and the &quot;typing&quot; animation you built earlier is redundant — the real arrival rate \u003Cem>is\u003C\u002Fem> the animation.\u003C\u002Fp>",{"id":147,"html":148,"text":148,"type":95,"level":31},"b20","The gotcha that catches people once",{"id":150,"html":151,"type":84},"b21","\u003Cp>Once you call \u003Ccode>res.body.getReader()\u003C\u002Fcode>, the body is locked to that reader. Call \u003Ccode>res.json()\u003C\u002Fcode> or \u003Ccode>res.text()\u003C\u002Fcode> on the same response afterward — say, because some shared logging helper touches every response — and it throws a \u003Ccode>TypeError\u003C\u002Fcode>, because the stream has already been (or is being) consumed. \u003Ccode>Response.bodyUsed\u003C\u002Fcode> flips to \u003Ccode>true\u003C\u002Fcode> the moment anything starts draining the body, streamed or buffered, and a \u003Ccode>Response\u003C\u002Fcode> only gets read once. If you need the raw text \u003Cem>and\u003C\u002Fem> need to stream, clone the response with \u003Ccode>res.clone()\u003C\u002Fcode> before touching either copy.\u003C\u002Fp>",{"id":153,"html":154,"text":154,"type":95,"level":31},"b22","The lesson underneath the API",{"id":156,"html":157,"type":84},"b23","\u003Cp>This isn&#39;t really a story about chat UIs. It&#39;s about a habit: reaching for a convenience method (\u003Ccode>.json()\u003C\u002Fcode>, \u003Ccode>.text()\u003C\u002Fcode>, \u003Ccode>.blob()\u003C\u002Fcode>) out of reflex, without noticing it silently collapses something that arrived over time into something that arrives all at once. The fix is never &quot;add an animation to fake the streaming back in&quot; — it&#39;s checking whether a buffering call already ate the streaming you wanted, upstream of where you&#39;re standing.\u003C\u002Fp>",{"id":159,"html":160,"type":84},"b24","\u003C!-- quiz:start -->",{"id":162,"html":163,"text":163,"type":95,"level":31},"b25","🧠 Test yourself",{"id":165,"html":166,"type":84},"b26","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":168,"html":169,"type":84},"b27","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":171,"html":172,"type":84},"b28","\u003C!-- quiz:end -->",{"id":174,"html":175,"type":84},"b29","\u003Cp>Next time you catch yourself writing a progress bar, a typing effect, or a loading spinner over a value that &quot;just needs a beat to feel right&quot; — check whether something underneath already knows the real timing, and you&#39;re just not asking it. What&#39;s the last place in your own code where you faked a delay that was already real?\u003C\u002Fp>",{"id":177,"type":178},"b30","divider",{"id":180,"html":181,"type":84},"b31","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":183,"html":184,"type":84},"b32","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":186,"type":187,"items":188,"ordered":18},"b33","list",[189,190,191],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","You build a chat feature. The reply should feel alive — words appearing one at a time, like the model is thinking out loud. You wire up a `setInterval` that reveals one character every 20ms over the response text, and it works. Reload the page, send a message, watch the words type themselves out. Ship it.\n\nThen someone asks why the \"typing\" doesn't start until the reply is already fully generated server-side. You watch the network tab. The request sits at \"pending\" for the entire generation time — three seconds, five seconds, however long the full answer takes — and only then does your beautiful typewriter animation begin, replaying an answer that finished being written a moment ago.\n\nThe animation was never streaming anything. It was a flipbook drawn after the fact.\n\n## The obvious fix, and why it isn't one\n\nHere's the code that got you there:\n\n```js\nasync function getReply(prompt) {\n  const res = await fetch(\"\u002Fapi\u002Fchat\", {\n    method: \"POST\",\n    body: JSON.stringify({ prompt }),\n  });\n  const { reply } = await res.json();\n  typewriter(reply); \u002F\u002F reveal one character at a time\n}\n```\n\nIt *looks* like streaming. Characters appear over time, in order, at a readable pace. But look at where the `await` sits: `res.json()` doesn't resolve until the server has sent every byte of the response and the browser has parsed the whole thing into an object. The entire reply exists in memory, complete, before `typewriter()` gets called at all. The animation is decoration bolted onto a request-response cycle that was never partial to begin with.\n\nThis is the trap: a fake typewriter effect and a real streaming response produce the *exact same visual result* on a fast connection. You can't tell them apart by looking at the finished page. You can only tell them apart by watching when the first pixel of text actually has something behind it — and by then it's a five-second bug already living in production.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## What `res.json()` is quietly doing\n\n`fetch()` doesn't hand you a finished string. It hands you a `Response` object whose `body` property is a real, honest-to-spec `ReadableStream\u003CUint8Array>` — a stream of raw byte chunks arriving from the network over time, same as any other stream on the platform. You can read from it directly, chunk by chunk, the moment each one lands.\n\n`res.json()` and `res.text()` are convenience wrappers. Under the hood they call `getReader()` on that same stream, read every chunk until the stream is done, concatenate the bytes, decode them, and *then* resolve. They're not lying about being async — they really do wait — but they wait for the entire body, every time, no matter how large it is or how long it takes to arrive. Calling one of them is an explicit choice to buffer, even when the whole reason you reached for a stream-shaped API was to avoid exactly that.\n\nRead the chunks yourself instead, and nothing forces you to wait for the last one before using the first:\n\n```js\nasync function getReply(prompt) {\n  const res = await fetch(\"\u002Fapi\u002Fchat\", {\n    method: \"POST\",\n    body: JSON.stringify({ prompt }),\n  });\n\n  const reader = res.body.getReader();\n  const decoder = new TextDecoder();\n\n  while (true) {\n    const { done, value } = await reader.read();\n    if (done) break;\n\n    \u002F\u002F value is a Uint8Array chunk — decode it and show it now, not later\n    const text = decoder.decode(value, { stream: true });\n    appendToChat(text);\n  }\n}\n```\n\n`reader.read()` resolves as soon as *a* chunk shows up, not when they all have. The `{ stream: true }` flag on the decoder matters more than it looks: a multi-byte UTF-8 character can land split across two chunks, and without that flag `TextDecoder` would mangle the half it sees instead of holding it back to join with the rest. Now the first words appear the instant the server writes them, and the \"typing\" animation you built earlier is redundant — the real arrival rate *is* the animation.\n\n## The gotcha that catches people once\n\nOnce you call `res.body.getReader()`, the body is locked to that reader. Call `res.json()` or `res.text()` on the same response afterward — say, because some shared logging helper touches every response — and it throws a `TypeError`, because the stream has already been (or is being) consumed. `Response.bodyUsed` flips to `true` the moment anything starts draining the body, streamed or buffered, and a `Response` only gets read once. If you need the raw text *and* need to stream, clone the response with `res.clone()` before touching either copy.\n\n## The lesson underneath the API\n\nThis isn't really a story about chat UIs. It's about a habit: reaching for a convenience method (`.json()`, `.text()`, `.blob()`) out of reflex, without noticing it silently collapses something that arrived over time into something that arrives all at once. The fix is never \"add an animation to fake the streaming back in\" — it's checking whether a buffering call already ate the streaming you wanted, upstream of where you're standing.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\nNext time you catch yourself writing a progress bar, a typing effect, or a loading spinner over a value that \"just needs a beat to feel right\" — check whether something underneath already knows the real timing, and you're just not asking it. What's the last place in your own code where you faked a delay that was already real?\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":47,"canonical":194,"description":195},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ffetch-already-streams-readablestream","A chat UI that types out its reply letter by letter isn't proof it's streaming — it's often proof the opposite. fetch() delivers a real ReadableStream body, and most code drains it","01a0901c-7fee-7408-989d-5225bd2b3d14",{"id":198,"locked":18},"01a0901c-834b-7695-9d28-cd10447a2e4e",[200],{"id":201,"slug":46,"title":202,"_count":203},"01a0901c-838c-75ea-8750-377d5464052b","Fetch, ReadableStream, and the buffering trap",{"questions":204},7,[206],{"locale":13,"slug":46},{"id":201,"slug":46,"title":202,"_count":208,"questionCount":204},{"questions":204},[210,212,214,218,222,226,230,234,237,241,244,248],{"slug":67,"name":68,"articles":211},98,{"slug":61,"name":65,"articles":213},82,{"slug":215,"name":216,"articles":217},"frontend","Frontend",70,{"slug":219,"name":220,"articles":221},"css","Css",32,{"slug":223,"name":224,"articles":225},"tutorial","Tutorial",27,{"slug":227,"name":228,"articles":229},"typescript","Typescript",14,{"slug":231,"name":232,"articles":233},"performance","Performance",12,{"slug":235,"name":236,"articles":233},"react","React",{"slug":238,"name":239,"articles":240},"node","Node",8,{"slug":242,"name":243,"articles":240},"browser","Browser",{"slug":245,"name":246,"articles":247},"grammar","Grammar",6,{"slug":249,"name":250,"articles":247},"programming","Programming",{"items":252,"meta":386},[253,264,287,307,327,349,368],{"id":45,"slug":46,"title":47,"subtitle":48,"excerpt":49,"coverUrl":50,"locale":13,"readingMinutes":51,"publishedAt":52,"viewCount":221,"likeCount":19,"commentCount":19,"author":254,"vertical":255,"topic":256,"tags":257,"_count":262,"playground":263,"hasQuiz":17,"hasPlayground":17},{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[258,259,260,261],{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":70,"name":71,"color":48},{"slug":73,"name":74,"color":48},{"assessments":76},{"slug":46},{"id":265,"slug":266,"title":267,"subtitle":48,"excerpt":268,"coverUrl":269,"locale":13,"readingMinutes":270,"publishedAt":271,"viewCount":272,"likeCount":19,"commentCount":19,"author":273,"vertical":274,"topic":275,"tags":276,"_count":285,"playground":286,"hasQuiz":17,"hasPlayground":17},"01a08af7-9462-760d-89bf-e4ef3c77bb6a","elementinternals-form-associated-custom-elements","The Star Rating That Never Reaches Your Server","A custom \u003Cstar-rating> element sits inside a \u003Cform>, looks like a form field, and gets left out of the submission entirely. The browser has a real fix for this — most teams reach for a hack instead.","\u002Fmedia\u002Fcovers\u002Felementinternals-form-associated-custom-elements.png",4,"2026-09-15T17:50:16.210Z",76,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[277,278,281,284],{"slug":61,"name":65,"color":48},{"slug":279,"name":280,"color":48},"webcomponents","Web Components",{"slug":282,"name":283,"color":48},"forms","Forms",{"slug":67,"name":68,"color":48},{"assessments":76},{"slug":266},{"id":288,"slug":289,"title":290,"subtitle":48,"excerpt":291,"coverUrl":292,"locale":13,"readingMinutes":233,"publishedAt":293,"viewCount":294,"likeCount":19,"commentCount":19,"author":295,"vertical":296,"topic":297,"tags":300,"_count":305,"playground":306,"hasQuiz":17,"hasPlayground":17},"01a04446-5a00-7624-b775-c73c6c035e67","vue-weekly-nexttick-batching-dom-updates","Vue nextTick Explained: How DOM Updates Are Batched","How Vue batches reactive writes into one DOM update, why the DOM looks stale right after a change, and how nextTick and watch's flush option fix it.","\u002Fmedia\u002Fcovers\u002Fvue-weekly-nexttick-batching-dom-updates.png","2026-09-14T12:19:00.606Z",115,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":298,"name":299},"vue","Vue",[301,302,303,304],{"slug":298,"name":299,"color":48},{"slug":61,"name":65,"color":48},{"slug":223,"name":224,"color":48},{"slug":67,"name":68,"color":48},{"assessments":76},{"slug":289},{"id":308,"slug":309,"title":310,"subtitle":48,"excerpt":311,"coverUrl":312,"locale":13,"readingMinutes":51,"publishedAt":313,"viewCount":314,"likeCount":19,"commentCount":19,"author":315,"vertical":316,"topic":317,"tags":318,"_count":325,"playground":326,"hasQuiz":17,"hasPlayground":17},"01a085d3-d892-7065-956a-2f646cc7107e","speech-synthesis-api-browser-tts","The 'Read Aloud' Button That Only Works on the Second Click","A text-to-speech button that works perfectly every time you test it — and silently fails or picks the wrong voice for a real visitor's first click. The bug is a timing race baked into the Web Speech API that most code never accounts for.","\u002Fmedia\u002Fcovers\u002Fspeech-synthesis-api-browser-tts.png","2026-09-14T12:18:29.717Z",78,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[319,320,321,324],{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":322,"name":323,"color":48},"accessibility","Accessibility",{"slug":223,"name":224,"color":48},{"assessments":76},{"slug":309},{"id":328,"slug":329,"title":330,"subtitle":48,"excerpt":331,"coverUrl":332,"locale":13,"readingMinutes":229,"publishedAt":333,"viewCount":334,"likeCount":19,"commentCount":19,"author":335,"vertical":336,"topic":337,"tags":340,"_count":347,"playground":348,"hasQuiz":17,"hasPlayground":17},"01a08c59-464c-7039-acf3-b32a0b9ee7ce","nuxt-weekly-useasyncdata-keys-dedupe","useAsyncData Keys in Nuxt: Caching, Dedupe & the Sharing Bug","Learn how Nuxt's useAsyncData and useFetch generate cache keys, how dedupe (cancel vs defer) really works, and why wrapper composables silently share data.","\u002Fmedia\u002Fcovers\u002Fnuxt-weekly-useasyncdata-keys-dedupe.png","2026-09-13T11:26:28.014Z",142,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":338,"name":339},"nuxt","Nuxt",[341,342,345,346],{"slug":338,"name":339,"color":48},{"slug":343,"name":344,"color":48},"ssr","SSR",{"slug":61,"name":65,"color":48},{"slug":223,"name":224,"color":48},{"assessments":76},{"slug":329},{"id":350,"slug":351,"title":352,"subtitle":48,"excerpt":353,"coverUrl":354,"locale":13,"readingMinutes":247,"publishedAt":355,"viewCount":356,"likeCount":19,"commentCount":19,"author":357,"vertical":358,"topic":359,"tags":361,"_count":366,"playground":367,"hasQuiz":17,"hasPlayground":17},"01a080a9-801c-727a-ad92-866c15063710","css-custom-highlight-api-no-dom-mutation","The Search Highlight That Deletes Your Selection","A search-highlight feature that rebuilds its DOM on every keystroke can wipe out a user's own text selection with no warning. The CSS Custom Highlight API fixes it by styling text ranges without ever touching a DOM node.","\u002Fmedia\u002Fcovers\u002Fcss-custom-highlight-api-no-dom-mutation.png","2026-09-13T11:25:57.014Z",186,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":219,"name":360},"CSS",[362,363,364,365],{"slug":219,"name":220,"color":48},{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":223,"name":224,"color":48},{"assessments":76},{"slug":351},{"id":369,"slug":370,"title":371,"subtitle":48,"excerpt":372,"coverUrl":373,"locale":13,"readingMinutes":51,"publishedAt":374,"viewCount":375,"likeCount":19,"commentCount":19,"author":376,"vertical":377,"topic":378,"tags":379,"_count":384,"playground":385,"hasQuiz":17,"hasPlayground":17},"01a0764b-4844-732e-99fe-3e0122793752","promise-try-synchronous-throw-safety","My .catch() Never Ran. The Bug Was Two Lines Above It","A function that sometimes throws synchronously instead of returning a promise will skip every .catch() chained to it — because the promise never gets created. Promise.try() fixes it, and does one thing try\u002Fcatch can't.","\u002Fmedia\u002Fcovers\u002Fpromise-try-synchronous-throw-safety.png","2026-09-13T08:41:22.072Z",123,{"id":55,"name":56,"username":57,"avatarUrl":48,"headline":58},{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":61,"name":62},[380,381,382,383],{"slug":61,"name":65,"color":48},{"slug":67,"name":68,"color":48},{"slug":238,"name":239,"color":48},{"slug":249,"name":250,"color":48},{"assessments":76},{"slug":370},{"page":76,"perPage":204,"total":213,"totalPages":233},"\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003Cdiv>8\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-kwd\">async\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">function\u003C\u002Fspan> \u003Cspan class=\"shj-func\">getReply\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>prompt\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> res \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> \u003Cspan class=\"shj-func\">fetch\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"\u002Fapi\u002Fchat\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n    method\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-str\">\"POST\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n    body\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-num\">JSON\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">stringify\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> prompt \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> reply \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> res\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">json\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n  \u003Cspan class=\"shj-func\">typewriter\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>reply\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>; \u003Cspan class=\"shj-cmnt\">\u002F\u002F reveal one character at a time\n\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>","\u003Cdiv class=\"shj shj-lang-js shj-multiline\" data-lang=\"js\">\u003Cdiv class=\"shj-scroll\">\u003Cdiv class=\"shj-numbers\">\u003Cdiv>1\u003C\u002Fdiv>\u003Cdiv>2\u003C\u002Fdiv>\u003Cdiv>3\u003C\u002Fdiv>\u003Cdiv>4\u003C\u002Fdiv>\u003Cdiv>5\u003C\u002Fdiv>\u003Cdiv>6\u003C\u002Fdiv>\u003Cdiv>7\u003C\u002Fdiv>\u003Cdiv>8\u003C\u002Fdiv>\u003Cdiv>9\u003C\u002Fdiv>\u003Cdiv>10\u003C\u002Fdiv>\u003Cdiv>11\u003C\u002Fdiv>\u003Cdiv>12\u003C\u002Fdiv>\u003Cdiv>13\u003C\u002Fdiv>\u003Cdiv>14\u003C\u002Fdiv>\u003Cdiv>15\u003C\u002Fdiv>\u003Cdiv>16\u003C\u002Fdiv>\u003Cdiv>17\u003C\u002Fdiv>\u003Cdiv>18\u003C\u002Fdiv>\u003C\u002Fdiv>\u003Cdiv class=\"shj-code\">\u003Cspan class=\"shj-kwd\">async\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">function\u003C\u002Fspan> \u003Cspan class=\"shj-func\">getReply\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>prompt\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> res \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> \u003Cspan class=\"shj-func\">fetch\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-str\">\"\u002Fapi\u002Fchat\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n    method\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-str\">\"POST\"\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n    body\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-num\">JSON\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">stringify\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> prompt \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan>\n  \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> reader \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> res\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>body\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">getReader\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n  \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> decoder \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">new\u003C\u002Fspan> \u003Cspan class=\"shj-class\">TextDecoder\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n\n  \u003Cspan class=\"shj-kwd\">while\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bool\">true\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan>\n    \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> done\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> value \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan> \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">await\u003C\u002Fspan> reader\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">read\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n    \u003Cspan class=\"shj-kwd\">if\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>done\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan> \u003Cspan class=\"shj-kwd\">break\u003C\u002Fspan>;\n\n    \u003Cspan class=\"shj-cmnt\">\u002F\u002F value is a Uint8Array chunk — decode it and show it now, not later\n\u003C\u002Fspan>    \u003Cspan class=\"shj-kwd\">const\u003C\u002Fspan> text \u003Cspan class=\"shj-oper\">=\u003C\u002Fspan> decoder\u003Cspan class=\"shj-oper\">.\u003C\u002Fspan>\u003Cspan class=\"shj-func\">decode\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>value\u003Cspan class=\"shj-oper\">,\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">{\u003C\u002Fspan> stream\u003Cspan class=\"shj-oper\">:\u003C\u002Fspan> \u003Cspan class=\"shj-bool\">true\u003C\u002Fspan> \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n    \u003Cspan class=\"shj-func\">appendToChat\u003C\u002Fspan>\u003Cspan class=\"shj-bracket\">(\u003C\u002Fspan>text\u003Cspan class=\"shj-bracket\">)\u003C\u002Fspan>;\n  \u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\n\u003Cspan class=\"shj-bracket\">}\u003C\u002Fspan>\u003C\u002Fdiv>\u003C\u002Fdiv>\u003C\u002Fdiv>",{"locked":18,"total":19,"comments":390},[]]