Understanding Asynchronous IO in Embedded JavaScript

September 9, 2026 | Peter Hoddie, Principal

Guides – A new kind of documentation for Embedded JavaScript Developers

Every JavaScript developer knows that they should minimize blocking to maximize responsiveness and throughput of the overall system. This is true on Node.js where blocking for long reduces the number of requests that can be served per second and on web browsers where blocking causes jank when rendering the user interface. This is true in the Embedded JavaScript world too. But, how you minimize blocking has an impact on the feel of the UI, memory use, and code complexity.

To understand the tradeoffs, we'll use a real-world example to bridge the gap between theory and practice. A good example is the GT911 touch driver in the Moddable SDK. The GT911 is a typical touch screen sensor, used on Moddable Six. The driver follows the ECMA-419 Sensor Class Pattern and communicates using I²C. Reading the touch sensor takes a few milliseconds. That's not unusual. The FT6206 used in Moddable One and Moddable Two is similar. A few milliseconds isn't the end of the world, but when that blocks, it takes time away from other operations, such as drawing the display.

There are three different implementations of the GT911 touch sensor in the Moddable SDK, each using a different technique. Let's look at each. The core operation of any touch sensor is reading the touch points, so we'll focus there.

Synchronous I²C

The synchronous GT911 touch driver doesn't try to minimize blocking. It accepts that a few milliseconds have minimal impact. The ECMA-419 standard encourages implementations to minimize blocking but acknowledges that "not all operations are instantaneous."

On the GT911, all the touch points may be retrieved with a single I²C read/write operation. This is the driver code that performs the operation:

const data = io.writeRead(Uint8Array.of(0x81, 0x4E));

Once a read is complete, the microcontroller notifies the touch sensor that it's ready for another sample using a synchronous write.

io.write(Uint8Array.of(0x81, 0x4E, 0));   // ready for next reading

While this code does block the main task for a few milliseconds to retrieve a touch sample, this can work well enough. Moddable Two uses the FT6202 synchronous touch driver and is able to comfortably render interactive user interfaces at 30 FPS. While it may not be optimal, it keeps the driver's IO very simple.

The GT911 driver has an optimization to only read when a new touch sample is available. This avoids I²C transactions that don't return any touch points. The optimization requires a connection to the GT911 interrupt pin which is triggered when a new touch point is available. Moddable Six has this interrupt wire, which means the GT911 driver is entirely idle when there is no active touch. Compare that to the FT6202 on Moddable Two, which polls for touch points at a fixed interval, causing I²C to block even when there is no active touch.

Asynchronous I²C

Conventional wisdom says that we should use asynchronous I²C transactions to eliminate all IO blocking in the driver. The ECMA-419 I²C standard defines an asynchronous I²C class, and the Moddable SDK implements that for ESP32.

The asynchronous I²C API uses callbacks modeled on Node.js "error-first" asynchronous callbacks because they have lower overhead than async JavaScript functions based on Promises. Reading a sample over I²C using the asynchronous I²C driver has two parts. First, the I²C transaction is initiated.

const data = new Uint8Array(9);
this.#io.writeRead(Uint8Array.of(0x81, 0x4E), data, this.#onPoints);

When the transaction completes, the callback is invoked.

this.#onPoints = (error) => {
   if (error)
      return void this.#onError?.(error);
   // .. process the sample in data
}

As in the synchronous case, the driver notifies the touch sensor that it is ready for the next touch sample. The code looks identical to the synchronous case, but it executes differently. Because this driver uses the asynchronous I²C class, the write happens asynchronously. The code could pass a callback to be notified when the transaction completes, but doesn't because it doesn't need that information.

io.write(Uint8Array.of(0x81, 0x4E, 0));      // ready chip for next reading

At first glance, there's a little more code here than the synchronous case, but not much. The real difference is in how it is organized. When you read the synchronous driver, everything reads in a straight line. In the asynchronous driver, the code is spread out, making it more difficult to trace the driver's flow. Proponents of async functions would rightly point out that await could hide most of that. But that brings a real cost in more runtime memory use, larger code size, and increased UI latency.

There's another problem hidden in the asynchronous driver. writeRead() performs the I²C transaction asynchronously, which means that the driver must exit the current turn of the event loop before it can receive the result. But when it returns, other code can run – data from an HTTP request might arrive, an application timer might fire, or it might be time to redraw the screen. These all delay delivery of the data read asynchronously by writeRead(). That can be much longer than the duration of a synchronous writeRead(). And if it is a screen redraw that intervenes, the screen is updated with a stale touch point, a touch point that would have been fresh in the synchronous case. That may sound theoretical or unlikely, but in practice, the asynchronous GT911 touch driver feels higher latency to users than the synchronous touch driver.

That's a trade-off. The responsiveness of the main task's event loop is improved overall, and is no longer blocked by the touch sensor driver. But if increased efficiency diminishes the user experience, maybe our priorities are off.

Fortunately, the third way combines the best of both worlds. Read on.

Hybrid Worker

The implementation of asynchronous I²C is instructive. The transactions themselves are synchronous. It has a dedicated native task to process I²C transactions outside the main task. Espressif has had asynchronous I²C APIs, but they haven't been stable, so they aren’t an option. Further, many other RTOSes don't have an asynchronous I²C API.

What we have is a separate, hidden task performing the same synchronous I²C work. This suggests an alternate architecture. Instead of using asynchronous I²C with an invisible background I²C task, the touch driver could just create its own task to run the synchronous I²C transactions itself. That's what our third version of the driver does.

