[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-offscreencanvas-canvas-off-main-thread":44,"search-suggestions":60,"quiz-article-offscreencanvas-canvas-off-main-thread":108,"path-membership-offscreencanvas-canvas-off-main-thread":296},[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,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a18121-9261-752f-8956-7c174be8b05f","offscreencanvas-canvas-off-main-thread","PRACTICE_QUIZ","OffscreenCanvas: rendering off the main thread","Seven questions on why canvas drawing blocks the page by default, what transferControlToOffscreen() actually moves, and when the extra complexity is and isn't worth it.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},7,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,100,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",166,{"slug":66,"name":67,"articles":68},"javascript","Javascript",133,{"slug":70,"name":71,"articles":72},"frontend","Frontend",94,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",66,{"slug":78,"name":79,"articles":80},"css","Css",50,{"slug":82,"name":83,"articles":84},"performance","Performance",28,{"slug":86,"name":87,"articles":88},"typescript","Typescript",24,{"slug":90,"name":91,"articles":92},"react","React",20,{"slug":94,"name":95,"articles":96},"programming","Programming",16,{"slug":98,"name":99,"articles":96},"browser","Browser",{"slug":101,"name":102,"articles":103},"node","Node",14,{"slug":105,"name":106,"articles":107},"html","Html",11,{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":113,"publishedAt":114,"viewCount":80,"likeCount":19,"commentCount":19,"author":115,"vertical":120,"topic":121,"tags":123,"_count":128,"playground":130,"body":132,"bodyMd":282,"seo":283,"translationGroupId":286,"series":52,"podcastUrl":52,"verticalId":5,"thread":287,"assessments":289,"translations":292,"quiz":294},"01a18121-921f-706e-9714-f48280c4dc61","Your Canvas Is Freezing the Page. OffscreenCanvas Fixes That.","Click a button while your canvas animation is running and the whole page stalls — because the pixels it draws, your layout, and your input handling all queue up on the same main thread. OffscreenCanvas moves the drawing itself into a worker, so the UI keeps responding no matter how much canvas work is happening.","\u002Fmedia\u002Fcovers\u002Foffscreencanvas-canvas-off-main-thread.png",6,"2026-11-03T06:10:56.984Z",{"id":116,"name":117,"username":118,"avatarUrl":52,"headline":119},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":122},"JavaScript",[124,125,126,127],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":82,"name":83,"color":52},{"slug":70,"name":71,"color":52},{"assessments":129},1,{"slug":46,"title":131},"OffscreenCanvas — interactive playground",{"blocks":133,"version":129},[134,138,141,144,148,151,157,160,164,167,170,173,177,181,185,188,191,194,197,200,203,206,209,212,215,218,221,224,227,230,233,236,239,242,245,248,251,254,257,264,267,270,273,276],{"id":135,"html":136,"type":137},"b1","\u003Cp>You open the profiler, record a session, and the flame chart looks great. 60 frames a second, steady, no dropped frames. You ship it.\u003C\u002Fp>","paragraph",{"id":139,"html":140,"type":137},"b2","\u003Cp>Then someone clicks a button while the animation is running, and for half a second, nothing happens. Not a dropped frame — the whole page. The click just sits there, queued, waiting for a thread that&#39;s busy painting pixels nobody asked it to interrupt.\u003C\u002Fp>",{"id":142,"html":143,"type":137},"b3","\u003Cp>Here&#39;s the part that doesn&#39;t show up in a frame-rate graph: your canvas animation was never competing with itself. It was competing with every click handler, every layout pass, every bit of input handling on the page — because by default, all of that runs on exactly one thread, and canvas drawing calls are first-class citizens on it.\u003C\u002Fp>",{"id":145,"html":146,"text":146,"type":147,"level":31},"b4","The fix everyone reaches for first","heading",{"id":149,"html":150,"type":137},"b5","\u003Cp>The instinct is to blame the math. Heavy canvas work — thousands of particles, a live chart redrawing every tick, a game loop doing collision checks — looks like a computation problem, so people reach for a Web Worker to do the computation off-thread.\u003C\u002Fp>",{"id":152,"code":153,"type":154,"language":155,"highlight":156},"b6","\u002F\u002F main.js\nconst worker = new Worker(\"particles-worker.js\");\nworker.postMessage({ type: \"tick\" });\nworker.onmessage = (e) => {\n  const positions = e.data.positions;\n  \u002F\u002F ...now draw them, back on the main thread\n  ctx.clearRect(0, 0, canvas.width, canvas.height);\n  for (const p of positions) ctx.fillRect(p.x, p.y, 2, 2);\n};","code","js",[],{"id":158,"html":159,"type":137},"b7","\u003Cp>This genuinely helps — the position math moves off the main thread. But watch what&#39;s still happening in that \u003Ccode>onmessage\u003C\u002Fcode> handler: \u003Ccode>clearRect\u003C\u002Fcode>, \u003Ccode>fillRect\u003C\u002Fcode>, every actual drawing call, is still running on the main thread, because that&#39;s the only thread with access to the \u003Ccode>&lt;canvas&gt;\u003C\u002Fcode> element&#39;s rendering context. You moved the arithmetic. You didn&#39;t move the part that was actually fighting your clicks for time.\u003C\u002Fp>",{"id":161,"html":162,"text":163,"type":147,"level":31},"b8","The part that&#39;s easy to miss: drawing itself blocks the page","The part that's easy to miss: drawing itself blocks the page",{"id":165,"html":166,"type":137},"b9","\u003Cp>A \u003Ccode>&lt;canvas&gt;\u003C\u002Fcode> element&#39;s 2D or WebGL context has always been tied to the DOM, and anything tied to the DOM only exists on the main thread. That&#39;s not a performance detail, it&#39;s a structural one: a worker has no \u003Ccode>document\u003C\u002Fcode>, so it has never been able to hold a canvas context at all — until there was a way to give it one that isn&#39;t attached to the page.\u003C\u002Fp>",{"id":168,"html":169,"type":137},"b10","\u003Cp>That&#39;s what \u003Ccode>OffscreenCanvas\u003C\u002Fcode> is. Not a faster canvas. A canvas whose rendering context can live somewhere other than the main thread, so the fastest possible canvas is one where the drawing calls stop queuing up behind your \u003Ccode>click\u003C\u002Fcode> listeners in the first place.\u003C\u002Fp>",{"id":171,"html":172,"text":172,"type":147,"level":31},"b11","The fix: move the context itself into the worker",{"id":174,"code":175,"type":154,"language":105,"highlight":176},"b12","\u003Ccanvas id=\"scene\" width=\"600\" height=\"400\">\u003C\u002Fcanvas>",[],{"id":178,"code":179,"type":154,"language":155,"highlight":180},"b13","\u002F\u002F main.js\nconst canvas = document.getElementById(\"scene\");\nconst offscreen = canvas.transferControlToOffscreen();\nconst worker = new Worker(\"render-worker.js\");\nworker.postMessage({ canvas: offscreen }, [offscreen]);\n\n\u002F\u002F the main thread still owns input — forward what the worker needs\ncanvas.addEventListener(\"pointermove\", (e) => {\n  worker.postMessage({ type: \"pointer\", x: e.offsetX, y: e.offsetY });\n});",[],{"id":182,"code":183,"type":154,"language":155,"highlight":184},"b14","\u002F\u002F render-worker.js\nlet ctx;\nlet pointer = { x: 0, y: 0 };\n\nself.onmessage = (e) => {\n  if (e.data.canvas) {\n    ctx = e.data.canvas.getContext(\"2d\");\n    requestAnimationFrame(loop);\n  }\n  if (e.data.type === \"pointer\") pointer = e.data;\n};\n\nfunction loop() {\n  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n  ctx.beginPath();\n  ctx.arc(pointer.x, pointer.y, 20, 0, Math.PI * 2);\n  ctx.fill();\n  requestAnimationFrame(loop);\n}",[],{"id":186,"html":187,"type":137},"b15","\u003Cp>\u003Ccode>transferControlToOffscreen()\u003C\u002Fcode> detaches the canvas element from its own rendering context and hands you an \u003Ccode>OffscreenCanvas\u003C\u002Fcode> you can transfer to a worker like any other transferable object. Inside the worker, \u003Ccode>getContext(&quot;2d&quot;)\u003C\u002Fcode> gives back something that draws almost exactly like the context you&#39;re used to — same \u003Ccode>fillRect\u003C\u002Fcode>, same \u003Ccode>beginPath\u003C\u002Fcode>, same \u003Ccode>drawImage\u003C\u002Fcode> — except every one of those calls now runs on a thread that has nothing else to do but draw.\u003C\u002Fp>",{"id":189,"html":190,"type":137},"b16","\u003Cp>The one thing that doesn&#39;t come along for free is input. A worker has no DOM, so it can&#39;t attach its own \u003Ccode>pointermove\u003C\u002Fcode> or \u003Ccode>click\u003C\u002Fcode> listeners to anything. The main thread stays the one place that owns events, and your job is to forward the few fields the worker actually needs — as shown above, just \u003Ccode>x\u003C\u002Fcode> and \u003Ccode>y\u003C\u002Fcode>, not the whole event object, which isn&#39;t transferable anyway.\u003C\u002Fp>",{"id":192,"html":193,"text":193,"type":147,"level":31},"b17","Why the page stays responsive now",{"id":195,"html":196,"type":137},"b18","\u003Cp>Open two tabs in your head. In the first, with everything on the main thread, a click has to wait in the same queue as every pending \u003Ccode>fillRect\u003C\u002Fcode> call — if the canvas is mid-frame, the click waits for the frame to finish. In the second, with \u003Ccode>OffscreenCanvas\u003C\u002Fcode>, the canvas&#39;s frame is happening on a different thread entirely. The main thread&#39;s event queue is short again, because the one thing that used to dominate it moved out.\u003C\u002Fp>",{"id":198,"html":199,"type":137},"b19","\u003Cp>This is the distinction worth sitting with: OffscreenCanvas doesn&#39;t make your particle system compute faster, and it doesn&#39;t raise your frame rate. It makes the main thread&#39;s queue shorter by taking an entire category of work out of it. A canvas that was drawing 10,000 particles at 60fps before will still be drawing 10,000 particles at roughly 60fps after — the difference is that a click, a keypress, or a scroll now gets handled in the same frame instead of after it.\u003C\u002Fp>",{"id":201,"html":202,"type":137},"b20","\u003C!-- playground:start -->",{"id":204,"html":205,"text":205,"type":147,"level":31},"b21","🎮 Try it yourself",{"id":207,"html":208,"type":137},"b22","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Foffscreencanvas-canvas-off-main-thread\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":210,"html":211,"type":137},"b23","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":213,"html":214,"type":137},"b24","\u003C!-- playground:end -->",{"id":216,"html":217,"text":217,"type":147,"level":31},"b25","When the main thread was fine all along",{"id":219,"html":220,"type":137},"b26","\u003Cp>Not every canvas needs this. A sparkline that redraws once when new data arrives, a static chart, an icon you draw once on mount — none of that is holding the main thread hostage, because none of it is running every frame. The tell isn&#39;t &quot;I use canvas,&quot; it&#39;s &quot;my canvas work and my users&#39; clicks are both trying to happen \u003Cem>right now\u003C\u002Fem>, repeatedly, for as long as the page is open.&quot; Games, live data visualizations, drawing tools, anything with a \u003Ccode>requestAnimationFrame\u003C\u002Fcode> loop that runs indefinitely — that&#39;s the category where this earns its complexity.\u003C\u002Fp>",{"id":222,"html":223,"type":137},"b27","\u003Cp>It&#39;s also not free complexity. You lose direct DOM access inside the worker, so anything that reads layout, measures text against real fonts in edge cases, or needs synchronous access to other page state has to be designed around message passing from the start, not bolted on later.\u003C\u002Fp>",{"id":225,"html":226,"text":226,"type":147,"level":31},"b28","Is this safe to ship today?",{"id":228,"html":229,"type":137},"b29","\u003Cp>Yes. Chrome has supported \u003Ccode>OffscreenCanvas\u003C\u002Fcode> since 2018, Firefox enabled it for workers a few years after that, and Safari was the last to land it, in Safari 16.4 in March 2023. All three major engines have carried it for a while now, so there&#39;s no meaningful fallback story you need to write — check \u003Ccode>&quot;OffscreenCanvas&quot; in window\u003C\u002Fcode> if you want a belt-and-suspenders guard, but you&#39;re unlikely to need it.\u003C\u002Fp>",{"id":231,"html":232,"type":137},"b30","\u003Cp>The next time a profiler shows you a perfectly smooth 60fps canvas sitting on top of a page that still stutters when someone clicks, you&#39;ll know the frame rate was never the number that mattered.\u003C\u002Fp>",{"id":234,"html":235,"type":137},"b31","\u003Cp>What&#39;s the heaviest thing you&#39;re currently drawing to a \u003Ccode>&lt;canvas&gt;\u003C\u002Fcode> on the main thread? I&#39;m curious how many of you are one \u003Ccode>transferControlToOffscreen()\u003C\u002Fcode> call away from fixing a bug you&#39;ve been blaming on something else.\u003C\u002Fp>",{"id":237,"html":238,"type":137},"b32","\u003C!-- quiz:start -->",{"id":240,"html":241,"text":241,"type":147,"level":31},"b33","🧠 Test yourself",{"id":243,"html":244,"type":137},"b34","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Foffscreencanvas-canvas-off-main-thread\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":246,"html":247,"type":137},"b35","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":249,"html":250,"type":137},"b36","\u003C!-- quiz:end -->",{"id":252,"html":253,"type":137},"b37","\u003C!-- related:start -->",{"id":255,"html":256,"text":256,"type":147,"level":31},"b38","📚 Read next",{"id":258,"type":259,"items":260,"ordered":18},"b39","list",[261,262,263],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscheduler-yield-main-thread\">Your UI is freezing because you never gave the browser a break\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Findexeddb-localstorage-main-thread-blocking\">localStorage Isn&#39;t Free — It&#39;s Blocking Your Main Thread\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftab-leader-election-weblocks-broadcastchannel\">Your Tabs Each Open Their Own WebSocket. navigator.locks Elects One.\u003C\u002Fa>",{"id":265,"html":266,"type":137},"b40","\u003C!-- related:end -->",{"id":268,"type":269},"b41","divider",{"id":271,"html":272,"type":137},"b42","\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":274,"html":275,"type":137},"b43","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":277,"type":259,"items":278,"ordered":18},"b44",[279,280,281],"⭐ \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 open the profiler, record a session, and the flame chart looks great. 60 frames a second, steady, no dropped frames. You ship it.\n\nThen someone clicks a button while the animation is running, and for half a second, nothing happens. Not a dropped frame — the whole page. The click just sits there, queued, waiting for a thread that's busy painting pixels nobody asked it to interrupt.\n\nHere's the part that doesn't show up in a frame-rate graph: your canvas animation was never competing with itself. It was competing with every click handler, every layout pass, every bit of input handling on the page — because by default, all of that runs on exactly one thread, and canvas drawing calls are first-class citizens on it.\n\n## The fix everyone reaches for first\n\nThe instinct is to blame the math. Heavy canvas work — thousands of particles, a live chart redrawing every tick, a game loop doing collision checks — looks like a computation problem, so people reach for a Web Worker to do the computation off-thread.\n\n```js\n\u002F\u002F main.js\nconst worker = new Worker(\"particles-worker.js\");\nworker.postMessage({ type: \"tick\" });\nworker.onmessage = (e) => {\n  const positions = e.data.positions;\n  \u002F\u002F ...now draw them, back on the main thread\n  ctx.clearRect(0, 0, canvas.width, canvas.height);\n  for (const p of positions) ctx.fillRect(p.x, p.y, 2, 2);\n};\n```\n\nThis genuinely helps — the position math moves off the main thread. But watch what's still happening in that `onmessage` handler: `clearRect`, `fillRect`, every actual drawing call, is still running on the main thread, because that's the only thread with access to the `\u003Ccanvas>` element's rendering context. You moved the arithmetic. You didn't move the part that was actually fighting your clicks for time.\n\n## The part that's easy to miss: drawing itself blocks the page\n\nA `\u003Ccanvas>` element's 2D or WebGL context has always been tied to the DOM, and anything tied to the DOM only exists on the main thread. That's not a performance detail, it's a structural one: a worker has no `document`, so it has never been able to hold a canvas context at all — until there was a way to give it one that isn't attached to the page.\n\nThat's what `OffscreenCanvas` is. Not a faster canvas. A canvas whose rendering context can live somewhere other than the main thread, so the fastest possible canvas is one where the drawing calls stop queuing up behind your `click` listeners in the first place.\n\n## The fix: move the context itself into the worker\n\n```html\n\u003Ccanvas id=\"scene\" width=\"600\" height=\"400\">\u003C\u002Fcanvas>\n```\n\n```js\n\u002F\u002F main.js\nconst canvas = document.getElementById(\"scene\");\nconst offscreen = canvas.transferControlToOffscreen();\nconst worker = new Worker(\"render-worker.js\");\nworker.postMessage({ canvas: offscreen }, [offscreen]);\n\n\u002F\u002F the main thread still owns input — forward what the worker needs\ncanvas.addEventListener(\"pointermove\", (e) => {\n  worker.postMessage({ type: \"pointer\", x: e.offsetX, y: e.offsetY });\n});\n```\n\n```js\n\u002F\u002F render-worker.js\nlet ctx;\nlet pointer = { x: 0, y: 0 };\n\nself.onmessage = (e) => {\n  if (e.data.canvas) {\n    ctx = e.data.canvas.getContext(\"2d\");\n    requestAnimationFrame(loop);\n  }\n  if (e.data.type === \"pointer\") pointer = e.data;\n};\n\nfunction loop() {\n  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);\n  ctx.beginPath();\n  ctx.arc(pointer.x, pointer.y, 20, 0, Math.PI * 2);\n  ctx.fill();\n  requestAnimationFrame(loop);\n}\n```\n\n`transferControlToOffscreen()` detaches the canvas element from its own rendering context and hands you an `OffscreenCanvas` you can transfer to a worker like any other transferable object. Inside the worker, `getContext(\"2d\")` gives back something that draws almost exactly like the context you're used to — same `fillRect`, same `beginPath`, same `drawImage` — except every one of those calls now runs on a thread that has nothing else to do but draw.\n\nThe one thing that doesn't come along for free is input. A worker has no DOM, so it can't attach its own `pointermove` or `click` listeners to anything. The main thread stays the one place that owns events, and your job is to forward the few fields the worker actually needs — as shown above, just `x` and `y`, not the whole event object, which isn't transferable anyway.\n\n## Why the page stays responsive now\n\nOpen two tabs in your head. In the first, with everything on the main thread, a click has to wait in the same queue as every pending `fillRect` call — if the canvas is mid-frame, the click waits for the frame to finish. In the second, with `OffscreenCanvas`, the canvas's frame is happening on a different thread entirely. The main thread's event queue is short again, because the one thing that used to dominate it moved out.\n\nThis is the distinction worth sitting with: OffscreenCanvas doesn't make your particle system compute faster, and it doesn't raise your frame rate. It makes the main thread's queue shorter by taking an entire category of work out of it. A canvas that was drawing 10,000 particles at 60fps before will still be drawing 10,000 particles at roughly 60fps after — the difference is that a click, a keypress, or a scroll now gets handled in the same frame instead of after it.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Foffscreencanvas-canvas-off-main-thread\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## When the main thread was fine all along\n\nNot every canvas needs this. A sparkline that redraws once when new data arrives, a static chart, an icon you draw once on mount — none of that is holding the main thread hostage, because none of it is running every frame. The tell isn't \"I use canvas,\" it's \"my canvas work and my users' clicks are both trying to happen *right now*, repeatedly, for as long as the page is open.\" Games, live data visualizations, drawing tools, anything with a `requestAnimationFrame` loop that runs indefinitely — that's the category where this earns its complexity.\n\nIt's also not free complexity. You lose direct DOM access inside the worker, so anything that reads layout, measures text against real fonts in edge cases, or needs synchronous access to other page state has to be designed around message passing from the start, not bolted on later.\n\n## Is this safe to ship today?\n\nYes. Chrome has supported `OffscreenCanvas` since 2018, Firefox enabled it for workers a few years after that, and Safari was the last to land it, in Safari 16.4 in March 2023. All three major engines have carried it for a while now, so there's no meaningful fallback story you need to write — check `\"OffscreenCanvas\" in window` if you want a belt-and-suspenders guard, but you're unlikely to need it.\n\nThe next time a profiler shows you a perfectly smooth 60fps canvas sitting on top of a page that still stutters when someone clicks, you'll know the frame rate was never the number that mattered.\n\nWhat's the heaviest thing you're currently drawing to a `\u003Ccanvas>` on the main thread? I'm curious how many of you are one `transferControlToOffscreen()` call away from fixing a bug you've been blaming on something else.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Foffscreencanvas-canvas-off-main-thread\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\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Your UI is freezing because you never gave the browser a break](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fscheduler-yield-main-thread)\n- [localStorage Isn't Free — It's Blocking Your Main Thread](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Findexeddb-localstorage-main-thread-blocking)\n- [Your Tabs Each Open Their Own WebSocket. navigator.locks Elects One.](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Ftab-leader-election-weblocks-broadcastchannel)\n\n\u003C!-- related:end -->\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":110,"canonical":284,"description":285},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Foffscreencanvas-canvas-off-main-thread","Click a button while your canvas animation is running and the whole page stalls — because the pixels it draws, your layout, and your input handling all queue up on the same main th","01a18121-921f-706e-9714-f83fd5ea352a",{"id":288,"locked":18},"01a18121-9245-70f0-b3bf-fd3f2b2a8387",[290],{"id":45,"slug":46,"title":48,"_count":291},{"questions":51},[293],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":295,"questionCount":51},{"questions":51},[]]