Native C11 Web Framework

The Simplicity of Express.js.
The Speed of Native C.

CExpress is a lightweight HTTP/1.1 web framework and server designed with Express.js ergonomics, with one single-threaded event loop per worker process, running directly on native BSD/macOS kqueue and Linux epoll (io_uring readiness polling opt-in).

1st
TechEmpower updates, queries, JSON & plaintext (results)
251,306
Req/Sec (Linux epoll, 4 Workers, /ping, 1,000 conns, 24 Sep)
393 Β΅s
Average Latency (macOS, 100 conns, 21 Sep)
~0.3 KB
RAM per Idle Keep-Alive Connection (was 25 KB)
226 ns
Hotpath CPU Cycle (make bench)
22 / 22
Passing Test Suites (CI: Linux + macOS)
172
Audited Engine Functions (Zero Drift)

πŸ† TechEmpower Framework Benchmarks

CExpress implements all six TechEmpower (TFB) test types and passes TFB's own verifier on every one. The runs below use TechEmpower's harness unmodified: their ./tfb toolset, their Postgres image and data, their wrk load generator, request mix and concurrency levels. Every competitor is built from TFB's own code in the same run, including Round 23's database leaders (may-minihttp, xitca-web). The database app sends every SQL statement with its own Sync, as TFB's rules require.

πŸ₯‡ 1st on updates, multiple queries, JSON and plaintext
26–27 Sep 2026
165,029 req/s
Updates, 1st at every query count Β· +2.7% to +6.6% over the next entry (actix-http 154,826 at 1 query)
329,366 req/s
Multiple queries, 1st at every count Β· 1 to 20 queries per request, ahead of may-minihttp, xitca-web and actix-http
787,754 req/s
JSON, 1st Β· actix 738,709, axum 727,775, h2o 699,260, Fiber 590,756
4.81M req/s
Plaintext (pipelined Γ—16), 1st Β· actix 4.41M; pipelined responses are coalesced into one write
Updates, 1 query per request (req/s, best concurrency level) Run 10 Β· 27 Sep 2026
⚑ CExpress (C)
165,029
actix-http (Rust)
154,826
h2o (C)
148,476
may-minihttp (Rust)
146,817
xitca-web (Rust)
146,809
axum-pg (Rust)
143,973
xitca-web-barebone (Rust)
141,732
Fiber (Go)
68,815
JSON serialization (req/s, best concurrency level) Run 4 Β· 26 Sep 2026
⚑ CExpress (C)
787,754
actix (Rust)
738,709
axum (Rust)
727,775
h2o (C)
699,260
Fiber (Go)
590,756
Test CExpress (req/s) Best other Place Run
JSON787,754actix 738,7091st of 54
Plaintext (pipelined Γ—16)4,813,888actix 4,412,5591st of 54
db328,331xitca-web-barebone 332,2582nd of 8 (98.8%)10
Multiple queries, 1329,366may-minihttp 328,4121st of 810
Multiple queries, 5110,799xitca-web 104,0861st of 8 (+6.4%)10
Multiple queries, 2029,713actix-http 29,5821st of 8 (+0.4%)10
Fortunes310,625xitca-web-barebone 329,5954th of 8 (94.2%)10
Updates, 1165,029actix-http 154,8261st of 8 (+6.6%)10
Updates, 2020,641xitca-web 20,1071st of 8 (+2.7%)10

Best req/s over TFB's concurrency levels. Run 10 had eight entries (CExpress, actix-http, axum-pg, h2o, Fiber, may-minihttp, xitca-web, xitca-web-barebone); run 4, the last run that measured JSON and plaintext, had five (CExpress, actix, axum, h2o, Fiber). On db and on multiple queries at 1, the top three (CExpress, may-minihttp, xitca-web-barebone) are within 2.4% and 0.8%, a tie; fortunes is the one test where CExpress trails the Round 23 leaders.

