Real-world insights for sharper web dev decisions Advertise with Us|Sign Up to the Newsletter @media only screen and (max-width: 100%;} #pad-desktop {display: none !important;} } @media only screen and (max-width: 100%;} #pad-desktop {display: none !important;} } WebDevPro #119 Modern Async JavaScript, without the knots Real-world insights for sharper web dev decisions AI-Powered Development with Cursor Workshop Accelerate your coding workflow and ship apps faster with Cursor, Supabase, and OpenAI. Book Your Seat 🗓️Nov 29, 2025 | 8:30 AM – 12:30 PM (ET) | 💻 Virtual 🎟️ 40% OFF with codeCURSOR40+ 2 FREE e-books Fill This Form to Claim Your FREE E-books! Thanks for joining me for this week’s WebDevPro issue! Most things that feel slow in a web app are not actually slow. They are waiting. A request is in flight. A timer has not fired yet. The user has not clicked. Async JavaScript is how you keep the page responsive while that waiting happens. Once you see how the browser schedules work, how promises settle, and how await resumes your function, you stop guessing. Your UI starts to feel calm because your code is calm. This week’s deep dive is based on material from JavaScript from Beginner to Professional by Laurence Lars Svekis, Maaike van Putten, and Rob Percival. This book is a practical guide that breaks down modern JavaScript fundamentals with clarity. and the concepts discussed here draw from this book. Let’s start with the mental model, then build toward patterns that hold up when real-world complexity enters the picture. By the end, it becomes much easier to see what can run in parallel, what must be sequenced, what deserves a timeout, and what should be canceled the moment the user moves on. But before we get into it, here are the standout links from last week’s edition: 🪄Learn JavaScript by building Mario 🛡️ OWASP Top 10 2025 RC1 highlights emerging web security risks 📊 GitHub Octoverse 2025 🔬 Why TypeScript leads the AI era of development Have any ideas you want to see in the next article? Hit Reply! Advertise with us Interested in reaching our audience? Reply to this email or write to kinnaric@packt.com. Learn more about our sponsorship opportunities here. The event loop is the heartbeat JavaScript in the browser runs on a single thread. You do not spin up operating system threads. You schedule work that will continue later. The browser owns an event loop. It pulls tasks off queues and runs your callbacks when results are ready. There are two queues you feel every day. The task queue holds timers and I O completions. The microtask queue holds promise reactions. The loop runs a task, then drains microtasks, then gives the browser a chance to paint. That one detail explains a lot of puzzling behavior. A continuation after await is a microtask. A callback from setTimeout(fn, 0) is a task. The microtask wins the race. You can prove it with a tiny console exercise and watch the order of logs line up with the model. console.log("A: start"); Promise.resolve().then(() => console.log("B: microtask")); setTimeout(() => console.log("C: task"), 0); (async () => { console.log("D: before await"); await null; // schedules a microtask continuation console.log("E: after await"); })(); console.log("F: end"); // typical order: A, D, F, B, E, C If the page ever feels sticky, it usually means you blocked the main thread. The cure is not a trick. Break heavy work into small pieces, yield between pieces, and let the browser breathe. Promises are a contract, not a mystery A promise represents a later value or a reason for failure. It starts pending, then becomes fulfilled or rejected, and it never changes again. You do not poll a promise. You register reactions and the runtime calls you exactly once when it settles. Chaining works because .then returns a new promise that adopts what you return. If you return a plain value, the chain moves forward with that value. If you return a promise, the chain waits. If you throw, the chain jumps to the next rejection handler. Many head-scratching bugs come from forgetting to return the inner promise and letting a chain run ahead of the work. The fix is simple. If you start more async work inside a handler, return it. Make error handling part of the design. Decide where errors become messages and where they become quiet fallbacks. Unhandled rejections will shout in devtools, but your users need a calmer story. Async and await help you read what you wrote async and await do not change promise semantics. They make the choreography readable. An async function always returns a promise. await yields inside that function until a promise settles, then either gives you the value or throws the error. You get the same behavior as a well-written chain, but in the order your brain tells the story. Keep try and catch around logical chunks, not the entire function unless it truly is one unit. Put cleanup in finally so it runs whether you succeeded or failed. That is where you stop spinners, release locks, and finish metrics. Here is a compact pattern for network work that needs clear outcomes and guaranteed cleanup. async function loadProfile(id, { signal } = {}) { startSpinner(); try { const res = await fetch(`/api/profile/${id}`, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { return { error: true, message: err.message }; } finally { stopSpinner(); } } One pitfall to keep in mind. array.map(async item => ...) gives you an array of promises. If you want the values, await them as a group. If the steps depend on each other, resist cleverness and write them in sequence. Orchestrating what overlaps and what depends A calm UI often comes down to honest orchestration. Some work can run together. Some must wait. If you are fetching user data, preferences, and notifications, start them together and join them when all are ready. If you are logging in and need the token before fetching a profile, keep that flow in sequence so the dependency is clear. For batches where one failure should not sink the rest, gather everything and inspect the outcomes after. For timing concerns, compose a timeout without twisting the control flow. function withTimeout(promise, ms) { return Promise.race([ promise, new Promise((_, rej) => setTimeout(() => rej(new Error("Timeout")), ms)) ]); } These helpers do not remove thinking. They encode intent so the code reads like the plan you had in your head. Cancellation that matches how people behave People change their minds. They type again. They navigate away. If a search response from an old query lands after the new one, it can overwrite fresh state with stale data. Treat cancel as a normal outcome. The platform gives you AbortController and AbortSignal. Wire them in and clear the pending state when the user moves on. let current = 0; async function search(q) { const id = ++current; const controller = new AbortController(); const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal }); const data = await res.json(); if (id !== current) return; // stale result, drop it renderResults(data); } Add a simple guard like that and you avoid ghost updates even if some lower level cannot be canceled. Do not toast an error when the user intentionally cancels. Just settle the UI and let them keep going. Concurrency limits and backpressure Starting twenty operations at once feels bold until your server pushes back and your tab stutters. A small limiter gives you backpressure. Allow a fixed number of active operations. Queue the rest. When one finishes, start the next. Median times improve. Tails tighten. Rate limits hurt less. You can write a limiter in a handful of lines or use a tiny utility if your team prefers. The key is to adopt the habit on the paths that matter. If you are streaming results into the DOM, apply the same idea. Do not reflow the page for every tiny chunk. Buffer a few and render on a steady rhythm so the browser can paint between updates. Streams and async iteration when a spinner is not enough Sometimes the right move is to show progress as it happens. Users prefer a trickle of real content over a long spinner. Async iterators make this clean. Loop over incoming chunks, render slices as they arrive, and the page feels alive. Pair it with a skeleton screen so the shape of the UI is visible from the start. Your content grows into a believable frame rather than jumping from nothing to finished. Wiring the UI so async behaves Most async work starts with an event. A click, a keypress, an input change. If you wire listeners at the right time and understand how events travel through the DOM, your async code stops fighting the page. The figure on the left shows bubbling: the event fires on the target, then moves outward through its ancestors. On the right, the figure shows capturing: the event moves in from the outer ancestor toward the target before the usual bubbling phase. Watch the order of the console messages. That order is the lesson. A normal listener gives you inner to outer. A capture listener gives you outer to inner. This is why delegation works so well. One listener on a stable parent can hear clicks from many dynamic children. Attach listeners when elements exist. DOMContentLoaded is the sweet spot. If you want to intercept early, register a capture listener. If you want one place to handle many items, register a bubbling listener on a parent and let events rise to it. Here is a tiny delegation pattern that stays out of the way: document.addEventListener("DOMContentLoaded", () => { const list = document.querySelector("#todo-list"); list.addEventListener("click", async (e) => { const btn = e.target.closest("[data-action='delete']"); if (!btn) return; try { const id = btn.closest("li").dataset.id; await fetch(`/api/todos/${id}`, { method: "DELETE" }); btn.closest("li").remove(); } catch (err) { alert(err.message); } }); }); Two controls to remember as you read the figures. event.preventDefault() stops the browser’s default action. event.stopPropagation() stops the journey through the tree. They solve different problems. Use them on purpose, not by habit. Debugging without guesswork There is a fast way to get unstuck. Put a breakpoint on the await line that surprises you. Step once. Watch local variables. Watch the call stack shift when the continuation runs. Put a breakpoint in the catch you believe is responsible for that block of work and confirm that it fires. Verify that it finally runs every time. Ten minutes of that beats an hour of logs. When a flow does something odd, ask three questions. Did the handler attach at the time I thought it did? Did the promise I awaited actually reject, or did it resolve to an error-shaped value? Did my cleanup run? You can answer those in the debugger faster than you can by guessing. Make it feel fast, not just be fast People judge speed by responsiveness. Start optimistic updates where it is safe and reconcile with the server when the result arrives. Keep event handlers small so the browser can paint and react to the next input. Defer non-critical work with requestAnimationFrame or a microtask. If you need heavy computation, hand it to a worker so the main thread stays clear. The goal is not a perfect number on a benchmark. The goal is a page that feels alive even on a noisy network and a mid-range phone. A short wrap so the ideas stick Promises are the contract. async and await make the contract readable. The event loop sets the rhythm and your code decides what runs together and what must wait. Add cancel so old results never clobber new ones. Add a gentle limiter so nothing overwhelms anything else. Add retries with timeouts so failures are handled on purpose. Wire events at the right time and let delegation carry some of the weight. Debug at the await points. If you keep these habits, your app will feel composed on a good day and resilient on a bad one. If you want to go deeper into these foundations, many of the concepts in this week’s issue come from JavaScript from Beginner to Professional. It’s a solid reference to keep on your shelf, or your Kindle! Cheers! Editor-in-chief, Kinnari Chohan Got 60 seconds? Tell us what clicked (or didn’t) SUBSCRIBE FOR MORE AND SHARE IT WITH A FRIEND! *{box-sizing:border-box}body{margin:0;padding:0}a[x-apple-data-detectors]{color:inherit!important;text-decoration:inherit!important}#MessageViewBody a{color:inherit;text-decoration:none}p{line-height:inherit}.desktop_hide,.desktop_hide table{mso-hide:all;display:none;max-height:0;overflow:hidden}.image_block img+div{display:none}sub,sup{font-size:75%;line-height:0}#converted-body .list_block ol,#converted-body .list_block ul,.body [class~=x_list_block] ol,.body [class~=x_list_block] ul,u+.body .list_block ol,u+.body .list_block ul{padding-left:20px} @media (max-width: 100%;display:block}.mobile_hide{min-height:0;max-height:0;max-width: 100%;overflow:hidden;font-size:0}.desktop_hide,.desktop_hide table{display:table!important;max-height:none!important}} @media only screen and (max-width: 100%;} #pad-desktop {display: none !important;} } @media only screen and (max-width: 100%;} #pad-desktop {display: none !important;} }
Read more