25000 WebSocket messages per second on STM32 Nucleo-H723ZG

How fast can a bare-metal STM32H723 push WebSocket messages over Ethernet? In this test a Nucleo-H723ZG running the Mongoose networking library sent about 26,000 small WebSocket messages per second to a single client, and about 15,000 messages per second when the message was roughly three times bigger. No RTOS, no lwIP, no mbedTLS: just a super loop, the Mongoose built-in TCP/IP stack and around 10 lines of extra code.

Results at a glance

Test Messages Rate
Short text message (~15 bytes) ~88,000 in a bit over 3 seconds ~26,000 msg/s
Longer text message (~3x bigger) - ~15,000 msg/s
Messages dropped / throttled 0 -

Board: STM32 Nucleo-H723ZG (Cortex-M7), Ethernet, bare metal. Software: Mongoose, built-in TCP/IP stack, MG_IO_SIZE left at the default 512 bytes. Client: curl on macOS over a direct Ethernet link.

This is a quick experiment, not a lab benchmark. Take the numbers as a ballpark of what the hardware and the stack can do for real-time device-to-browser streaming.

The setup

Nothing fancy here:

Open a serial terminal on the ST-LINK virtual COM port at 115200 to watch the logs.

Build and flash the minimal example

The starting point is the minimal bare-metal example for this board: tutorials/stm32/nucleo-h723zg/minimal.

cd tutorials/stm32/nucleo-h723zg/minimal
make flash

make downloads the CMSIS headers (Cortex-M core and STM32H7), builds the firmware and flashes it. In the serial console you'll see the board come up, get an IP address over DHCP and start serving HTTP. Quick check:

curl http://IP_ADDRESS/api/tick
{"tick":12345}

The example already serves a couple of URLs, /api/tick and /api/kill (the latter crashes the board on purpose and is there for the health monitoring demo). We don't need them today.

Add a WebSocket endpoint

Here's the plan: when a client opens /ws, upgrade the connection to WebSocket, then send a message on every poll, as fast as the loop goes.

The firmware is a classic super loop:

for (;;) {
  mg_mgr_poll(&mgr, 0);
  blink_task();
}

Each mg_mgr_poll() call delivers an MG_EV_POLL event to every connection, so a handler that sends something on MG_EV_POLL sends as often as the loop spins.

We also need to know which connection is a WebSocket one, because the same handler gets HTTP connections too. Every struct mg_connection has a small c->data[] scratch buffer for application use, so we set its first byte to 'W' as a marker during the upgrade.

Here's the event handler from main.c with the new bits:

static void http_ev_handler(struct mg_connection *c, int ev, void *ev_data) {
  if (ev == MG_EV_HTTP_MSG) {
    struct mg_http_message *hm = (struct mg_http_message *) ev_data;
    if (mg_match(hm->uri, mg_str("/api/tick"), NULL)) {
      mg_http_reply(c, 200, "", "{%m:%llu}\n", MG_ESC("tick"), hal_get_tick());
    } else if (mg_match(hm->uri, mg_str("/ws"), NULL)) {
      mg_ws_upgrade(c, hm, NULL);  // Switch this connection to WebSocket
      c->data[0] = 'W';            // Mark it, so we know it later
    } else {
      mg_http_reply(c, 200, "", "Hi from Mongoose, tick %llu\n",
                    hal_get_tick());
    }
  } else if (ev == MG_EV_POLL && c->data[0] == 'W') {
    static size_t count = 0;
    if (c->send.len < MG_IO_SIZE) {
      mg_ws_printf(c, WEBSOCKET_OP_TEXT, "Chunk # %zu\n", count++);
    } else {
      MG_INFO(("throttled, size %zu", c->send.len));
    }
  }
}

The first run in the video used a shorter message, around 15 bytes, and no c->send.len check. The version above is the final one, with the longer message and the throttling check, both explained below.

mg_ws_printf() formats a string, wraps it into a WebSocket frame and appends it to the connection's send buffer. Mongoose flushes that buffer to the network inside mg_mgr_poll().

Measure it with curl

Rebuild with make flash, then hit the endpoint. The curl that ships with macOS doesn't know about ws:// URLs, so I installed a fresh one from Homebrew, which has WebSocket support built in:

brew install curl

Homebrew doesn't put it in your PATH by default, so call it by full path. Each message ends with \n, so counting lines gives the message count. The -m 3 option stops curl after 3 seconds:

time /opt/homebrew/opt/curl/bin/curl ws://IP_ADDRESS/ws

In my run that gave about 88,000 messages in a bit over 3 seconds. Divide the message count by the "real" time that time prints, and you get roughly 26,000 messages per second. Pretty impressive for a microcontroller.

Are we dropping anything?

A fair question. If the firmware produces data faster than TCP can push it out, the send buffer grows, and in a real application you'd run out of RAM or start losing data. So the final version only sends when the send buffer is nearly empty:

if (c->send.len < MG_IO_SIZE) {
  // ... send
} else {
  MG_INFO(("throttled, size %zu", c->send.len));
}

MG_IO_SIZE defaults to 512 bytes on embedded targets. If the buffer is above that, we skip this poll and log a "throttled" line.

After reflashing, the messages kept flowing and not a single "throttled" line showed up in the console. So the network side keeps up with the loop, and everything we produce actually goes out on the wire.

Keep this pattern for production code. Checking c->send.len before writing is the simplest form of backpressure, and it keeps RAM use flat when a client is slow.

What about bigger messages?

Next I padded the message with some junk, making it roughly three times longer, and ran the same test.

Result: around 15,000 messages per second. No surprise: bigger messages, fewer of them per second. Note the total payload throughput actually went up, since every message carries fixed per-frame and per-packet overhead, and longer messages amortise it better.

So a fair summary is: the ceiling for small messages on this board is 20,000+ WebSocket messages per second, with the exact figure depending on message size.

What this tells you

FAQ

How many WebSocket messages per second can an STM32 send?

On a Nucleo-H723ZG with Mongoose, about 26,000 short (~15 byte) text messages per second to one client over Ethernet, bare metal. About 15,000 per second for messages three times longer.

Do I need an RTOS or lwIP for WebSocket on STM32?

No. Mongoose has its own TCP/IP stack with a built-in STM32H Ethernet driver, and WebSocket support is part of the same library. The test above runs in a bare-metal super loop. Mongoose can also run on top of FreeRTOS, Zephyr or lwIP if you already use them.

How do I avoid filling up RAM when sending WebSocket data fast?

Check c->send.len before calling mg_ws_printf() or mg_ws_send(), and skip sending while it is above a threshold such as MG_IO_SIZE.

Which Mongoose functions are used?

mg_http_listen() to start the server, mg_ws_upgrade() to turn an HTTP request into a WebSocket connection, mg_ws_printf() to send frames, and the MG_EV_POLL event to send on every loop iteration.

Why doesn't curl connect to ws:// on macOS?

The curl bundled with macOS is built without WebSocket support. Install curl from Homebrew and run it as /opt/homebrew/opt/curl/bin/curl.