How to read these numbers. Server, Postgres and wrk all shared one 10-CPU VM on an Apple Silicon laptop, where an official TechEmpower round uses three dedicated 28-core Xeon machines on 10 GbE. The method is TFB's own and every framework ran in the same run, so the ranking within a run is a fair comparison. The absolute numbers are not comparable with TechEmpower's published rounds. Each configuration is a single run; differences of about 2% are inside noise. TechEmpower archived the project in March 2026, so these are local runs, not an official listing. Full per-test tables, setup and the rules check: techEmpV1.md.

πŸ“Š Performance & Stress Benchmarks

CExpress is engineered from the ground up for extreme throughput, deterministic microsecond latency, and tiny memory consumption. All benchmarks below were executed on one Apple Silicon machine (M3 Pro 12-core, 18 GB RAM), either on macOS directly or in Docker Desktop's Linux VM where marked, in multi-worker cluster mode (QUIET=1 WORKERS=4) using wrk over 8 threads.

When each figure was measured. All runs are single runs with wrk on the same machine as the server.
β€’ Standalone Engine Peak cards: 21 Sep 2026 (100 and 5,000 connections) and 23 Sep 2026 (1,000 connections, after the switch to one arena and one receive buffer per worker).
β€’ Memory tab: 21 Sep 2026 (before that switch) and 23 Sep 2026 (after it).
β€’ Ping, SQLite, Static, Churn and Hotpath tabs and the Linux spotlight: 24 Sep 2026, from scripts/stress_test.sh (macOS, 15 s per row, churn 2 s per row), scripts/docker_stress_test.sh (Linux epoll in Docker, 10 s per row, 100 and 1,000 connections) and make bench. The macOS machine was busy during this run (load average 7–8 before the benchmark started, sysmond at ~76% CPU), and wrk shares its cores, so the macOS /ping figures are lower than the 21–23 Sep ones. Two shorter re-runs gave 176–180k req/s at 100 connections and 191–193k at 1,000.
β€’ Framework comparison chart: an earlier engine revision; no competitor was re-measured since.
The 5,000-connection runs show intermittent wrk read errors of unidentified cause.
🐧 Linux epoll in Docker: scripts/docker_stress_test.sh
24 Sep 2026

The server (4 workers) and wrk (8 threads, built natively for arm64) each run in their own container on one private Docker network, inside Docker Desktop's Linux VM on the same M3 Pro. Traffic goes container to container. Each row is one 10 s run. Zero worker crashes; peak memory for master plus 4 workers was 11.8 MB in total (largest single process 2.6 MB).

251,306 req/s
/ping, 1,000 connections Β· 6.24 ms avg latency Β· 0 errors (240,019 req/s at 100 connections)
110,212 req/s
GET / (6.5 KB page), 100 connections Β· 2.92 ms avg Β· 694.54 MB/s
72,901 req/s
GET /api/todos (SQLite + JSON), 100 connections Β· 3.70 ms avg Β· POST: 7,891 req/s
🧠 Idle Connections Cost Almost Nothing
23 Sep 2026

A keep-alive connection that is waiting for its next request no longer owns a request arena or an input buffer. Each worker has one 64 KiB arena and one 8 KiB receive buffer that every connection shares. A connection only gets its own buffer while a request is half-received, and gives it back as soon as that request is answered. Throughput is unchanged.

239 B / conn
Idle keep-alive, after one request Β· was ~25 KB (βˆ’99%)
+1.4 MB
5,000 connections on a 4-worker cluster Β· was +122 MB
~24 MB
100,000 idle connections, projected from 239 B each Β· was ~2.5 GB (kernel socket buffers not included)
πŸš€ Standalone Engine Peak: wrk /ping (macOS, 21–23 Sep 2026)
QUIET=1 WORKERS=4 ./cexpress_demo

Measuring raw CExpress request-response dispatch over persistent keep-alive TCP sockets (fixed 4-byte response, zero database I/O, zero JSON overhead).

