A design pattern provides a general reusable solution for the common problems that occur in software design. The pattern typically shows relationships and interactions between classes or objects. The idea is to speed up the development process by providing well-tested, proven development/design paradigms. Design patterns come in three different categories. Creational patterns include the generation of object instances. Structure refers to how an object is made and how things behave and interact In this Webinar(Live Meetup) we will be covering - What is node js - When to use node js - Async I/O operations in node js - Advantages of Async/Await - Some interesting - async patterns - Performance comparison
3. Always re-imagining
We are a pioneering technology consultancy focused
on AWS and serverless
| |
Accelerated Serverless AI as a Service Platform Modernisation
loige
✉Reach out to us at
😇We are always looking for talent:
hello@fourTheorem.com
fth.link/careers
3
4. We host a weekly podcast about AWS
awsbites.com
loige 4
7. What does async even mean?
In JavaScript and in Node.js, input/output operations are non-
blocking.
Classic examples: reading the content of a file, making an HTTP
request, loading data from a database, etc.
loige 7
8. Blocking style vs JavaScript
Blocking style JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
loige 8
9. Blocking style vs JavaScript
Blocking style JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
loige 9
10. Blocking style vs JavaScript
Blocking style JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
loige 10
11. Blocking style vs JavaScript
Blocking style JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
loige 11
12. Blocking style vs JavaScript
Blocking style JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
(done)
loige 12
13. Blocking style vs JavaScript
Blocking style JavaScript
1. Assign a variable
2. Read data from a file
3. Print to stdout
1. Assign a variable
2. Read data from a file
3. Print to stdout
(done)
(done)
loige 13
14. Non-blocking I/O is convenient:
you can do work while waiting for I/O!
But, what if we need to do something when
the I/O operation completes?
loige 14
15. Once upon a time there were...
Callbacks
loige 15
16. Anatomy of callback-based non-blocking code
doSomethingAsync(arg1, arg2, cb)
This is a callback
loige 16
17. doSomethingAsync(arg1, arg2, (err, data) => {
// ... do something with data
})
You are defining what happens when the I/O operations
completes (or fails) with a function.
doSomethingAsync will call that function for you!
loige
Anatomy of callback-based non-blocking code
17
18. doSomethingAsync(arg1, arg2, (err, data) => {
if (err) {
// ... handle error
return
}
// ... do something with data
})
Always handle errors first!
loige
Anatomy of callback-based non-blocking code
18
19. An example
Fetch the latest booking for a given user
If it exists print it
loige 19
20. getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
} else {
console.log(`No booking found for user ${userId}`)
}
})
1
2
3
4
5
6
7
8
9
10
11
12
An example
loige 20
21. A more realistic example
Fetch the latest booking for a given user
If it exists, cancel it
If it was already paid for, refund the user
loige 21
22. getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
cancelBooking(booking.id, (err) => {
if (err) {
console.error(err)
return
}
if (booking.paid) {
console.log('Booking was paid, refunding the user')
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
}
})
} else {
console.log(`No booking found for user ${userId}`)
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
cancelBooking(booking.id, (err) => {
if (err) {
console.error(err)
return
}
if (booking.paid) {
console.log('Booking was paid, refunding the user')
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
}
})
getLatestBooking(userId, (err, booking) => {
1
if (err) {
2
console.error(err)
3
return
4
}
5
6
if (booking) {
7
console.log(`Found booking for user ${userId}`, booking)
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
} else {
27
console.log(`No booking found for user ${userId}`)
28
}
29
})
30
if (booking.paid) {
console.log('Booking was paid, refunding the user')
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
}
getLatestBooking(userId, (err, booking) => {
1
if (err) {
2
console.error(err)
3
return
4
}
5
6
if (booking) {
7
console.log(`Found booking for user ${userId}`, booking)
8
cancelBooking(booking.id, (err) => {
9
if (err) {
10
console.error(err)
11
return
12
}
13
14
15
16
17
18
19
20
21
22
23
24
25
})
26
} else {
27
console.log(`No booking found for user ${userId}`)
28
}
29
})
30
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
getLatestBooking(userId, (err, booking) => {
1
if (err) {
2
console.error(err)
3
return
4
}
5
6
if (booking) {
7
console.log(`Found booking for user ${userId}`, booking)
8
cancelBooking(booking.id, (err) => {
9
if (err) {
10
console.error(err)
11
return
12
}
13
14
if (booking.paid) {
15
console.log('Booking was paid, refunding the user')
16
17
18
19
20
21
22
23
24
}
25
})
26
} else {
27
console.log(`No booking found for user ${userId}`)
28
}
29
})
30 loige 22
23. getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
cancelBooking(booking.id, (err) => {
if (err) {
console.error(err)
return
}
if (booking.paid) {
console.log('Booking was paid, refunding the user')
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
}
})
} else {
console.log(`No booking found for user ${userId}`)
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 loige 23
24. getLatestBooking(userId, (err, booking) => {
if (err) {
console.error(err)
return
}
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
cancelBooking(booking.id, (err) => {
if (err) {
console.error(err)
return
}
if (booking.paid) {
console.log('Booking was paid, refunding the user')
refundUser(userId, booking.paidAmount, (err) => {
if (err) {
console.error(err)
return
}
console.log('User refunded')
})
}
})
} else {
console.log(`No booking found for user ${userId}`)
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 loige
THE PIRAMID OF
DOOM
(or callback hell 🔥)
24
30. With callbacks we are not in
charge!
We need to trust that the async function will call our
callbacks when the async work is completed!
loige 30
31. Promise help us to be more in control!
const promiseObj = doSomethingAsync(arg1, arg2)
An object that represents the
status of the async operation
loige 31
32. const promiseObj = doSomethingAsync(arg1, arg2)
A promise object is a tiny state machine with 2 possible states
pending (still performing the async operation)
settled (completed)
✅fullfilled (witha value)
🔥rejected (with an error)
loige
Promise help us to be more in control!
32
33. const promiseObj = doSomethingAsync(arg1, arg2)
promiseObj.then((data) => {
// ... do something with data
})
loige
Promise help us to be more in control!
33
34. const promiseObj = doSomethingAsync(arg1, arg2)
promiseObj.then((data) => {
// ... do something with data
})
promiseObj.catch((err) => {
// ... handle errors
}
loige
Promise help us to be more in control!
34
35. Promises can be chained ⛓
This solves the pyramid of doom problem!
doSomethingAsync(arg1, arg2)
.then((result) => doSomethingElseAsync(result))
.then((result) => doEvenMoreAsync(result)
.then((result) => keepDoingStuffAsync(result))
.catch((err) => { /* ... */ })
35
loige
36. Promises can be chained ⛓
This solves the pyramid of doom problem!
doSomethingAsync(arg1, arg2)
.then((result) => doSomethingElseAsync(result))
// ...
.catch((err) => { /* ... */ })
.finally(() => { /* ... */ })
loige 36
37. How to create a promise
new Promise ((resolve, reject) => {
// ...
})
loige 37
38. How to create a promise
new Promise ((resolve, reject) => {
// ... do something async
// reject(someError)
// resolve(someValue)
})
loige 38
39. How to create a promise
Promise.resolve('SomeValue')
Promise.reject(new Error('SomeError'))
loige 39
40. How to create a promise (example)
function queryDB(client, query) {
return new Promise((resolve, reject) => {
client.executeQuery(query, (err, data) => {
if (err) {
return reject(err)
}
resolve(data)
})
})
}
1
2
3
4
5
6
7
8
9
10
11
loige 40
41. How to create a promise (example)
queryDB(dbClient, 'SELECT * FROM bookings')
.then((data) => {
// ... do something with data
})
.catch((err) => {
console.error('Failed to run query', err)
})
.finally(() => {
dbClient.disconnect()
})
1
2
3
4
5
6
7
8
9
10
queryDB(dbClient, 'SELECT * FROM bookings')
1
.then((data) => {
2
// ... do something with data
3
})
4
.catch((err) => {
5
console.error('Failed to run query', err)
6
})
7
.finally(() => {
8
dbClient.disconnect()
9
})
10
.then((data) => {
// ... do something with data
})
queryDB(dbClient, 'SELECT * FROM bookings')
1
2
3
4
.catch((err) => {
5
console.error('Failed to run query', err)
6
})
7
.finally(() => {
8
dbClient.disconnect()
9
})
10
.catch((err) => {
console.error('Failed to run query', err)
})
queryDB(dbClient, 'SELECT * FROM bookings')
1
.then((data) => {
2
// ... do something with data
3
})
4
5
6
7
.finally(() => {
8
dbClient.disconnect()
9
})
10
.finally(() => {
dbClient.disconnect()
})
queryDB(dbClient, 'SELECT * FROM bookings')
1
.then((data) => {
2
// ... do something with data
3
})
4
.catch((err) => {
5
console.error('Failed to run query', err)
6
})
7
8
9
10
queryDB(dbClient, 'SELECT * FROM bookings')
.then((data) => {
// ... do something with data
})
.catch((err) => {
console.error('Failed to run query', err)
})
.finally(() => {
dbClient.disconnect()
})
1
2
3
4
5
6
7
8
9
10
loige 41
42. Let's re-write our example with Promise
Fetch the latest booking for a given user
If it exists, cancel it
If it was already paid for, refund the user
loige 42
43. getLatestBooking(userId)
.then((booking) => {
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
return cancelBooking(booking.id)
}
console.log(`No booking found for user ${userId}`)
})
.then((cancelledBooking) => {
if (cancelledBooking && cancelledBooking.paid) {
console.log('Booking was paid, refunding the user')
return refundUser(userId, cancelledBooking.paidAmount)
}
})
.then((refund) => {
if (refund) {
console.log('User refunded')
}
})
.catch((err) => {
console.error(err)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
getLatestBooking(userId)
1
.then((booking) => {
2
if (booking) {
3
console.log(`Found booking for user ${userId}`, booking)
4
return cancelBooking(booking.id)
5
}
6
console.log(`No booking found for user ${userId}`)
7
})
8
.then((cancelledBooking) => {
9
if (cancelledBooking && cancelledBooking.paid) {
10
console.log('Booking was paid, refunding the user')
11
return refundUser(userId, cancelledBooking.paidAmount)
12
}
13
})
14
.then((refund) => {
15
if (refund) {
16
console.log('User refunded')
17
}
18
})
19
.catch((err) => {
20
console.error(err)
21
})
22
.then((booking) => {
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
return cancelBooking(booking.id)
}
console.log(`No booking found for user ${userId}`)
})
getLatestBooking(userId)
1
2
3
4
5
6
7
8
.then((cancelledBooking) => {
9
if (cancelledBooking && cancelledBooking.paid) {
10
console.log('Booking was paid, refunding the user')
11
return refundUser(userId, cancelledBooking.paidAmount)
12
}
13
})
14
.then((refund) => {
15
if (refund) {
16
console.log('User refunded')
17
}
18
})
19
.catch((err) => {
20
console.error(err)
21
})
22
.then((cancelledBooking) => {
if (cancelledBooking && cancelledBooking.paid) {
console.log('Booking was paid, refunding the user')
return refundUser(userId, cancelledBooking.paidAmount)
}
})
getLatestBooking(userId)
1
.then((booking) => {
2
if (booking) {
3
console.log(`Found booking for user ${userId}`, booking)
4
return cancelBooking(booking.id)
5
}
6
console.log(`No booking found for user ${userId}`)
7
})
8
9
10
11
12
13
14
.then((refund) => {
15
if (refund) {
16
console.log('User refunded')
17
}
18
})
19
.catch((err) => {
20
console.error(err)
21
})
22
.then((refund) => {
if (refund) {
console.log('User refunded')
}
})
getLatestBooking(userId)
1
.then((booking) => {
2
if (booking) {
3
console.log(`Found booking for user ${userId}`, booking)
4
return cancelBooking(booking.id)
5
}
6
console.log(`No booking found for user ${userId}`)
7
})
8
.then((cancelledBooking) => {
9
if (cancelledBooking && cancelledBooking.paid) {
10
console.log('Booking was paid, refunding the user')
11
return refundUser(userId, cancelledBooking.paidAmount)
12
}
13
})
14
15
16
17
18
19
.catch((err) => {
20
console.error(err)
21
})
22
.catch((err) => {
console.error(err)
})
getLatestBooking(userId)
1
.then((booking) => {
2
if (booking) {
3
console.log(`Found booking for user ${userId}`, booking)
4
return cancelBooking(booking.id)
5
}
6
console.log(`No booking found for user ${userId}`)
7
})
8
.then((cancelledBooking) => {
9
if (cancelledBooking && cancelledBooking.paid) {
10
console.log('Booking was paid, refunding the user')
11
return refundUser(userId, cancelledBooking.paidAmount)
12
}
13
})
14
.then((refund) => {
15
if (refund) {
16
console.log('User refunded')
17
}
18
})
19
20
21
22
getLatestBooking(userId)
.then((booking) => {
if (booking) {
console.log(`Found booking for user ${userId}`, booking)
return cancelBooking(booking.id)
}
console.log(`No booking found for user ${userId}`)
})
.then((cancelledBooking) => {
if (cancelledBooking && cancelledBooking.paid) {
console.log('Booking was paid, refunding the user')
return refundUser(userId, cancelledBooking.paidAmount)
}
})
.then((refund) => {
if (refund) {
console.log('User refunded')
}
})
.catch((err) => {
console.error(err)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
loige 43
45. Sometimes, we just want to wait for a promise to
resolve before executing the next line...
const promiseObj = doSomethingAsync(arg1, arg2)
const data = await promiseObj
// ... process the data
await allows us to do exactly that
loige 45
46. const data = await doSomethingAsync(arg1, arg2)
// ... process the data
We don't have to assign the promise to a variable to use await
Sometimes, we just want to wait for a promise to
resolve before executing the next line...
loige 46
47. try {
const data = await doSomethingAsync(arg1, arg2)
// ... process the data
} catch (err) {
// ... handle error
}
Unified error handling
If we await a promise that eventually rejects we can capture the error with a regular try/catch block
loige 47
48. Async functions
async function doSomethingAsync(arg1, arg2) {
// ...
}
special keyword that marks a function as async
loige 48
49. Async functions
async function doSomethingAsync(arg1, arg2) {
return 'SomeValue'
}
function doSomethingAsync(arg1, arg2) {
return Promise.resolve('SomeValue')
}
loige 49
50. Async functions
async function doSomethingAsync(arg1, arg2) {
throw new Error('SomeError')
}
function doSomethingAsync(arg1, arg2) {
return Promise.reject(new Error('SomeError'))
}
loige 50
51. Async functions
async function doSomethingAsync(arg1, arg2) {
const res1 = await doSomethingElseAsync()
const res2 = await doEvenMoreAsync(res1)
const res3 = await keepDoingStuffAsync(res2)
// ...
}
inside an async function you can use await to
suspend the execution until the awaited promise
resolves
loige 51
52. Async functions
async function doSomethingAsync(arg1, arg2) {
const res = await doSomethingElseAsync()
if (res) {
for (const record of res1.records) {
await updateRecord(record)
}
}
}
Async functions make it very easy to write code
that manages asynchronous control flow
loige 52
53. Let's re-write our example with async/await
Fetch the latest booking for a given user
If it exists, cancel it
If it was already paid for, refund the user
loige 53
54. async function cancelLatestBooking(userId) {
const booking = await getLatestBooking(userId)
if (!booking) {
console.log(`No booking found for user ${userId}`)
return
}
console.log(`Found booking for user ${userId}`, booking)
await cancelBooking(booking.id)
if (booking.paid) {
console.log('Booking was paid, refunding the user')
await refundUser(userId, booking.paidAmount)
console.log('User refunded')
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function cancelLatestBooking(userId) {
}
1
const booking = await getLatestBooking(userId)
2
3
if (!booking) {
4
console.log(`No booking found for user ${userId}`)
5
return
6
}
7
8
console.log(`Found booking for user ${userId}`, booking)
9
10
await cancelBooking(booking.id)
11
12
if (booking.paid) {
13
console.log('Booking was paid, refunding the user')
14
await refundUser(userId, booking.paidAmount)
15
console.log('User refunded')
16
}
17
18
const booking = await getLatestBooking(userId)
async function cancelLatestBooking(userId) {
1
2
3
if (!booking) {
4
console.log(`No booking found for user ${userId}`)
5
return
6
}
7
8
console.log(`Found booking for user ${userId}`, booking)
9
10
await cancelBooking(booking.id)
11
12
if (booking.paid) {
13
console.log('Booking was paid, refunding the user')
14
await refundUser(userId, booking.paidAmount)
15
console.log('User refunded')
16
}
17
}
18
if (!booking) {
console.log(`No booking found for user ${userId}`)
return
}
async function cancelLatestBooking(userId) {
1
const booking = await getLatestBooking(userId)
2
3
4
5
6
7
8
console.log(`Found booking for user ${userId}`, booking)
9
10
await cancelBooking(booking.id)
11
12
if (booking.paid) {
13
console.log('Booking was paid, refunding the user')
14
await refundUser(userId, booking.paidAmount)
15
console.log('User refunded')
16
}
17
}
18
console.log(`Found booking for user ${userId}`, booking)
async function cancelLatestBooking(userId) {
1
const booking = await getLatestBooking(userId)
2
3
if (!booking) {
4
console.log(`No booking found for user ${userId}`)
5
return
6
}
7
8
9
10
await cancelBooking(booking.id)
11
12
if (booking.paid) {
13
console.log('Booking was paid, refunding the user')
14
await refundUser(userId, booking.paidAmount)
15
console.log('User refunded')
16
}
17
}
18
await cancelBooking(booking.id)
async function cancelLatestBooking(userId) {
1
const booking = await getLatestBooking(userId)
2
3
if (!booking) {
4
console.log(`No booking found for user ${userId}`)
5
return
6
}
7
8
console.log(`Found booking for user ${userId}`, booking)
9
10
11
12
if (booking.paid) {
13
console.log('Booking was paid, refunding the user')
14
await refundUser(userId, booking.paidAmount)
15
console.log('User refunded')
16
}
17
}
18
if (booking.paid) {
console.log('Booking was paid, refunding the user')
await refundUser(userId, booking.paidAmount)
console.log('User refunded')
}
async function cancelLatestBooking(userId) {
1
const booking = await getLatestBooking(userId)
2
3
if (!booking) {
4
console.log(`No booking found for user ${userId}`)
5
return
6
}
7
8
console.log(`Found booking for user ${userId}`, booking)
9
10
await cancelBooking(booking.id)
11
12
13
14
15
16
17
}
18
async function cancelLatestBooking(userId) {
const booking = await getLatestBooking(userId)
if (!booking) {
console.log(`No booking found for user ${userId}`)
return
}
console.log(`Found booking for user ${userId}`, booking)
await cancelBooking(booking.id)
if (booking.paid) {
console.log('Booking was paid, refunding the user')
await refundUser(userId, booking.paidAmount)
console.log('User refunded')
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
loige 54
55. Mini summary
Async/Await generally helps to keep the code simple &
readable
To use Async/Await you need to understand Promise
To use Promise you need to understand callbacks
callbacks → Promise → async/await
Don't skip any step of the async journey!
loige 55
58. Sequential execution (gotcha!)
const users = ['Peach', 'Toad', 'Mario', 'Luigi']
users.forEach(async (userId) => {
await cancelLatestBooking(userId)
})
1
2
3
4
5
loige
⚠Don't do this with Array.map() or Array.forEach()
Array.forEach() will run the provided function without
awaiting for the returned promise, so all the invocation will
actually happen concurrently!
58
59. Concurrent execution (Promise.all)
const users = ['Peach', 'Toad', 'Mario', 'Luigi']
await Promise.all(
users.map(
userId => cancelLatestBooking(userId)
)
)
1
2
3
4
5
6
7
loige
Promise.all() receives a list of promises and it returns a
new Promise. This promise will resolve once all the original
promises resolve, but it will reject as soon as ONE promise
rejects
59
62. You want to use async/await but...
you have a callback-based API! 😣
loige 62
63. Node.js offers promise-based alternative APIs
Callback-based Promise-based
setTimeout, setImmediate, setInterval import timers from 'timers/promises'
import fs from 'fs' import fs from 'fs/promises'
import stream from 'stream' import stream from 'stream/promises'
import dns from 'dns' import dns from 'dns/promises'
loige 63
66. What if we we want to do the opposite? 🤷
Convert a promise-based function to a callback-based one
loige 66
67. var env = nunjucks.configure('views')
env.addFilter('videoTitle', function(videoId, cb) {
// ... fetch the title through youtube APIs
// ... extract the video title
// ... and call the callback with the title
}, true)
1
2
3
4
5
6
7
OK, this is not a common use case, so let me give you a real
example!
Nunjucks async filters
{{ data | myCustomFilter }}
We are forced to pass a callback-based
function here!
Ex: {{ youtubeId | videoTitle }}
loige 67
76. The data fetching function (with batching)
let pendingRequests = new Map()
function getHotelsForCity (cityId) {
if (pendingRequests.has(cityId)) {
return pendingRequests.get(cityId)
}
const asyncOperation = db.query({
text: 'SELECT * FROM hotels WHERE cityid = $1',
values: [cityId],
})
pendingRequests.set(cityId, asyncOperation)
asyncOperation.finally(() => {
pendingRequests.delete(cityId)
})
return asyncOperation
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 loige 76
77. Benchmarks
loige.link/req-batch-bench
Without request batching With request batching (+90% avg req/sec)*
* This is an artificial benchmark and results might vary significantly in real-life scenarios. Always run your own
benchmarks before deciding whether this optimization can have a positive effect for you.
loige 77
78. Closing Notes
JavaScript can be a very powerful and convenient language
when we have to deal with a lot of I/O (e.g. web servers)
The async story has evolved a lot in the last 10-15 years: new
patterns and language constructs have emerged
Async/Await is probably the best way to write async code today
To use Async/Await correctly you need to understand Promise
and callbacks
Take your time and invest in learning the fundamentals
loige 78
79. Cover Picture by on
Marc-Olivier Jodoin Unsplash
THANKS! 🙌❤
nodejsdp.link
loige
Grab these slides!
😍Grab the book
79