[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-promise-try-synchronous-throw-safety":44,"search-suggestions":60,"quiz-article-promise-try-synchronous-throw-safety":106},[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},"01a0764b-48b0-7038-b5af-b3ef02dc73f7","promise-try-synchronous-throw-safety","PRACTICE_QUIZ","Promise.try() and the .catch() that never ran","Check what you took from the post: why a synchronous throw can skip a chained .catch(), what async functions already fix, and what Promise.try() adds on top.",{"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,88,92,95,98,102],{"slug":62,"name":63,"articles":64},"webdev","Webdev",89,{"slug":66,"name":67,"articles":68},"javascript","Javascript",73,{"slug":70,"name":71,"articles":72},"frontend","Frontend",69,{"slug":74,"name":75,"articles":76},"css","Css",31,{"slug":78,"name":79,"articles":80},"tutorial","Tutorial",20,{"slug":82,"name":83,"articles":84},"typescript","Typescript",12,{"slug":86,"name":87,"articles":84},"performance","Performance",{"slug":89,"name":90,"articles":91},"react","React",10,{"slug":93,"name":94,"articles":51},"browser","Browser",{"slug":96,"name":97,"articles":51},"node","Node",{"slug":99,"name":100,"articles":101},"grammar","Grammar",6,{"slug":103,"name":104,"articles":105},"html","Html",5,{"id":107,"slug":46,"title":108,"subtitle":52,"excerpt":109,"coverUrl":110,"locale":13,"readingMinutes":105,"publishedAt":111,"viewCount":112,"likeCount":19,"commentCount":19,"author":113,"vertical":118,"topic":119,"tags":121,"_count":128,"playground":130,"body":132,"bodyMd":281,"seo":282,"translationGroupId":285,"series":52,"podcastUrl":52,"verticalId":5,"thread":286,"assessments":288,"translations":291,"quiz":293},"01a0764b-4844-732e-99fe-3e0122793752","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-11T10:56:58.379Z",34,{"id":114,"name":115,"username":116,"avatarUrl":52,"headline":117},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":120},"JavaScript",[122,123,124,125],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":96,"name":97,"color":52},{"slug":126,"name":127,"color":52},"programming","Programming",{"assessments":129},1,{"slug":46,"title":131},"Promise.try() — interactive playground",{"blocks":133,"version":129},[134,138,141,147,150,155,158,161,164,167,170,173,177,180,184,187,191,194,198,201,204,207,211,214,217,220,223,226,229,232,235,238,241,244,247,250,253,256,259,262,265,268,271,274],{"id":135,"html":136,"type":137},"b1","\u003Cp>Sentry paged me at 2am for an unhandled exception in a function that had a \u003Ccode>.catch()\u003C\u002Fcode> on every single call site. I read the code three times before 2:15 and still couldn&#39;t find how it got past. The error wasn&#39;t hiding in the \u003Ccode>.catch()\u003C\u002Fcode>. It was two lines above it, in code that looked completely unremarkable.\u003C\u002Fp>","paragraph",{"id":139,"html":140,"type":137},"b2","\u003Cp>Here&#39;s the shape of it, trimmed to the part that matters:\u003C\u002Fp>",{"id":142,"code":143,"type":144,"language":145,"highlight":146},"b3","function getUser(id) {\n  if (typeof id !== \"number\") {\n    throw new TypeError(`id must be a number, got ${typeof id}`);\n  }\n  return fetch(`\u002Fapi\u002Fusers\u002F${id}`).then((r) => r.json());\n}\n\ngetUser(userId)\n  .then(renderProfile)\n  .catch(reportError);","code","js",[],{"id":148,"html":149,"type":137},"b4","\u003Cp>Somewhere upstream, \u003Ccode>userId\u003C\u002Fcode> had come through as \u003Ccode>undefined\u003C\u002Fcode>. \u003Ccode>getUser\u003C\u002Fcode> did exactly what it was written to do — it threw. And the page crashed anyway, \u003Ccode>reportError\u003C\u002Fcode> never called, like the \u003Ccode>.catch()\u003C\u002Fcode> wasn&#39;t even there.\u003C\u002Fp>",{"id":151,"html":152,"text":153,"type":154,"level":31},"b5","Before you scroll — where&#39;s the bug?","Before you scroll — where's the bug?","heading",{"id":156,"html":157,"type":137},"b6","\u003Cp>Take a second. The \u003Ccode>.catch()\u003C\u002Fcode> is right there, chained to the call. What did I miss?\u003C\u002Fp>",{"id":159,"html":160,"type":137},"b7","\u003Cp>Here&#39;s the part that took me embarrassingly long to see: \u003Ccode>getUser\u003C\u002Fcode> is a plain function, not an \u003Ccode>async\u003C\u002Fcode> one. When you write \u003Ccode>getUser(userId)\u003C\u002Fcode>, JavaScript runs the entire body of \u003Ccode>getUser\u003C\u002Fcode> \u003Cstrong>synchronously\u003C\u002Fstrong>, top to bottom, before it hands anything back to the caller. If \u003Ccode>id\u003C\u002Fcode> is bad, the \u003Ccode>throw\u003C\u002Fcode> fires during that synchronous run — before \u003Ccode>getUser\u003C\u002Fcode> has returned anything, including before it&#39;s returned a promise.\u003C\u002Fp>",{"id":162,"html":163,"type":137},"b8","\u003Cp>\u003Ccode>.then()\u003C\u002Fcode> and \u003Ccode>.catch()\u003C\u002Fcode> are methods on a promise \u003Cem>object\u003C\u002Fem>. No promise object ever got created. There was nothing for \u003Ccode>.catch()\u003C\u002Fcode> to attach to, because the line \u003Ccode>getUser(userId).then(...).catch(...)\u003C\u002Fcode> never finished evaluating \u003Ccode>getUser(userId)\u003C\u002Fcode> in the first place. The exception just does what exceptions do in synchronous code: it unwinds the call stack until something catches it. Nothing did.\u003C\u002Fp>",{"id":165,"html":166,"type":137},"b9","\u003Cp>The unsettling bit is that \u003Ccode>getUser\u003C\u002Fcode> works fine 99% of the time. When \u003Ccode>id\u003C\u002Fcode> is valid, it returns a real promise from \u003Ccode>fetch(...).then(...)\u003C\u002Fcode>, and the \u003Ccode>.catch()\u003C\u002Fcode> chained after it works exactly as expected. The bug only shows up on the one path that skips the \u003Ccode>return\u003C\u002Fcode> and throws instead — which is exactly the path most likely to be a bad input nobody tested.\u003C\u002Fp>",{"id":168,"html":169,"text":169,"type":154,"level":31},"b10","The fix everyone reaches for first",{"id":171,"html":172,"type":137},"b11","\u003Cp>The obvious patch is to wrap the \u003Cem>call site\u003C\u002Fem>:\u003C\u002Fp>",{"id":174,"code":175,"type":144,"language":145,"highlight":176},"b12","try {\n  getUser(userId).then(renderProfile).catch(reportError);\n} catch (err) {\n  reportError(err);\n}",[],{"id":178,"html":179,"type":137},"b13","\u003Cp>This works. It also means \u003Ccode>reportError\u003C\u002Fcode> now has two separate roads leading to it, and every place that calls \u003Ccode>getUser\u003C\u002Fcode> needs the same wrapper or it&#39;s back to square one. I found three other call sites that didn&#39;t have it.\u003C\u002Fp>",{"id":181,"html":182,"text":183,"type":154,"level":31},"b14","If you own the function: make it \u003Ccode>async\u003C\u002Fcode>","If you own the function: make it async",{"id":185,"html":186,"type":137},"b15","\u003Cp>The clean fix, when you can change \u003Ccode>getUser\u003C\u002Fcode> itself, is one keyword:\u003C\u002Fp>",{"id":188,"code":189,"type":144,"language":145,"highlight":190},"b16","async function getUser(id) {\n  if (typeof id !== \"number\") {\n    throw new TypeError(`id must be a number, got ${typeof id}`);\n  }\n  return fetch(`\u002Fapi\u002Fusers\u002F${id}`).then((r) => r.json());\n}",[],{"id":192,"html":193,"type":137},"b17","\u003Cp>An \u003Ccode>async\u003C\u002Fcode> function \u003Cem>always\u003C\u002Fem> returns a promise, full stop — and a synchronous \u003Ccode>throw\u003C\u002Fcode> inside it is automatically converted into a rejection of that promise instead of an exception that escapes to the caller. Now every call site&#39;s \u003Ccode>.catch()\u003C\u002Fcode> works, with zero changes at the call site. This is genuinely the right fix when the function is yours.\u003C\u002Fp>",{"id":195,"html":196,"text":197,"type":154,"level":31},"b18","When it isn&#39;t yours","When it isn't yours",{"id":199,"html":200,"type":137},"b19","\u003Cp>The case that actually bit me: \u003Ccode>getUser\u003C\u002Fcode> came from a small internal library, and I didn&#39;t want to fork it just to add \u003Ccode>async\u003C\u002Fcode>. More generally — you&#39;re wrapping a callback someone handed you, a plugin hook, an event handler — something that \u003Cem>might\u003C\u002Fem> throw synchronously, \u003Cem>might\u003C\u002Fem> return a rejected promise, and you don&#39;t control which, and you can&#39;t turn it into an \u003Ccode>async function\u003C\u002Fcode> because you don&#39;t own its source.\u003C\u002Fp>",{"id":202,"html":203,"type":137},"b20","\u003Cp>The old trick for this is \u003Ccode>Promise.resolve().then(() =&gt; fn(...args))\u003C\u002Fcode> — running \u003Ccode>fn\u003C\u002Fcode> inside a \u003Ccode>.then\u003C\u002Fcode> callback so any synchronous throw becomes a rejection instead of an escaped exception. It works. It also reads like a no-op if you don&#39;t already know the idiom, and it costs an extra microtask turn before \u003Ccode>fn\u003C\u002Fcode> even starts.\u003C\u002Fp>",{"id":205,"html":206,"type":137},"b21","\u003Cp>\u003Ccode>Promise.try()\u003C\u002Fcode> is that idiom, built in and named for what it does:\u003C\u002Fp>",{"id":208,"code":209,"type":144,"language":145,"highlight":210},"b22","Promise.try(getUser, userId)\n  .then(renderProfile)\n  .catch(reportError);",[],{"id":212,"html":213,"type":137},"b23","\u003Cp>\u003Ccode>Promise.try(fn, ...args)\u003C\u002Fcode> calls \u003Ccode>fn\u003C\u002Fcode> immediately with the given arguments and always hands back a promise: resolved with whatever \u003Ccode>fn\u003C\u002Fcode> returned, chained to it if \u003Ccode>fn\u003C\u002Fcode> returned a promise, or — the part that matters here — rejected with whatever \u003Ccode>fn\u003C\u002Fcode> threw. Now every path through \u003Ccode>getUser\u003C\u002Fcode> reaches \u003Ccode>reportError\u003C\u002Fcode> through the exact same \u003Ccode>.catch()\u003C\u002Fcode>, no \u003Ccode>try\u002Fcatch\u003C\u002Fcode> duplication, no library fork.\u003C\u002Fp>",{"id":215,"html":216,"type":137},"b24","\u003C!-- playground:start -->",{"id":218,"html":219,"text":219,"type":154,"level":31},"b25","🎮 Try it yourself",{"id":221,"html":222,"type":137},"b26","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpromise-try-synchronous-throw-safety\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":224,"html":225,"type":137},"b27","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":227,"html":228,"type":137},"b28","\u003C!-- playground:end -->",{"id":230,"html":231,"text":231,"type":154,"level":31},"b29","The detail both old workarounds get slightly wrong",{"id":233,"html":234,"type":137},"b30","\u003Cp>Here&#39;s the one thing \u003Ccode>Promise.try\u003C\u002Fcode> does that neither \u003Ccode>try\u002Fcatch\u003C\u002Fcode> around the call site nor the \u003Ccode>Promise.resolve().then(fn)\u003C\u002Fcode> hack quite matches: \u003Cstrong>timing\u003C\u002Fstrong>.\u003C\u002Fp>",{"id":236,"html":237,"type":137},"b31","\u003Cp>\u003Ccode>Promise.resolve().then(() =&gt; fn())\u003C\u002Fcode> doesn&#39;t run \u003Ccode>fn\u003C\u002Fcode> in the same tick as the call — \u003Ccode>.then()\u003C\u002Fcode> always schedules its callback as a microtask, so \u003Ccode>fn\u003C\u002Fcode> starts running \u003Cem>after\u003C\u002Fem> the current synchronous code finishes, even on the success path. \u003Ccode>Promise.try(fn)\u003C\u002Fcode> calls \u003Ccode>fn\u003C\u002Fcode> synchronously, right when you call it — the same contract an \u003Ccode>async function\u003C\u002Fcode>&#39;s body has before its first \u003Ccode>await\u003C\u002Fcode>. If \u003Ccode>fn\u003C\u002Fcode> does something time-sensitive before it returns (kicks off a synchronous side effect, reads something off \u003Ccode>document.activeElement\u003C\u002Fcode>, starts a timer), that now happens exactly when you called it, not one microtask later. Small, and easy to not care about — until you&#39;re debugging why something that &quot;obviously&quot; runs first actually ran second.\u003C\u002Fp>",{"id":239,"html":240,"type":137},"b32","\u003Cp>\u003Ccode>Promise.try\u003C\u002Fcode> reached Baseline in 2025 and by now runs in current Chrome, Firefox, and Safari, plus Node 22 and newer, without a polyfill.\u003C\u002Fp>",{"id":242,"html":243,"text":243,"type":154,"level":31},"b33","When the old way is still fine",{"id":245,"html":246,"type":137},"b34","\u003Cp>If you own the function, keep using \u003Ccode>async\u003C\u002Fcode> — it&#39;s clearer to the next person reading it than a \u003Ccode>Promise.try\u003C\u002Fcode> wrapper at every call site. Reach for \u003Ccode>Promise.try\u003C\u002Fcode> specifically at the boundary: wrapping something you \u003Cem>didn&#39;t\u003C\u002Fem> write and can&#39;t safely assume is async-shaped.\u003C\u002Fp>",{"id":248,"html":249,"type":137},"b35","\u003C!-- quiz:start -->",{"id":251,"html":252,"text":252,"type":154,"level":31},"b36","🧠 Test yourself",{"id":254,"html":255,"type":137},"b37","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpromise-try-synchronous-throw-safety\u002Fquiz\">Take the 7-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":257,"html":258,"type":137},"b38","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":260,"html":261,"type":137},"b39","\u003C!-- quiz:end -->",{"id":263,"html":264,"type":137},"b40","\u003Cp>The \u003Ccode>.catch()\u003C\u002Fcode> didn&#39;t fail me at 2am. I had, three months earlier, when I wrote a function that wasn&#39;t safe to chain in the first place. If you&#39;ve got five minutes, grep your own codebase for a non-\u003Ccode>async\u003C\u002Fcode> function that both \u003Ccode>return\u003C\u002Fcode>s a promise and can \u003Ccode>throw\u003C\u002Fcode> before it gets there — I&#39;d bet you find at least one. What did yours look like?\u003C\u002Fp>",{"id":266,"type":267},"b41","divider",{"id":269,"html":270,"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":272,"html":273,"type":137},"b43","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":275,"type":276,"items":277,"ordered":18},"b44","list",[278,279,280],"⭐ \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>","Sentry paged me at 2am for an unhandled exception in a function that had a `.catch()` on every single call site. I read the code three times before 2:15 and still couldn't find how it got past. The error wasn't hiding in the `.catch()`. It was two lines above it, in code that looked completely unremarkable.\n\nHere's the shape of it, trimmed to the part that matters:\n\n```js\nfunction getUser(id) {\n  if (typeof id !== \"number\") {\n    throw new TypeError(`id must be a number, got ${typeof id}`);\n  }\n  return fetch(`\u002Fapi\u002Fusers\u002F${id}`).then((r) => r.json());\n}\n\ngetUser(userId)\n  .then(renderProfile)\n  .catch(reportError);\n```\n\nSomewhere upstream, `userId` had come through as `undefined`. `getUser` did exactly what it was written to do — it threw. And the page crashed anyway, `reportError` never called, like the `.catch()` wasn't even there.\n\n## Before you scroll — where's the bug?\n\nTake a second. The `.catch()` is right there, chained to the call. What did I miss?\n\nHere's the part that took me embarrassingly long to see: `getUser` is a plain function, not an `async` one. When you write `getUser(userId)`, JavaScript runs the entire body of `getUser` **synchronously**, top to bottom, before it hands anything back to the caller. If `id` is bad, the `throw` fires during that synchronous run — before `getUser` has returned anything, including before it's returned a promise.\n\n`.then()` and `.catch()` are methods on a promise *object*. No promise object ever got created. There was nothing for `.catch()` to attach to, because the line `getUser(userId).then(...).catch(...)` never finished evaluating `getUser(userId)` in the first place. The exception just does what exceptions do in synchronous code: it unwinds the call stack until something catches it. Nothing did.\n\nThe unsettling bit is that `getUser` works fine 99% of the time. When `id` is valid, it returns a real promise from `fetch(...).then(...)`, and the `.catch()` chained after it works exactly as expected. The bug only shows up on the one path that skips the `return` and throws instead — which is exactly the path most likely to be a bad input nobody tested.\n\n## The fix everyone reaches for first\n\nThe obvious patch is to wrap the *call site*:\n\n```js\ntry {\n  getUser(userId).then(renderProfile).catch(reportError);\n} catch (err) {\n  reportError(err);\n}\n```\n\nThis works. It also means `reportError` now has two separate roads leading to it, and every place that calls `getUser` needs the same wrapper or it's back to square one. I found three other call sites that didn't have it.\n\n## If you own the function: make it `async`\n\nThe clean fix, when you can change `getUser` itself, is one keyword:\n\n```js\nasync function getUser(id) {\n  if (typeof id !== \"number\") {\n    throw new TypeError(`id must be a number, got ${typeof id}`);\n  }\n  return fetch(`\u002Fapi\u002Fusers\u002F${id}`).then((r) => r.json());\n}\n```\n\nAn `async` function *always* returns a promise, full stop — and a synchronous `throw` inside it is automatically converted into a rejection of that promise instead of an exception that escapes to the caller. Now every call site's `.catch()` works, with zero changes at the call site. This is genuinely the right fix when the function is yours.\n\n## When it isn't yours\n\nThe case that actually bit me: `getUser` came from a small internal library, and I didn't want to fork it just to add `async`. More generally — you're wrapping a callback someone handed you, a plugin hook, an event handler — something that *might* throw synchronously, *might* return a rejected promise, and you don't control which, and you can't turn it into an `async function` because you don't own its source.\n\nThe old trick for this is `Promise.resolve().then(() => fn(...args))` — running `fn` inside a `.then` callback so any synchronous throw becomes a rejection instead of an escaped exception. It works. It also reads like a no-op if you don't already know the idiom, and it costs an extra microtask turn before `fn` even starts.\n\n`Promise.try()` is that idiom, built in and named for what it does:\n\n```js\nPromise.try(getUser, userId)\n  .then(renderProfile)\n  .catch(reportError);\n```\n\n`Promise.try(fn, ...args)` calls `fn` immediately with the given arguments and always hands back a promise: resolved with whatever `fn` returned, chained to it if `fn` returned a promise, or — the part that matters here — rejected with whatever `fn` threw. Now every path through `getUser` reaches `reportError` through the exact same `.catch()`, no `try\u002Fcatch` duplication, no library fork.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpromise-try-synchronous-throw-safety\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## The detail both old workarounds get slightly wrong\n\nHere's the one thing `Promise.try` does that neither `try\u002Fcatch` around the call site nor the `Promise.resolve().then(fn)` hack quite matches: **timing**.\n\n`Promise.resolve().then(() => fn())` doesn't run `fn` in the same tick as the call — `.then()` always schedules its callback as a microtask, so `fn` starts running *after* the current synchronous code finishes, even on the success path. `Promise.try(fn)` calls `fn` synchronously, right when you call it — the same contract an `async function`'s body has before its first `await`. If `fn` does something time-sensitive before it returns (kicks off a synchronous side effect, reads something off `document.activeElement`, starts a timer), that now happens exactly when you called it, not one microtask later. Small, and easy to not care about — until you're debugging why something that \"obviously\" runs first actually ran second.\n\n`Promise.try` reached Baseline in 2025 and by now runs in current Chrome, Firefox, and Safari, plus Node 22 and newer, without a polyfill.\n\n## When the old way is still fine\n\nIf you own the function, keep using `async` — it's clearer to the next person reading it than a `Promise.try` wrapper at every call site. Reach for `Promise.try` specifically at the boundary: wrapping something you *didn't* write and can't safely assume is async-shaped.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 7-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpromise-try-synchronous-throw-safety\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\nThe `.catch()` didn't fail me at 2am. I had, three months earlier, when I wrote a function that wasn't safe to chain in the first place. If you've got five minutes, grep your own codebase for a non-`async` function that both `return`s a promise and can `throw` before it gets there — I'd bet you find at least one. What did yours look like?\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":108,"canonical":283,"description":284},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fpromise-try-synchronous-throw-safety","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 i","01a0764b-4844-732e-99fe-419eee5c7af5",{"id":287,"locked":18},"01a0764b-4897-7080-9687-48d1c128b488",[289],{"id":45,"slug":46,"title":48,"_count":290},{"questions":51},[292],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":294,"questionCount":51},{"questions":51}]