250,055 req/s
100 connections Β· 392.95 Β΅s avg latency Β· 21.94 MB/s
256,864 req/s
1,000 connections Β· 3.79 ms avg latency Β· 22.54 MB/s Β· 0 errors Β· best of 3 on 23 Sep (206k–257k)
238,859 req/s
5,000 connections Β· 14.72 ms avg latency Β· 2,219 wrk read errors
HTTP/1.1 Throughput Comparison (1,000 Connections, 4 Cores/Workers) Earlier revision Β· competitors not re-run
⚑ CExpress (C11)
260,000 req/s
260,000 req/s
Rust Actix-web (4 workers)
197,880 req/s
197,880 req/s
Node.js Express (4 workers)
191,093 req/s
191,093 req/s
Go stdlib (GOMAXPROCS=4)
189,735 req/s
189,735 req/s
Node.js Express (1 worker)
66,645 req/s
66,645 req/s
Zero Runtime Overhead
0 ns GC C11 stack
Direct kqueue / epoll readiness polling

The /ping endpoint measures raw server event-loop throughput and kernel socket multiplexing with zero disk or database overhead. The response is a pre-formatted 4-byte payload. macOS, scripts/stress_test.sh, 24 Sep 2026, 15 s per row, on a busy machine (see the note above); the 21–23 Sep peak on the same machine was 250–257k req/s.

Concurrency Threads Throughput Avg Latency Max Latency Data Transferred Stability
100 conns 8 158,730 req/s 1.94 ms 81.44 ms 19.53 MB/s (295.0 MB read) βœ“ 0 errors
1,000 conns 8 181,403 req/s 6.01 ms 139.25 ms 22.32 MB/s (337.0 MB read) βœ“ 0 errors
5,000 conns 8 172,327 req/s 36.22 ms 1.22 s 21.20 MB/s (320.1 MB read) 1,344 wrk read errors Β· 0 crashes
Engine Architecture: Non-blocking sockets driven by kqueue (macOS/BSD) or epoll (Linux; io_uring opt-in), one event loop per worker process, requests parsed with picohttpparser straight out of one 8 KiB receive buffer per worker and served from one 64 KiB arena per worker (not per connection); responses are written with plain write() calls.

Real-world CRUD benchmark against an embedded SQLite database in WAL (Write-Ahead Logging) mode with busy_timeout=5000. Four worker processes concurrently execute reads and writes against the same shared SQLite database file. macOS, scripts/stress_test.sh, 24 Sep 2026, 15 s per row; reads run against the 20 seeded rows, before any write phase.

Benchmark Phase Concurrency Throughput Avg Latency Max Latency Bandwidth Lock Errors
GET /api/todos (Read) 100 conns 154,644 req/s 705.68 Β΅s 39.03 ms 388.90 MB/s βœ“ 0 locks
GET /api/todos (Read) 1,000 conns 169,478 req/s 6.02 ms 71.47 ms 426.21 MB/s βœ“ 0 locks
GET /api/todos (Read) 5,000 conns 163,515 req/s 26.29 ms 446.36 ms 411.21 MB/s 994 wrk read errors
POST /api/todos (Write) 100 conns 20,818 req/s 96.22 ms 1.65 s 5.37 MB/s βœ“ 0 locks
POST /api/todos (Write) 1,000 conns 19,741 req/s 121.54 ms 1.25 s 5.10 MB/s 301 wrk read errors
POST /api/todos (Write) 5,000 conns 19,748 req/s 306.66 ms 1.81 s 5.10 MB/s 5,283 read errors, 3,008 timeouts
Fork-Safe SQLite & WAL: Each worker maintains a private SQLite connection opened after fork(). About 907,000 POST requests completed across the three write rows with zero worker crashes. The errors in the last column are reported by wrk on the client side; at 5,000 connections the average write latency (307 ms) approaches wrk's timeout, so some requests time out. Reads now run at about the /ping rate, which suggests the load generator, not SQLite, limits them on this machine.

Serves the responsive single-page Todo application HTML directly from disk via CExpress's bounded streaming engine (res_send_file). The page is 6,481 bytes. macOS, scripts/stress_test.sh, 24 Sep 2026, 15 s per row.

Concurrency Threads Throughput Avg Latency Max Latency Transfer Rate Total Read
100 conns 8 117,244 req/s 0.91 ms 44.01 ms 738.86 MB/s 10.90 GB
1,000 conns 8 118,832 req/s 8.39 ms 59.79 ms 748.87 MB/s 10.98 GB
5,000 conns 8 110,533 req/s 42.18 ms 366.67 ms 696.57 MB/s 10.25 GB (655 read errors)
Bounded Buffer Streaming: File content goes from the disk descriptor to the client socket with sendfile, or in 16 KB chunks where sendfile is unavailable, ensuring RAM consumption stays bounded even when saturation pushes throughput past 740 megabytes per second.

