Asynchronous JS

JS has only one thread, so it cannot handle multiple tasks at the same time. But thanks to the non-blocking behaviour, we can carry on to the next task before the last task is finished, this is Asynchronous Javascript.

HTTP/HTTPS

When making communication with a server, we use HTTP/HTTPS requests.

What happens when we make an HTTP/HTTPS request:

  1. Parse URL and check cache: to make a request we need to know the url of the server, so we need to parse the URL(https://example.com:443/path?query=foo#hash ) into:

    • protocol: https/http

    • host: example.com

    • port: 443 (default for HTTPS) or 80 (default for HTTP)

    • path: /path

    • query string: ?query=foo

  2. DNS(Domain Name System) lookup: using DNS to get the server’s ip address.

  3. TCP and three-way handshake: SYN SYN-ACK ACK

  4. TLS handshake(optional): for HTTPS only, to encrypt the request.

  5. Send request

  6. Server receive and process request

  7. Server return a response to be parsed by browser

The key here is to send and receive the HTTP/HTTPS request via the browser.

So what is inside the request and the server’s response?

Differ domain. ip address and URL:

  • IP Address: The real numeric address of the server
  • Domain: The human-readable name mapped to an ip address. The mapping is stored in DNS server.
  • URL(Uniform Resource Locator): The complete address including protocol, domain, path, etc.

HTTP Request

Inside the HTTP request we have:

  • Request Line: This is the first line of the request, including:

    • Request Method: GET/POST/DELETE/PATCH/PUT/…
    • Path/URL
    • Version: HTTP/HTTPS version

    eg: GET /search?q=monash HTTP/1.1

  • Request Headers: give the server context and metadata. Common headers are:

    • Host: tell the server which domain you want to search, as most server hosts multiple domains.
    • User-agent: Give information on your browser, your operating system, etc.
    • Accept: Tells the server what format your browser is willing to accept. Like JSON/image/HTML/…
    • Authorization: Provide information on who you are. Like API keys.
  • Request Body: Optional. Usually PUT/POST/PATCH requests have request body.

    • It may contain form data, JSON files or other file uploads.

HTTP Response

A HTTP Response is usually consists of:

  • Status line: HTTP/1.1 200 OK containing:
    • HTTP/HTTPS Version
    • Status Code: Success/Redirect/Server Error/Client Error
    • Status Text: human-readable text, Like 200 OK

Status Codes:

2xx — Success

Code Meaning
200 OK Standard success
201 Created New resource created (POST)
204 No Content Success, but no body

3xx — Redirects

Code Meaning
301 Moved Permanently Permanent redirect
302 Found Temporary redirect
304 Not Modified Browser can use cache

4xx — Client Errors

Code Meaning
400 Bad Request Invalid request (wrong syntax)
401 Unauthorized Need login token
403 Forbidden You are not allowed
404 Not Found Page doesn’t exist
429 Too Many Requests Rate limit

5xx — Server Errors

Code Meaning
500 Internal Server Error Server crashed
502 Bad Gateway Reverse proxy problem
503 Service Unavailable Server temporarily down
504 Gateway Timeout Upstream server took too long
  • Response Headers: Headers give metadata about the response, including content information, cache control, authentication, etc.
  • Response Body: Optional. contains real content, including file response, JSON API response, or empty response for 204 No Content

AJAX (Asynchronous JS and XML)

AJAX is a technique that allows a webpage to request data from a server without reloading the entire page.

It lets websites update only part of the page (like search suggestions, comments, likes) while the rest stays the same.

For now, the most used AJAX technique to communicate with servers is XHR(XMLHttpRequest) and Fetch API.

Using XHR

Steps:

  1. Create XHR object
    1
    const xhr = new XMLHttpRequest();
  2. Set up the request
    1
    xhr.open(method, url)
  3. Listen for load event
    1
    2
    3
    4
    5
    6
    7
    8
    9
    xhr.addEventListener("load", ()=>{});
    //or
    xhr.onload = function () {
    if (xhr.status === 200) {
    console.log("Response:", xhr.responseText);
    } else {
    console.log("Request failed:", xhr.status);
    }
    };
  4. Send the request
    1
    xhr.send()
    If using POST request to send data
    1
    xhr.send(JSON.stringify({data: "data"}))

Useful properties:

  • xhr.response:could be JSON or other file types
  • xhr.status: the response status, like 200 OK
  • Progress event:
    1
    2
    3
    xhr.onprogress = (e) => {
    console.log(`Loaded: ${e.loaded} / ${e.total}`);
    }
  • Error event:
    1
    2
    xhr.onerror = () => console.error("Network error");

Callback Hell

In real development, we may need nested callback functions, that is to use the result of the inner callback function as parameter for the outer ones. Doing so makes the code very hard to maintain and read.

Promise and Fetch

A Promise is an object that represents a value that may arrive now, later, or never.

A promise can be in three states: pending fulfilled and rejected

  • pending: the promise hasn’t returned a value yet
  • fulfilled: the promise returned a value
  • rejected: the promise returned an error

We can create a promise object manually, but usually the promises are created automatically when we are dealing with asynchronous operations, like fetch.

Chaining a promise

We can handle the promise using then, catch and finally

1
2
3
4
5
6
7
8
9
promise
.then(response => {
const data = response.json;
console.log(data)
})
.catch(error => {
console.log(error.message)
})
.finally()
  • .then happens when the promise is fulfilled, by default it returns a new promise. That is what enables promise chaining:
1
2
3
4
5
6
promise
.then()
.then()
.then()
.catch()
.finally()
  • .catch happens when any promise in the chain is rejected.
  • .finally happens after the promise is fulfilled or rejected

Fetch

fetch() is a modern, promise-based API used to make HTTP requests (GET, POST, PUT, DELETE, etc.).

By default, a fetch() returns a promise.

The syntax of a fetch request is :

1
2
3
4
5
6
7
8
9
fetch(url, {
method: "GET", // HTTP Method
headers: {}, // Optional headers
body: {}, // Body for POST/PUT
mode: "cors", // CORS
credentials: "include", // Cookies
cache: "no-cache", // Cache behavior
redirect: "follow" // Redirect behavior
});

Doing so returns a promise, and if successful, the promise will return an http response.

1
2
3
4
5
6
7
fetch(url)
.then(response => {
if(!response.ok){
console.log(response.status);
};
return(response.json());
});

Note that event if the request is not successful, as long as the server returns a response, the fetch promise is fulfilled.

Error Handling

For now, if the fetch promise is rejected, the error can be handled by .catch. This usually happens when the request is CORS blocked, or have no internet connections.

But receiving a failed response, like 404, is also not desirable. So we can throw an error manually when that happens:

1
2
3
4
5
6
7
8
9
10
11
12
13
fetch("/api/data")
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! Status: ${res.status}`); //throw the error
}
return res.json();
})
.then(data => {
console.log("Success:", data);
})
.catch(err => {
console.error("Network or HTTP error:", err);
});

The error object: there are many ways to create an error object manually:

  • Basic error: throw new Error(“message”)
  • Specific type: throw new TypeError(“msg”), there are also SyntaxError, ReferenceError, etc
  • Custom class: class MyError extends Error {…}

Async Await

In the previous section, we used promise chaining to handle async operations, but now we have a more elegant way of writing it.

Syntax:

1
2
3
4
async function hello() {
let result = await Promise.resolve("Hello");
console.log(result);
}

When we add async to a function, that function will automatically return a promise.
We can use await to get the result of the promise.

It can be used in fetch operations:

1
2
3
4
5
6
7
async function getIP() {
const res = await fetch("https://ipapi.co/json/");
const data = await res.json();
console.log(data);
}

getIP();

We can assume that await is just like .then()

try…catch

In promise chaining we use .catch for error handling, in async-await, we also have similar feature.

1
2
3
4
5
6
7
8
9
10
11
12
async function loadData() {
try {
const res = await fetch("https://wrong-url");
const data = await res.json();
console.log(data);
} catch (err) {
console.error("Error:", err);
}
}

loadData();

try catch is just like .catch() in promise chaining.

Tips:

  • only use await in an async function
  • await most be followed with a promise
  • async functions always return a promise
  • await doesn’t block the main thread, it only stops the current function

Running multiple promises

If we simply use promise chaining, or async await, the promises are handled one by one. But sometimes, when the promises do not rely on each other, and the order doesn’t matter, we want to get the result of multiple promises at once.

  • Using Promise.all:
1
2
3
4
5
6
7
8
9
10
Promise.all([
fetch("https://ipapi.co/json/"),
fetch("https://api.github.com/users/octocat"),
fetch("https://api.publicapis.org/entries")
]).then(async ([res1, res2, res3]) => {
const data1 = await res1.json();
const data2 = await res2.json();
const data3 = await res3.json();
console.log(data1, data2, data3);
});

Using it, we can get the result of the three results at once. It returns an array of all promise results.

  • Using Promise.allSettled()

However, if any of the promises is rejected, the Promise.all will be rejected as a whole.

In order to get the results even if some promises are rejected, we can use Promise.allSettled():

1
2
3
4
Promise.allSettled([p1, p2, p3])
.then(results => {
console.log(results);
});
  • Using Promise.race()

Given an array of promises, Promise.race() returns the that promise that gives an result(no matter rejected or fulfilled).

1
Promise.race([slow, fast]).then(console.log); // "fast"
  • Using Promise.any()

Given an array of promises, Promise.any() returns the first fulfilled promise. If all the promises are rejected, it will return a AggregateError.

1
2
3
4
5
6
7
8
9
const p1 = Promise.reject("fail 1");
const p2 = Promise.resolve("success!");
const p3 = Promise.reject("fail 2");


Promise.any([p1, p2, p3])
.then(console.log) // "success!"
.catch(console.error);


Top level await

After ES2022, we can use await outside the async function. That is top level await. But there are restraints:

Top level await can only be used in modules, this includes:

  • .mjs file
  • js files with <script type="module"> or "type": "module"

It’s usage is quite the same, following the await should be a promise.

Note that the top level await will block the module from loading.