Using Racket's async for non‑blocking I/O: a practical guide
A concise guide showing how Racket's async library lets you write non‑blocking I/O, with a concrete web‑scraper example and practical verification steps.
28 Sept 2025, 08:36 UTC

Why blocking I/O stalls your Racket scripts
When a Racket program performs a network request or reads a file, the thread waits synchronously. In a CPU‑bound script this pause can dominate the total runtime, making the program feel unresponsive. The problem is not the language itself but the default blocking model.
Thesis: async gives lightweight concurrency
The racket/async library introduces an event‑driven concurrency model that lets you write code that looks synchronous while the runtime schedules many I/O operations on a single thread. This keeps the REPL alive and lets you compose asynchronous steps with await‑style async forms.
Core abstractions
Three building blocks are central:
- Promise: a container for a future value that can be
awaited. - Thunk: a zero‑argument function that returns a promise, enabling lazy evaluation of asynchronous work.
- async form: creates a thunk that the event loop executes;
awaitextracts the result.
These abstractions replace blocking calls such as call-with-input-file or url-request with non‑blocking equivalents, letting the event loop handle other tasks while waiting.
Worked example: concurrent URL fetches
Below is a minimal script that fetches three URLs concurrently, aggregates the responses, and prints a summary. Run it with racket async-scraper.rkt. The total runtime should be close to the longest single request rather than the sum of all delays.
#lang racket
(require racket/async)
(require net/url)
(require net/http)
(define urls '("https://example.com" "https://racket-lang.org" "https://github.com"))
(define (fetch url)
(async
(let ([resp (url-request (string->url url))])
[body (response->string resp)])
(values url body))))
(define tasks (map fetch urls))
(define results (await* tasks))
(for ([([url . body] result) (in-list results)])]
(printf "~a: ~a~n" url (substring body 0 60)))
(display "\nDone. Total time ≈ longest request.\n")
Key points to verify after running:
- Measure elapsed time (e.g., with
timeorracket/trace) and confirm it is near the longest individual request. - Ensure the REPL stays responsive; you can type new expressions while the async tasks run.
- Check that the printed summaries appear in the order the URLs were defined, demonstrating that results are not scrambled.
Trade‑offs and practical considerations
Async improves throughput but adds complexity:
- Error handling must be wrapped around
awaitforms; uncaught exceptions can terminate the whole event loop if not caught. - The event loop must stay alive for the duration of all pending tasks; long‑running programs should explicitly keep the process running (e.g., by awaiting a final future or using
racket/async/event-loop). - Debugging stack traces are split across fibers, so tools like
racket/traceorracket/debugare recommended.
Takeaway and next steps
If your Racket scripts spend most of their time waiting on I/O, adopting racket/async can dramatically reduce total runtime while preserving the language’s familiar syntax. Try the example above, profile the execution, and then refactor a real‑world module that performs sequential network calls. By keeping the event loop active and handling errors explicitly, you’ll gain non‑blocking performance without leaving the Racket ecosystem.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.