Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Async JavaScript: history, patterns and gotchas

Hugo
January 28, 2019

Async JavaScript: history, patterns and gotchas

A look at the history, patterns and gotchas of async operations in JavaScript.

We'll go through the pros and cons of callbacks, Promises and async/await. Present some pitfalls to bear in mind as well as introducing how you would deal with certain situations.

Live-coding/workshop section touching on both Node and client-side JS situations at https://github.com/HugoDF/async-js-presentation

For Codebar London January Monthly 2019.

Hugo

January 28, 2019
Tweet

More Decks by Hugo

Other Decks in Programming

Transcript

  1. ASYNC JAVASCRIPT
    ASYNC JAVASCRIPT
    PATTERNS AND GOTCHAS ☔
    PATTERNS AND GOTCHAS ☔

    View Slide

  2. Hugo Di Francesco
    Engineer at Concrete.cc
    Slides:
    Twitter:
    codewithhugo.com/async-js
    @hugo__df

    View Slide

  3. CONTENTS
    CONTENTS
    1. Asynchronicity in JavaScript (a history lesson)
    2. Why async/await?
    3. Gotchas
    4. Patterns

    View Slide

  4. ASYNCHRONICITY IN JAVASCRIPT
    ASYNCHRONICITY IN JAVASCRIPT
    Primitives:
    Callbacks
    Promises
    (Observables)
    async/await

    View Slide

  5. What's asynchronous in a web application?

    View Slide

  6. What's asynchronous in a web application?
    tl;dr Most things

    View Slide

  7. View Slide

  8. 1. any network calls (HTTP, database)

    View Slide

  9. 1. any network calls (HTTP, database)
    2. timers (setTimeout, setInterval)

    View Slide

  10. 1. any network calls (HTTP, database)
    2. timers (setTimeout, setInterval)
    3. filesystem access

    View Slide

  11. 1. any network calls (HTTP, database)
    2. timers (setTimeout, setInterval)
    3. filesystem access
    ... Anything else that can be offloaded

    View Slide

  12. In JavaScript, these operations are non-blocking.
    HTTP Request in Python:
    HTTP Request in JavaScript:
    data = request(myUrl)
    print(data)
    request(myUrl, (err, data) => {
    console.log(data);
    });

    View Slide

  13. Why non-blocking I/O?
    You don't want to freeze your UI while you wait.

    View Slide

  14. Why non-blocking I/O?
    You don't want to freeze your UI while you wait.
    Non-blocking -> waiting doesn't cost you compute
    cycles.

    View Slide

  15. How non-blocking I/O is implemented (in JavaScript):
    pass a "callback" function
    it's called with the outcome of the async operation

    View Slide

  16. NODE-STYLE CALLBACKS
    NODE-STYLE CALLBACKS
    myAsyncFn((err, data) => {
    if (err) dealWithIt(err);
    doSomethingWith(data);
    })

    View Slide

  17. A callback is:
    "just" a function
    in examples, usually anonymous functions (pass
    function () {} directly)
    according to some style guides, should be an arrow
    function (() => {})
    called when the async operation finishes

    View Slide

  18. A Node-style callback is:
    called with any error(s) as the first
    argument/parameter, if there's no error, null is
    passed
    called with any number of "output" data as the
    other arguments
    ie. (err, data) => { /* more logic */ }

    View Slide

  19. NODE-STYLE CALLBACKS:
    NODE-STYLE CALLBACKS:
    PROBLEMS
    PROBLEMS

    View Slide

  20. 1. CALLBACK
    1. CALLBACK HELL
    HELL ⏫

    myAsyncFn((err, data) => {
    if (err) handle(err)
    myOtherAsyncFn(data, (err, secondData) => {
    fun(data, secondData, (err) => {
    if (err) handle(err)
    })
    fn(data, secondData, (err) => {
    if (err) handle(err)
    })
    })
    })

    View Slide

  21. For each asynchronous operation:
    extra level of indent
    lots of names for async output: data,
    secondData

    View Slide

  22. 2. SHADOWING VARIABLES
    2. SHADOWING VARIABLES
    err (in myAsyncFn callback) !== err (in
    myOtherAsyncFn callback) despite having the
    same name
    myAsyncFn((err, data) => {
    if (err) handle(err)
    myOtherAsyncFn(data, (err, secondData) => {
    fun(data, secondData, (err) => {
    if (err) handle(err)
    })
    fn(data, secondData, (err) => {
    if (err) handle(err)
    })
    })
    })

    View Slide

  23. 3. DUPLICATED ERROR HANDLING ❄
    3. DUPLICATED ERROR HANDLING ❄
    1 call to handle(err) per operation
    myAsyncFn((err, data) => {
    if (err) handle(err)
    myOtherAsyncFn(data, (err, secondData) => {
    fun(data, secondData, (err) => {
    if (err) handle(err)
    })
    fn(data, secondData, (err) => {
    if (err) handle(err)
    })
    })
    })

    View Slide

  24. 4. SWALLOWED ERRORS
    4. SWALLOWED ERRORS
    Ideal failure:
    fail early
    fail fast
    fail loud

    View Slide

  25. Spot the unhandled error:
    myAsyncFn((err, data) => {
    if (err) handle(err)
    myOtherAsyncFn(data, (err, secondData) => {
    fun(data, secondData, (err) => {
    if (err) handle(err)
    })
    fn(data, secondData, (err) => {
    if (err) handle(err)
    })
    })
    })

    View Slide

  26. Silent error
    err doesn't get handled
    hope your linter caught that
    myAsyncFn((err, data) => {
    if (err) handle(err)
    myOtherAsyncFn(data, (err, secondData) => {
    // Missing error handling!
    fun(data, secondData, (err) => {
    if (err) handle(err)
    })
    fn(data, secondData, (err) => {
    if (err) handle(err)
    })
    })
    })

    View Slide

  27. CALLBACK PROBLEMS
    CALLBACK PROBLEMS
    1. Callback hell (indents )
    2. Shadowed variables
    3. Duplicated error-handling
    4. Swallowed errors

    View Slide

  28. BRING ON THE PROMISE
    BRING ON THE PROMISE

    View Slide

  29. myAsyncFn()
    .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
    ]))
    .then(([data, secondData]) => Promise.all([
    fun(data, secondData),
    fn(data, secondData),
    ]))
    .then(/* do anything else */)
    .catch((err) => handle(err));

    View Slide

  30. Pros: Chainable
    no crazy indent stuff
    myAsyncFn()
    .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
    ]))
    .then(([data, secondData]) => Promise.all([
    fun(data, secondData),
    fn(data, secondData),
    ]))
    .then(/* do anything else */)
    .catch((err) => handle(err));

    View Slide

  31. Pros: Single error handler
    .catch once on the chain
    myAsyncFn()
    .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
    ]))
    .then(([data, secondData]) => Promise.all([
    fun(data, secondData),
    fn(data, secondData),
    ]))
    .then(/* do anything else */)
    .catch((err) => handle(err));

    View Slide

  32. Pros: lots of tightly scoped functions
    Small functions are usually easier to understand
    myAsyncFn()
    .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
    ]))
    .then(([data, secondData]) => Promise.all([
    fun(data, secondData),
    fn(data, secondData),
    ]))
    .then(/* do anything else */)
    .catch((err) => handle(err));

    View Slide

  33. Cons:
    Lots of tightly scoped functions
    Very verbose way of returning multiple things.
    .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
    ])

    View Slide

  34. PROMISE GOTCHAS
    PROMISE GOTCHAS

    View Slide

  35. Gotcha: ♀ nesting them is super tempting.
    myAsyncFn()
    .then((data) =>
    myOtherAsyncFn(data)
    .then(
    ([data, secondData]) =>
    Promise.all([
    fun(data, secondData),
    fn(data, secondData),
    ])
    )
    )
    .catch((err) => handle(err))

    View Slide

  36. Solution: Avoid the Pyramid of Doom ☠
    Promises "flatten":
    you can return a Promise from a then and keep
    chaining
    myAsyncFn()
    .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
    ]))
    .then(([data, secondData]) => Promise.all([
    fun(data, secondData),
    fn(data, secondData),
    ]))
    .then(/* do anything else */)
    .catch((err) => handle(err))

    View Slide

  37. Gotcha: onRejected callback
    The following works:
    But we're back to doing per-operation error-handling
    like in callbacks (potentially swallowing errors etc.)
    myAsyncFn()
    .then(
    (data) => myOtherAsyncFn(data),
    (err) => handle(err)
    );

    View Slide

  38. Solution: avoid it, in favour of .catch
    Unless you specifically need it
    myAsyncFn()
    .then(
    (data) => myOtherAsyncFn(data)
    )
    .catch((err) => handle(err));

    View Slide

  39. PROMISE RECAP
    PROMISE RECAP
    Lots of tightly scoped functions
    Very verbose way of returning/passing multiple
    things
    fn()
    .then((data) => Promise.all([
    data,
    myOtherAsyncFn(data),
    ]))
    .then(([data, secondData]) => {})

    View Slide

  40. ASYNC/AWAIT
    ASYNC/AWAIT

    View Slide

  41. (async () => {
    try {
    const data = await myAsyncFn();
    const secondData = await myOtherAsyncFn(data);
    const final = await Promise.all([
    fun(data, secondData),
    fn(data, secondData),
    ]);
    /* do anything else */
    } catch (err) {
    handle(err);
    }
    })();

    View Slide

  42. Given a Promise (or any object that has a .then
    function), await takes the value passed to the
    callback in .then

    View Slide

  43. await can only be used inside a function that is
    async *
    * top-level (ie. outside of async functions) await is coming
    (async () => {
    console.log('Immediately invoked function expressions (IIFEs
    const res = await fetch('https://jsonplaceholder.typicode.co
    const data = await res.json()
    console.log(data)
    })()
    // SyntaxError: await is only valid in async function
    const res = await fetch(
    'https://jsonplaceholder.typicode.com/todos/2'
    )

    View Slide

  44. async functions are "just" Promises
    const arrow = async () => { return 1 }
    const implicitReturnArrow = async () => 1
    const anonymous = async function () { return 1 }
    async function expression () { return 1 }
    console.log(arrow()); // Promise { 1 }
    console.log(implicitReturnArrow()); // Promise { 1 }
    console.log(anonymous()); // Promise { 1 }
    console.log(expression()); // Promise { 1 }

    View Slide

  45. LOOP THROUGH SEQUENTIAL CALLS
    LOOP THROUGH SEQUENTIAL CALLS

    View Slide

  46. With async/await:
    With promises:
    async function fetchSequentially(urls) {
    for (const url of urls) {
    const res = await fetch(url);
    const text = await res.text();
    console.log(text.slice(0, 100));
    }
    }
    function fetchSequentially(urls) {
    const [ url, ...rest ] = urls
    fetch(url)
    .then(res => res.text())
    .then(text => console.log(text.slice(0, 100)))
    .then(fetchSequentially(rest));
    }

    View Slide

  47. SHARE DATA BETWEEN CALLS
    SHARE DATA BETWEEN CALLS
    We don't have the whole
    async function run() {
    const data = await myAsyncFn();
    const secondData = await myOtherAsyncFn(data);
    const final = await Promise.all([
    fun(data, secondData),
    fn(data, secondData),
    ]);
    return final
    }
    .then(() => Promise.all([dataToPass, promiseThing]))
    .then(([data, promiseOutput]) => { })

    View Slide

  48. ERROR HANDLING
    ERROR HANDLING
    async function withErrorHandling(url) {
    try {
    const res = await fetch(url);
    const data = await res.json();
    return data
    } catch(e) {
    console.log(e.stack)
    }
    }
    withErrorHandling(
    'https://jsonplaceholer.typicode.com/todos/2'
    // The domain should be jsonplaceholder.typicode.com
    ).then(() => { /* but we'll end up here */ })

    View Slide

  49. CONS OF
    CONS OF async
    async/
    /await
    await
    Browser support is only good in latest/modern
    browsers
    polyfills (async-to-gen, regenerator runtime) are
    sort of big
    supported in Node 8+ though ♀
    Keen functional programming people would say it
    leads to a more "imperative" style of programming

    View Slide

  50. GOTCHAS
    GOTCHAS

    View Slide

  51. CREATING AN ERROR
    CREATING AN ERROR
    throw-ing inside an async function and
    Promise.reject work the same
    .reject and throw Error objects please
    async function asyncThrow() {
    throw new Error('asyncThrow');
    }
    function rejects() {
    return Promise.reject(new Error('rejects'))
    }
    async function swallowError(fn) {
    try { await asyncThrow() }
    catch (e) { console.log(e.message, e.__proto__) }
    try { await rejects() }
    catch (e) { console.log(e.message, e.__proto__) }
    }
    swallowError() // asyncThrow Error {} rejects Error {}

    View Slide

  52. WHAT HAPPENS WHEN YOU FORGET
    WHAT HAPPENS WHEN YOU FORGET
    await
    await?
    ?
    values are undefined
    TypeError: x.fn is not a function
    async function forgotToWait() {
    try {
    const res = fetch('https://jsonplaceholer.typicode.com/tod
    const text = res.text()
    } catch (e) {
    console.log(e);
    }
    }
    forgotToWait()
    // TypeError: res.text is not a function

    View Slide

  53. WHAT HAPPENS WHEN YOU FORGET
    WHAT HAPPENS WHEN YOU FORGET
    await
    await?
    ?
    console.log of Promise/async function
    *inserts 100th reminder*: an async function is a
    Promise
    async function forgotToWait() {
    const res = fetch('https://jsonplaceholer.typicode.com/todos
    console.log(res)
    }
    forgotToWait()
    // Promise { }

    View Slide

  54. PROMISES EVALUATE EAGERLY ✨
    PROMISES EVALUATE EAGERLY ✨
    Promises don't wait for anything to execute, when
    you create it, it runs:
    new Promise((resolve, reject) => {
    console.log('eeeeager');
    resolve();
    })

    View Slide

  55. TESTING GOTCHAS
    TESTING GOTCHAS
    Jest supports Promises as test output (therefore
    also async functions)
    what if your test fails?
    const runCodeUnderTest = async () => {
    throw new Error();
    };
    test('it should pass', async () => {
    doSomeSetup();
    await runCodeUnderTest();
    // the following never gets run
    doSomeCleanup();
    })

    View Slide

  56. TESTING GOTCHAS
    TESTING GOTCHAS
    BUT do your cleanup in "before/a er" hooks, async
    test bodies crash and don't clean up which might
    make multiple tests fail
    describe('feature', () => {
    beforeEach(() => doSomeSetup())
    afterEach(() => doSomeCleanup())
    test('it should pass', async () => {
    await runCodeUnderTest();
    })
    })

    View Slide

  57. PATTERNS
    PATTERNS
    A lot of these are to avoid the pitfalls we've looked in
    the "gotchas" section.

    View Slide

  58. RUNNING PROMISES IN PARALLEL
    RUNNING PROMISES IN PARALLEL
    Promise.all
    function fetchParallel(urls) {
    return Promise.all(
    urls.map(
    (url) =>
    fetch(url).then(res => res.json())
    )
    );
    }

    View Slide

  59. RUNNING PROMISES IN PARALLEL
    RUNNING PROMISES IN PARALLEL
    Promise.all + map over an async function
    Good for logging or when you've got non-
    trivial/business logic
    function fetchParallel(urls) {
    return Promise.all(
    urls.map(async (url) => {
    const res = await fetch(url);
    const data = await res.json();
    return data;
    })
    );
    }

    View Slide

  60. DELAY EXECUTION OF A PROMISE ✋
    DELAY EXECUTION OF A PROMISE ✋
    Promises are eager, they just wanna run!
    Use a function that returns the Promise
    No Promise, no eager execution
    Fancy people call the above "thunk"
    function getX(url) {
    return fetch(url)
    }
    // or
    const delay = url => fetch(url)

    View Slide

  61. SEPARATE SYNCHRONOUS AND
    SEPARATE SYNCHRONOUS AND
    ASYNCHRONOUS OPERATIONS
    ASYNCHRONOUS OPERATIONS
    async fetch > do stuff in memory > async write back
    const fs = require('fs').promises
    const fetchFile = () =>
    fs.readFile('path', 'utf-8');
    const replaceAllThings = (text) =>
    text.replace(/a/g, 'b');
    const writeFile = (text) =>
    fs.writeFile('path', text, 'utf-8');
    (async () => {
    const text = await fetchFile();
    const newText = replaceAllThings(text);
    await writeFile(newText);
    })();

    View Slide

  62. RUNNING PROMISES SEQUENTIALLY
    RUNNING PROMISES SEQUENTIALLY
    using recursion + rest/spread and way too much
    bookkeeping
    function fetchSequentially(urls, data = []) {
    if (urls.length === 0) return data
    const [url, ...rest] = urls
    return fetch(url)
    .then(res => res.text())
    .then(text =>
    fetchSequentially(
    rest,
    [...data, text]
    ));
    }

    View Slide

  63. RUNNING PROMISES SEQUENTIALLY
    RUNNING PROMISES SEQUENTIALLY
    using await + a loop?
    async function fetchSequentially(urls) {
    const data = []
    for (const url of urls) {
    const res = await fetch(url);
    const text = await res.text();
    data.push(text)
    }
    return data
    }

    View Slide

  64. PASSING DATA IN SEQUENTIAL ASYNC
    PASSING DATA IN SEQUENTIAL ASYNC
    CALLS
    CALLS
    return array + destructuring in next call, very
    verbose in Promise chains
    async function findLinks() { /* some implementation */ }
    function crawl(url, parentText) {
    console.log('crawling links in: ', parentText);
    return fetch(url)
    .then(res => res.text())
    .then(text => Promise.all([
    findLinks(text),
    text
    ]))
    .then(([links, text]) => Promise.all(
    links.map(link => crawl(link, text))
    ));
    }

    View Slide

  65. PASSING DATA IN SEQUENTIAL ASYNC
    PASSING DATA IN SEQUENTIAL ASYNC
    CALLS
    CALLS
    await + data in the closure
    async function findLinks() { /* someimplementation */ }
    async function crawl(url, parentText) {
    console.log('crawling links in: ', parentText);
    const res = await fetch(url);
    const text = await res.text();
    const links = await findLinks(text);
    return crawl(links, text);
    }

    View Slide

  66. ERROR HANDLING ❌
    ERROR HANDLING ❌
    try/catch, or .catch ♀
    function withCatch() {
    return fetch('borked_url')
    .then(res => res.text())
    .catch(err => console.log(err))
    }
    async function withBlock() {
    try {
    const res = await fetch('borked_url');
    const text = await res.text();
    } catch (err) {
    console.log(err)
    }
    }

    View Slide

  67. WORKSHOP
    WORKSHOP
    "callbackify"-ing a Promise-based API
    getting data in parallel using callbacks
    "promisify"-ing a callback-based API (read/write file)
    Why we don't mix async and sync operations

    View Slide

  68. FURTHER READING
    FURTHER READING
    Slides/write up (including workshop examples) at
    About non-blocking I/O in Node.js docs:
    - Tyler McGinnis
    codewithhugo.com/async-js
    nodejs.org/en/docs/guides/blocking-vs-non-
    blocking/
    Async JavaScript: From Callbacks, to Promises, to
    Async/Await

    View Slide