JavaScript Intermediate: async, fetch, modules, and real-world patterns

Learn to Code · August 26, 2026
JavaScript Intermediate: async, fetch, modules, and real-world patterns

You finished the beginner's guide. You can put a button on a page, listen for clicks, and update text. That is real — that is actual JavaScript, the same language professional developers use every day.



Now it is time to learn the four tools that separate "I can write a script" from "I can build a real app": async/await, fetch, ES modules, and the small handful of patterns you'll use constantly. By the end of this article, you'll be able to load data from a server, structure a real JavaScript project, and handle the trickiest common scenarios — without ever writing a callback pyramid.



The problem with callbacks



You know how to do things one at a time. Read a variable, do some math, log a string. JavaScript runs line by line, top to bottom. Synchronous. Easy.



But some operations take time. Fetching a file from the internet. Reading from a database. Waiting for a timer. If JavaScript just stopped and waited for each one, every page on the web would feel like a 1990s dial-up connection.



So JavaScript does something clever. It says, "Start this slow operation. When it's done, run this function." That "this function" is called a callback. The old way of writing async code looks like this:



setTimeout(() => {
console.log("first");
setTimeout(() => {
console.log("second");
setTimeout(() => {
console.log("third");
}, 1000);
}, 1000);
}, 1000);


See the shape? Each step nests inside the previous one. The deeper you go, the more your code leans to the right. This is callback hell, and it is why older JavaScript code was hard to read.



Promises: a better way to say "later"



A Promise is an object that represents a value that doesn't exist yet but will, eventually. It can be pending (still waiting), fulfilled (it worked, here's the value), or rejected (it failed, here's why).



When you call fetch(), you get back a Promise. You chain .then() on it for success and .catch() for failure:



fetch('/api/posts')
.then(response => response.json())
.then(posts => console.log(posts))
.catch(error => console.error('oops', error));


Cleaner than nesting. Each .then is a step. Errors get caught in one place at the bottom. Still a bit ceremonial, though.



async / await: code that reads like sync



The modern way. Add the word async to a function, and you can use await inside it to "pause" until a Promise resolves. The function itself returns a Promise.



async function loadPosts() {
try {
const response = await fetch('/api/posts');
const posts = await response.json();
console.log(posts);
} catch (error) {
console.error('oops', error);
}
}

loadPosts();


Look at that. It reads top to bottom, like a normal recipe. The await keyword is the magic — it tells JavaScript "wait here until this finishes, then continue." Under the hood, it's still using Promises. await is just a prettier way to write the same thing.



Rule of thumb: if you find yourself writing .then().then().then() more than two levels deep, switch to async/await. Your future self will thank you.


fetch: actually talking to a server



fetch is a built-in browser function for making HTTP requests. You give it a URL. It gives you back a Promise. By default it makes a GET request.



GET — read some data



async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
const user = await res.json();
return user;
}


POST — send some data



async function createUser(name, email) {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email })
});
return res.json();
}


The pattern is the same: send a request, await the response, parse the JSON. The only thing that changes is the method, the headers, and the body.



Always handle errors



fetch only rejects on network failures, not on HTTP errors. A 404 or 500 response is still a "successful" Promise. Check res.ok:



const res = await fetch('/api/posts');
if (!res.ok) throw new Error('HTTP ' + res.status);
const posts = await res.json();


ES Modules: splitting code into files



Once you have more than a few hundred lines of JavaScript, putting it all in one file stops working. ES Modules let you split it into pieces and import what you need.



In a file called utils.js:



export function formatDate(date) {
return new Date(date).toLocaleDateString();
}

export const PI = 3.14159;


In another file, app.js:



import { formatDate, PI } from './utils.js';

console.log(formatDate('2026-07-25'));
console.log(PI);


Each file is a module. export makes something available. import brings it in. To use modules in a browser, your <script> tag needs type="module":



<script type="module" src="app.js"></script>


The patterns you'll use every day



1. Loading state



When you fetch data, there's a moment where the user is waiting. Show them that:



async function load() {
showSpinner();
try {
const data = await fetch('/api/...').then(r => r.json());
render(data);
} catch (err) {
showError(err);
} finally {
hideSpinner();
}
}


The finally block runs no matter what — success or failure — which is perfect for hiding the spinner.



2. Run several things in parallel



Sometimes you need data from multiple endpoints. Don't await them one by one — that's slow. Run them in parallel with Promise.all:



const [user, posts, comments] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/comments').then(r => r.json())
]);


All three requests fire at the same time. The total wait is the slowest one, not the sum.



3. Debouncing — "wait until they stop typing"



When the user types in a search box, you don't want to fetch on every keystroke. Wait until they pause:



function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}

const search = debounce(async (query) => {
const res = await fetch(`/api/search?q=${query}`);
const results = await res.json();
render(results);
}, 300);

input.addEventListener('input', e => search(e.target.value));


Now the search only runs 300 milliseconds after the user stops typing. Smooth, efficient, professional.



What's next



You now know the four big tools: async/await for sequential async work, fetch for talking to servers, ES modules for organizing code, and a small set of patterns for the common cases. That's enough to build a real, modern, data-driven web app.



From here, the natural next steps are:




  • Build a tiny project. A weather widget that fetches from a public API. A todo list that saves to localStorage. A search box with debouncing. Pick one. Ship it.

  • Learn a framework. React, Vue, Svelte — all three do the same job (organize a big JavaScript app into reusable components). Pick one and stick with it for a month.

  • Read other people's code. Open the DevTools Sources tab on a website you like. Read their JavaScript. Steal patterns. Adapt them. That's how you level up.



You are no longer a beginner. You are a JavaScript developer who happens to be early in the journey. Welcome to the club.


← More in Learn to Code