Benchmarks server performance when clients send Connection: close, requiring a full TCP handshake, HTTP request parsing, response delivery, and connection teardown on every single transaction. 24 Sep 2026. The macOS rows run for 2 s each, and the script waits for TIME_WAIT sockets to drain between them (13–19k were left after each run; draining took 31–32 s).

Benchmark Mode Concurrency New Conns / Second Avg Latency Max Latency Notes
GET /ping (Connection: close) 100 conns 35,445 conn/s 2.06 ms 88.53 ms 74,501 connections in 2 s Β· 0 connect errors
GET /ping (Connection: close) 1,000 conns 23,627 conn/s 4.82 ms 93.39 ms 48,505 connections in 2 s Β· 0 connect errors
GET /ping (Connection: close) 5,000 conns 22,542 conn/s 6.14 ms 39.89 ms 46,175 connections in 2 s Β· 0 connect errors
Linux epoll in Docker 100 conns 47,132 conn/s 3.52 ms 150.18 ms 10 s run, container to container
Linux epoll in Docker 1,000 conns 43,806 conn/s 10.99 ms 210.85 ms 10 s run, container to container
TIME_WAIT & Port Exhaustion: At 1,000+ connections with Connection: close, client load generators on macOS quickly exhaust the ~16k ephemeral port range because closed sockets linger in TIME_WAIT for 30–60 seconds. For maximum performance in production, always leverage persistent HTTP keep-alive.

Resident set size (RSS) was measured with ps across the master and all 4 workers after opening N keep-alive connections from a script and sending one GET /ping on each (macOS). The 21 Sep column is the engine before the shared arena and receive buffer; the 23 Sep columns are the current one, measured the same way.

Execution Phase Total Cluster RSS, 21 Sep Total Cluster RSS, 23 Sep Largest Single Worker, 23 Sep Memory Scaling Behavior (23 Sep)
Idle (boot) 11.5 MB 11.3 MB 2.2 MB Master + 4 pre-forked workers
100 keep-alive conns 17.1 MB 11.8 MB 2.3 MB Connections now spread evenly across workers
1,000 keep-alive conns 38.5 MB 11.9 MB 2.3 MB Growth is within rounding
5,000 keep-alive conns 133.7 MB 12.7 MB 2.5 MB About 0.3 KB per connection, down from about 25 KB
Where the 25 KB went: it used to be a 64 KiB arena per connection (the touched part) plus an 8 KiB input buffer per connection. Now each worker has one shared arena and one shared 8 KiB receive buffer. A connection copies bytes into a buffer of its own only while a request is half-received, and frees it once the request is answered. A more precise single-worker run (5,000 connections) measured 239 B per idle keep-alive connection and 216 B for one that never sent a byte. /ping throughput did not change. Kernel socket buffers are not part of RSS.
Under load (24 Sep 2026 stress runs): on macOS the cluster idled at 13.0 MB (5 processes) after startup and seeding, and peaked at 18.3 MB in total (largest single process 4.2 MB) during the 5,000-connection POST row, after about 907,000 inserts. /usr/bin/time -l reported a maximum RSS of 4.2 MB (4,407,296 bytes) for the largest process, which matches the ps sampler. In the Linux Docker run the whole cluster stayed between 11.2 and 11.8 MB. Zero worker crashes in either run.
Memory safety: all 22 suites pass under AddressSanitizer and UndefinedBehaviorSanitizer and the make fuzz mutation fuzz run finished clean. LeakSanitizer is not available on macOS, so no leak check has been run on this platform.

Measured with make bench (compiled with -O2) on 24 Sep 2026, single run. Gauges the raw CPU cycle cost of the core framework pipeline (parsing, routing, dispatching, response generation) isolated from operating system socket I/O.