Web Workers are how new tasks are created from JavaScript. The worker wraps a native task with an independent JavaScript virtual machine. Those virtual machines can be quite small. The worker used by the Hybrid Worker version of the GT911 driver is just 8 KB of RAM beyond the native task, which is the same size as in the Asynchronous I²C case. On a Moddable Six, that's only 0.095% of the total RAM, a small cost if there are real benefits.

The code itself is a bit bigger and more complicated. It relays the touch points to a small touch driver shell running in the main task that implements the Touch Sensor API, delivering new touch points received from the worker. The worker uses the synchronous I²C Touch driver to obtain touch samples. It doesn't matter that the I²C transactions block, since this worker is dedicated solely to the touch sensor.

onSample() {
   const points = this.sample();
   self.postMessage(points);
}

Unlike the Asynchronous I²C case, no additional turns through the event loop are introduced, ensuring the sample is delivered as soon as possible.

The Hybrid Worker has another advantage. Because nothing else can block it, it is guaranteed to run as soon as the touch sample interrupt triggers. This means that it has the most accurate time stamp possible for the sample. The worker stamps the samples with the time of the interrupt trigger to relay this information back to the main task. The worker gets the time stamp before reading the sample, to exclude the duration of the I²C transaction. Generating the time stamp in the worker also ensures that an accurate time stamp is available to the application independent of any delivery delay.

onSample() {
   const ticks = Time.ticks;
   const points = this.sample();
   points.ticks = ticks;
   self.postMessage(points);
}

Accurate time stamps are essential to gesture recognizers, which use them, for example, to determine the velocity of a swipe. The Hybrid Worker approach is well suited for sophisticated uses like gesture recognizers which can run in the worker so their calculations don't block the main task.

The Hybrid Worker model eliminates the need for an asynchronous driver. It simply uses the synchronous driver in a parallel task. This keeps driver code simple. Projects can organize workers based on their needs, from a dedicated worker for each sensor to a single worker for all sensors. Or perhaps no worker at all. Sensor driver authors keep their code simple and reliable, which is a big enough challenge.

The Numbers

We created a testbed to compare the runtime behavior of these three techniques. The testbed runs on Moddable Six with a variant of the classic Piu Balls example modified to render a square around the current touch point and to render twelve balls instead of the default four.

The Moddable Six display controller runs at 48.6 FPS by default, and the GT911 touch driver reports 93 touch events per second. These hardware limits constrain what the software can achieve. All three cases achieve the full frame rate of the display controller, so none of the techniques reduce the frame rate.

Sync I²C Async I²C Worker
touch events read from sensor, per second 49 24 93
touch events delivered to app, per second 49 24 49
main task blocked on I²C, per event 2.2 ms 0 ms 0 ms
main task in touch callback, per event 3.4 ms 1.4 ms 1.5 ms
freshness: age of event used to draw frame 16.8 ms 50.9 ms 28.2 ms
time stamp error 15.2 ms 12.6 ms 0.1 ms
CPU 0 increase during touch, per event +0.37% +0.48% +0.48%
CPU 1 increase during touch, per event 0 0 +18%

Reading the rows:

  • Events per second varies by 4x across the drivers, which in-turn impacts CPU load. The sync driver reads once per frame. The async driver needs two turns of the event loop per read and so reads once per two frames. The worker reads every available sample, which a gesture recognizer would need, and provides the main task with one sample per frame.
  • Blocking on I²C only happens in the sync driver: 2.2 ms per event, about 11 percent of the main task while a finger is down. The rest of every driver's callback time is Piu's touch dispatch, which is the same for all.
  • Freshness is the age of the touch sample on screen. Touch samples can only processed between frames. Consequently samples that arrive from the worker during a frame are processed for the next frame; the sync driver is fresher because it can only read after the frame. We have an update that improves its event freshness to beat the Synchronous I²C case.
  • Time stamp accuracy comes from stamping at the interrupt in the worker. The sync and async drivers can only stamp between frames, making their error higher.
  • CPU per delivered sample is about the same for every driver, roughly 5 ms of main-task work including the redraw; the per-second difference is caused by the differing number of samples delivered. The second core cost is the worker reading every sample.

The Asynchronous I²C and Hybrid Workers eliminate blocking for the touch sensor on the main task. The difference is that the Hybrid Worker delivers twice as many touch events and processes nearly four times as many. The Hybrid Worker also has much more accurate time stamps for touch samples. The main cost of the worker is increased CPU cost on the second ESP32-S3 core, because it is retrieving more samples. That core is often underutilized, so that's not a concern for most projects.

The impact on the user's perception is important to consider. The Asynchronous I²C driver feels less responsive because it is, with higher latency and half as many touch points. The Hybrid Worker feels light and responsive by comparison. A pending update reduces latency further to beat the Synchronous I²C case by 5 ms. Still, the old school Synchronous I²C comes out looking pretty good, especially when the main task is primarily for rendering.

To block. Or not to block.

All code blocks. The characterization of a driver as blocking or non-blocking is a matter of degrees. There are projects where blocking for even a millisecond is unacceptable. There are also projects where blocking for 10 milliseconds is perfectly acceptable. The three approaches described in this article – synchronous, asynchronous, and hybrid worker – all have their place. We don't have to choose one to use everywhere. The JavaScript language has the tools to support all of them, and the Moddable SDK provides them on a variety of embedded devices. Developers can choose the best approach for their project.

I hope this article encourages more Embedded JavaScript developers to consider using workers. They can make their projects feel more responsive and perform better, without a significant increase in complexity, simply by arranging existing pieces differently.