Quick answer: io_context::run() blocks until there’s no work. Don’t call it on the game thread — spawn a dedicated worker thread for it, and marshal completion handlers back via a thread-safe queue.

A networked game freezes when there’s no traffic. io_context.run() was called on the main thread “briefly” — but it blocks until there’s work, which is forever when idle.

Run on a Worker Thread

asio::io_context ctx;
auto work = asio::make_work_guard(ctx);   // keep run() alive when idle
std::thread io_thread([&]() { ctx.run(); });

The worker thread owns Asio. Your game thread stays free. The work guard keeps run() alive even with no pending ops — remove it when shutting down so run() returns and the thread joins.

Marshal Results Back

Asio completion handlers run on whichever thread called run() — the worker. Don’t touch game state from there. Push results onto a thread-safe queue; drain it on the game thread each frame.

// in handler (worker thread):
results_queue.push(parsed);

// in game loop (main thread):
while (results_queue.try_pop(r)) ApplyResult(r);

Strands for Ordered Handlers

If multiple worker threads run the same io_context (a pool), use a strand to order handlers for one connection. Avoids interleaving from the same socket.

Verifying

The game runs smoothly with no network traffic. Connections handle messages without main-thread stalls. Shutdown is clean: release the work guard, join the thread.

“Asio runs where you call run(). Put it on a worker; marshal results back to the game thread.”

Same pattern works for any external IO framework — libuv, grpc, custom socket loops. Network on its own thread, game thread reads results.