Pipeline Operation Latency per Request Single-Core Theoretical Capacity Dynamic Allocations
Minimal GET (2 headers) 226 ns ~4,400,000 requests / sec 0 heap allocs
404 Route Miss 318 ns ~3,100,000 requests / sec 0 heap allocs
POST with JSON Body 244 ns ~4,100,000 requests / sec 0 heap allocs
Browser GET (10 headers, cookies, query) 763 ns ~1,310,000 requests / sec 0 heap allocs
…same, handler reads a cookie and a header 770 ns ~1,300,000 requests / sec 0 heap allocs
yyjson (20-row list serialization) 696 ns Default (libc) allocator in the benchmark Heap allocations (not the arena)
Arena Allocation: sizeof(Request) = 9,792 B, sizeof(Response) = 10,160 B, sizeof(Connection) = 288 B. Per-request data (request body, response bytes, yyjson documents) comes from one 64 KiB bump-allocator arena per worker, so the request path needs no malloc() or free() unless a request outgrows the arena. These figures exclude socket I/O and use a static arena.
bash β€” Reproduce Benchmarks Locally
# 1. Build release binary
make clean && make demo

# 2. Launch 4-worker cluster in background
cd examples/todo_sqlite && QUIET=1 WORKERS=4 ./cexpress_demo

# 3. In another terminal, run standalone wrk benchmarks on /ping
wrk -t8 -c100 -d15s http://127.0.0.1:8080/ping
wrk -t8 -c1000 -d15s http://127.0.0.1:8080/ping

# 4. Or run the full automated stress battery with memory profiling
./scripts/stress_test.sh

# 5. Same battery against Linux (epoll; CEXPRESS_EVENT_LOOP=io_uring for io_uring) in Docker
./scripts/docker_stress_test.sh

# 6. Pure request-path microbenchmark (no sockets)
make bench

πŸ›‘οΈ Reliability, Hardening & Test Matrix

High throughput is meaningless without correctness and rock-solid memory safety. CExpress is rigorously verified across 22 dedicated automated test suites, AddressSanitizer and UndefinedBehaviorSanitizer instrumentation, continuous mutation fuzzing, and automated API documentation audits.

22 / 22
Automated Test Suites Passing
172 / 172
Public APIs Audited (check_docs.sh)
1,000,000+
Mutation Fuzz Iterations Clean
ASan + UBSan
All Suites Clean Under Sanitizers

Automated Test Suites (make test)

test_middleware βœ“ Passing
Pipeline ordering, next() sequencing, prefix-scoped middleware, route-level guards, and centralized error handler invocation.
test_router βœ“ Passing
Exact route matching, parameterized paths (:id), wildcard capturing (*), sub-router mounting via app_mount().
test_http_parser βœ“ Passing
HTTP/1.1 request parsing on picohttpparser, header and query decoding, chunked bodies, and chunked transfer decoding.
test_http_hardening βœ“ Passing
RFC 7230 compliance: request smuggling defense, conflicting Content-Length & Transfer-Encoding rejection, CRLF header injection guards.
test_connection βœ“ Passing
Non-blocking TCP state machine, EAGAIN/EWOULDBLOCK retry loops, buffer overflow protection, keep-alive connection reuse.
test_response βœ“ Passing
Header serialization, cookie attributes (HttpOnly, SameSite, Secure, Max-Age), chunked body streaming, trailers.
test_multipart βœ“ Passing
Streaming multipart/form-data parser, MIME boundary matching, file upload fields, and truncated payload resilience.
test_urlencoded βœ“ Passing
application/x-www-form-urlencoded decoding, URL query string parameter extraction, and percent-encoding normalization.
test_static βœ“ Passing
Static asset delivery, MIME resolution, index.html fallbacks, and directory traversal defense (blocking ../ and null bytes).
test_event_loop βœ“ Passing
Unified event loop abstraction verifying kqueue (macOS/BSD) and epoll / io_uring (Linux) readiness registration and timers.
test_cluster βœ“ Passing
Multi-process cluster management, SO_REUSEPORT multi-bind, automatic worker crash recovery, and graceful drain on SIGTERM.
test_cookbook βœ“ Passing
End-to-end recipes from the CExpress Cookbook: authentication tokens, API keys, body parsing, custom responses, and sub-routers.
test_ping βœ“ Passing
Fast-path lightweight endpoint verification validating 4-byte response format, headers, and keep-alive stability.

Automated Documentation Parity (scripts/check_docs.sh)

To prevent documentation drift, check_docs.sh (invoked via make check-docs) audits the entire public C API against the documentation:

172 of 172 Public Functions Covered: Fails immediately if any function declared in lib/*.h is missing from the documentation, or if the documentation references any function not declared in a header.

Memory Sanitizers & Fuzzing (make SANITIZE=1 test & make fuzz)

Memory safety in C requires rigorous automated verification. The entire suite can be compiled under AddressSanitizer and UndefinedBehaviorSanitizer:

bash
# Run entire test suite under ASan + UBSan
make clean && make SANITIZE=1 test

# Run mutation fuzzer on HTTP parser (1,000,000 iterations)
make fuzz

πŸ“¦ Installation & Umbrella Header

CExpress exposes its entire public API via a single umbrella header:

c
/* Includes routing, response builders, middlewares, HTTP parser, static files, the arena allocator and yyjson */
#include "cexpress.h"

Compile and link directly against the static library libcexpress.a:

bash
gcc-16 -Wall -Wextra -std=c11 -O2 -Ipath/to/lib -o my_app main.c path/to/libcexpress.a   # Linux: add -D_GNU_SOURCE and -luring

πŸš€ Hello World

A minimal server with a single route listening on port 8080:

c
#include "cexpress.h"
#include <stdio.h>

void handler_home(const Request *req, Response *res) {
    (void)req;
    res_send(res, "Hello from CExpress!");
}

int main(void) {
    App app;
    app_init(&app);

    app_get(&app, "/", handler_home);

    printf("Listening on http://localhost:8080\n");
    app_listen(&app, 8080);

    app_destroy(&app);
    return 0;
}

🧭 Routing & HTTP Verbs

Handlers are defined with the signature void (*Handler)(const Request *req, Response *res). Register handlers for standard HTTP verbs:

c
app_get(&app, "/users", handler_get_users);
app_post(&app, "/users", handler_create_user);
app_put(&app, "/users/:id", handler_update_user);
app_patch(&app, "/users/:id", handler_patch_user);
app_delete(&app, "/users/:id", handler_delete_user);
Automatic Protocol Conformance:
β€’ HEAD Requests: Executes the matching GET handler while automatically suppressing the message body.
β€’ OPTIONS Requests: Returns 200 OK with the computed Allow header.
β€’ 405 Method Not Allowed: Emitted automatically if the route path exists but the verb is not registered.

Route Parameters (:id)

Extract URL path variables with req_get_param:

c
app_get(&app, "/orgs/:org/repos/:repo", handler_repo);

void handler_repo(const Request *req, Response *res) {
    const char *org  = req_get_param(req, "org");
    const char *repo = req_get_param(req, "repo");

    char body[128];
    snprintf(body, sizeof(body), "Repository: %s/%s", org, repo);
    res_send(res, body);
}

Sub-Routers (app_mount)

Modularize routes into isolated Router instances and mount them under path prefixes (like Express's app.use('/api', apiRouter)):

c
Router api_router;
router_init(&api_router);

/* Scoped to /api */
router_get(&api_router, "/health", handler_health);
router_get(&api_router, "/metrics", handler_metrics);

/* Mounts routes to /api/health and /api/metrics */
app_mount(&app, "/api", &api_router);

πŸ›‘οΈ Middleware Pipeline

Middleware functions match void (*Middleware)(const Request *req, Response *res, MiddlewareChain *chain). Advance with chain_next(chain) or terminate early:

c
void mw_logger(const Request *req, Response *res, MiddlewareChain *chain) {
    /* 1. Pre-handler processing */
    chain_next(chain);

    /* 2. Post-handler inspection (res->status is finalized) */
    printf("%s %s -> %d\n", req->method, req->path, res->status);
}

/* Register app-wide middleware */
app_use(&app, mw_logger);

/* Register prefix-scoped middleware (only runs for /admin/*) */
app_use_prefix(&app, "/admin", mw_admin_auth);

/* Register per-route middleware */
app_post_mw(&app, "/checkout", handler_checkout, (Middleware[]){mw_auth}, 1);

Centralized Error Handling

Trigger centralized error handling with chain_error:

c
void mw_guard(const Request *req, Response *res, MiddlewareChain *chain) {
    if (!is_authorized(req)) {
        chain_error(chain, 401, "Invalid or missing API key");
        return;
    }
    chain_next(chain);
}

/* Register custom error handler */
void custom_error_handler(int status, const char *msg, const Request *req, Response *res) {
    (void)req;
    res_status(res, status);
    char json[256];
    snprintf(json, sizeof(json), "{\"error\":\"%s\",\"status\":%d}", msg, status);
    res_json(res, json);
}

app_use_error(&app, custom_error_handler);

πŸ“₯ Request & Response API

Inspect headers, cookies, query strings, and body data:

c
void handler_inspect(const Request *req, Response *res) {
    const char *query_sort = req_get_query(req, "sort");
    const char *auth_hdr   = req_get_header(req, "Authorization");
    const char *session    = req_get_cookie(req, "session_id");

    /* Set status and custom headers */
    res_status(res, 200);
    res_set_header(res, "X-Engine", "CExpress");

    /* Send JSON */
    res_json(res, "{\"status\":\"ok\"}");
}

Cookies & Redirects

c
/* Setting a cookie with SameSite, Max-Age, and HttpOnly */
CookieOptions opts = {
    .max_age = 86400,
    .http_only = 1,
    .secure = 1,
    .same_site = COOKIE_SAMESITE_STRICT
};
res_set_cookie(res, "auth_token", "secure_secret", &opts);

/* Clearing a cookie */
res_clear_cookie(res, "auth_token", "/");

/* Performing a 302 Redirect */
res_redirect(res, 0, "/dashboard");

🌊 Streaming & Chunked Responses

Emit HTTP/1.1 chunked responses incrementally using res_write(), attach RFC 7230 chunked trailers with res_set_trailer(), and finalize with res_end():

c
void handler_stream(const Request *req, Response *res) {
    (void)req;
    res_set_header(res, "Content-Type", "text/plain");

    /* Stage trailer (declares Trailer: Server-Timing in headers) */
    res_set_trailer(res, "Server-Timing", "app;dur=24.2");

    /* Write chunks incrementally */
    res_write(res, "Chunk 1\n", 8);
    res_write(res, "Chunk 2\n", 8);

    /* Emits terminal 0\r\n, trailers, and terminating CRLF */
    res_end(res);
}

Bounded File Streaming (res_send_file)

Stream large files directly from the filesystem through the non-blocking event loop (sendfile, or bounded 16KB chunks where it is unavailable). Memory is bounded and whole files are never buffered into RAM:

c
void handler_download(const Request *req, Response *res) {
    (void)req;
    if (res_send_file(res, "application/pdf", "reports/report.pdf") != 0) {
        res_status(res, 404);
        res_send(res, "Report not found");
    }
}

Static File Serving (app_serve_static)

Mount a directory of static assets with traversal protection and MIME type detection:

c
/* GET /static/app.css -> serves public/app.css */
app_serve_static(&app, "/static", "public");

πŸ“‹ Multipart & Form Parsing

Parse multipart/form-data file uploads and application/x-www-form-urlencoded forms:

c
/* Multipart File Uploads */
const char *content_type = req_get_header(req, "Content-Type");
char boundary[128];
MultipartForm form;
if (content_type == NULL || !multipart_parse_boundary(content_type, boundary, sizeof(boundary)) ||
    parse_multipart_body(req->body, (size_t)req->content_length, boundary, &form) < 0) {
    res_status(res, 400);
    res_send(res, "Bad multipart body");
    return;
}

for (int i = 0; i < form.part_count; i++) {
    if (form.parts[i].filename[0] != '\0') {
        printf("File uploaded: %s (%zu bytes)\n", form.parts[i].filename, form.parts[i].data_len);
    }
}

⚑ JSON with yyjson

CExpress vendors yyjson and includes it from cexpress.h. Give it the shared per-worker arena (res->conn->arena, already a pointer) as its allocator so documents need no freeing; the string returned by yyjson_mut_write comes from libc and must be freed:

c
/* Parse incoming JSON */
yyjson_alc alc = arena_yyjson_alc(res->conn->arena);
yyjson_doc *doc = yyjson_read_opts(req->body, (size_t)req->content_length, 0, &alc, NULL);
if (doc != NULL) {
    const char *name = yyjson_get_str(yyjson_obj_get(yyjson_doc_get_root(doc), "name"));
    /* name points into the arena: use it before the handler returns */
}

/* Construct and send JSON */
yyjson_mut_doc *out = yyjson_mut_doc_new(&alc);
yyjson_mut_val *obj = yyjson_mut_obj(out);
yyjson_mut_doc_set_root(out, obj);
yyjson_mut_obj_add_str(out, obj, "status", "active");
char *text = yyjson_mut_write(out, 0, NULL); /* libc-malloc'd */
if (text != NULL) { res_json(res, text); free(text); }

πŸ›‘ Graceful Shutdown

CExpress intercepts SIGINT and SIGTERM synchronously in the native event loop (EVFILT_SIGNAL on kqueue, signalfd on Linux):

1. Stops accepting incoming TCP connections.
2. Closes idle keep-alive sockets immediately.
3. Sets Connection: close on active responses and streams until flushed.
4. Arms a 5-second deadline timer to prevent stalled clients from blocking process exit.
5. Returns cleanly to main() so app_destroy() frees all memory.

πŸ”’ TLS / HTTPS

CExpress is plaintext HTTP/1.1 only. TLS support was removed from the engine on 22 Sep 2026: TLS termination belongs at a gateway or reverse proxy in front of it (nginx, an ALB, a sidecar), not inside a library that only parses HTTP/1.1.

Behind a proxy on the same host: set app.config.bind_address = "127.0.0.1" (the demo: BIND_ADDRESS=127.0.0.1) so the plaintext port cannot be reached around the proxy. The default listens on every IPv4 interface.

πŸ“– API Cheat Sheet

Function Header Description
app_init(App *app) router.h Initializes server app and dynamic connection table.
app_listen(App *app, int port) connection.h Starts event loop (delegates to cluster if workers > 1).
app_listen_cluster(App *app, port, n) connection.h Launches multi-process cluster of n workers (SO_REUSEPORT).
cluster_listen(App *app, port, n) cluster.h Cluster supervisor managing worker processes and crash recovery.
app_destroy(App *app) connection.h Closes sockets and frees connection tables cleanly.
app_get / app_post / app_put ... router.h Registers route handlers for specific HTTP methods.
app_mount(App *app, prefix, Router *r) router.h Mounts a sub-router under a URL prefix.
app_serve_static(App *app, pfx, root) router.h Mounts static file directory with traversal protection.
app_use(App *app, Middleware mw) router.h Registers an app-wide middleware.
app_use_prefix(App *app, pfx, mw) router.h Registers prefix-scoped middleware (e.g. /api).
chain_next(MiddlewareChain *c) middleware.h Advances to next middleware or route handler.
chain_error(c, status, message) middleware.h Delegates to centralized error handler.
res_status(Response *res, int status) response.h Sets HTTP status code (200, 201, 404, etc.).
res_set_header(res, name, value) response.h Sets a custom response header.
res_send(Response *res, const char *b) response.h Sends text/plain body.
res_json(Response *res, const char *b) response.h Sends application/json body.
res_write(Response *res, data, len) response.h Emits incremental chunk in chunked response.
res_set_trailer(res, name, value) response.h Stages RFC 7230 trailer for chunked transfer.
res_end(Response *res) response.h Finalizes chunked response and emits trailers.
res_send_file(res, type, filepath) response.h Streams a file through the event loop (sendfile, or bounded 16KB chunks).
req_get_param(req, name) http_parser.h Retrieves extracted route path variable.
req_get_query(req, name) http_parser.h Retrieves parsed query-string parameter.
req_get_header(req, name) http_parser.h Looks up request header (case-insensitive).
req_get_cookie(req, name) http_parser.h Extracts cookie value from Cookie header.