Warning
This project was written entirely by AI (Claude), prompted by a non-developer.
It exists as a functional workaround for a personal project. It is not a proper ESPHome implementation and should not be treated as one. The component fills a genuine, long-standing gap in ESPHome:
- feature-requests#799 — "Non-blocking http requests" — open since July 2020, no milestone, no assignee.
- PR#7892 — "[http_request] don't block loop with actions" — the only serious upstream attempt, closed stale in February 2026.
If you are a real ESPHome developer reading this: please do this properly. The problem
is real, documented, and affects anyone using http_request with automations.
The code here was written as carefully as an AI conversation allows — with tests, documented design decisions, and a sensible architecture. It runs in production on real hardware. But it was not written by someone who knows the ESPHome internals, and it has not been reviewed by anyone who does. Use it at your own risk, and treat it as a reference for what a proper implementation might look like — not as the implementation itself.
Non-blocking HTTP requests for ESP32 + ESP-IDF. The ESPHome main loop never pauses.
The built-in http_request component holds the ESPHome main loop for the full
duration of every request — typically 50–4500 ms per call. During that window,
every sensor poll, BLE scan, switch debounce, and display refresh is suspended.
http_request (built-in):
Main loop: ──── [████████████ HTTP 380ms ████████████] ──── sensors ──▶
everything else stops here ↑
http_request_async (this):
Main loop: ──── sensors / BLE / switches / display ──────────────────▶
Worker: ──── [HTTP 380ms] ──── idle ─────────────────────────────▶
Automation: │←── waiting ──→│ continue next step
Requests run on a dedicated FreeRTOS worker task. Inside an automation, the action
pauses the chain at the HTTP step and resumes it when the response arrives — so
sequential scripts (http post → script.execute) work exactly as written.
- ESP32 (any variant)
framework: type: esp-idf
Pinned release (recommended):
external_components:
- source:
type: git
url: https://github.com/Vaarlion/esphome-http-request-async
ref: esphome-2026.10
components: [http_request_async]Tags follow esphome-YYYY.MM. A tag is published only after make test && make compile
passes against that ESPHome version and the hardware test suite passes on a real
ESP32. If a bug is fixed after release, a patch tag is published (e.g. esphome-2026.10.1).
Latest main (tracks the current ESPHome release):
external_components:
- source:
type: git
url: https://github.com/Vaarlion/esphome-http-request-async
ref: main
components: [http_request_async]| Tag | ESPHome |
|---|---|
esphome-2026.5 |
2026.5.x |
esphome-2026.6 |
2026.6.x |
esphome-2026.7 |
2026.7.x |
esphome-2026.8 |
2026.8.x |
esphome-2026.9 |
2026.9.x |
esphome-2026.10 |
2026.10.x |
http_request_async:
id: http_client # optional; auto-generated if omitted
timeout: 4.5s # per-request timeout
verify_ssl: true # validate TLS certificates
follow_redirects: true # follow HTTP 3xx responses
redirect_limit: 3 # max redirects to follow
buffer_size_rx: 512 # ESP-IDF receive buffer in bytes
buffer_size_tx: 512 # ESP-IDF transmit buffer in bytes
task_count: 1 # concurrent worker tasks (1–8)
task_stack_size: 8192 # FreeRTOS stack per worker in bytes
task_priority: 5 # FreeRTOS priority (1–24)
useragent: "MyDevice/1.0" # defaults to ESPHome/<version>
ca_certificate_path: ca.pem # PEM for custom/self-signed TLS| Key | Default | Description |
|---|---|---|
timeout |
4.5s |
Fires on_error when exceeded. |
verify_ssl |
true |
Set false for self-signed certs or plain HTTP. See note below. |
follow_redirects |
true |
|
redirect_limit |
3 |
|
buffer_size_rx |
512 |
Increase if server returns large headers. |
buffer_size_tx |
512 |
Increase for large request bodies. |
task_count |
1 |
See Concurrency. |
task_stack_size |
8192 |
Decrease only if RAM is very tight. |
task_priority |
5 |
Higher = preempts lower-priority tasks. |
ca_certificate_path |
— | If omitted and verify_ssl: true, uses ESP-IDF's common-CAs bundle (covers ~99% of public sites). For uncommon CAs set esp32: framework: advanced: use_full_certificate_bundle: true. |
- http_request_async.get:
url: !lambda 'return str_sprintf("http://%s/api/status", ip.c_str());'
capture_response: true
request_headers:
Authorization: "Bearer mytoken"
collect_headers:
- content-type
on_response:
then:
- lambda: |-
ESP_LOGI("http", "status=%d body=%s", response->status_code, body.c_str());
on_error:
then:
- logger.log: "Request failed"If the URL is a static string, the shorthand form works:
- http_request_async.get: "http://192.168.1.10/api/ping"JSON dict — for static or templatable key/value pairs:
- http_request_async.post:
url: "http://192.168.1.10/api/report"
request_headers:
Content-Type: application/json
json:
device: !lambda 'return App.get_name();'
value: "42"
on_error:
then:
- logger.log: "Post failed"JSON lambda — for nested objects, non-string values, or dynamic keys:
- http_request_async.post:
url: !lambda 'return str_sprintf("http://%s/rpc", ip.c_str());'
capture_response: true
request_headers:
Content-Type: application/json
json: |-
root["jsonrpc"] = "2.0";
root["method"] = "Light.Toggle";
root["params"]["id"] = light_id;
on_response:
then:
- logger.log:
format: "status=%d %u ms %s"
args: [ response->status_code, (unsigned) response->duration_ms, body.c_str() ]
on_error:
then:
- logger.log: "Post failed"- http_request_async.send:
method: PUT
url: "http://192.168.1.10/api/state"
body: '{"on": true}'
on_response:
then:
- logger.log:
format: "PUT → %d"
args: [ response->status_code ]Supported methods: GET, POST, PUT, DELETE, PATCH.
| Variable | Type | Notes |
|---|---|---|
response |
std::shared_ptr<HttpContainer> |
Always available |
response->status_code |
int |
HTTP status (200, 404, …) |
response->duration_ms |
uint32_t |
Total wall-clock time for the request. Cast to (unsigned) when logging with %u — uint32_t is unsigned long on ESP32/S2/S3 and unsigned int on C3/C6, so an uncast %u or %lu warns on one of them. |
response->content_length |
size_t |
Bytes in the body. From Content-Length header when present; derived from the captured body size for chunked responses; 0 if capture_response: false. |
response->get_response_header("name") |
std::string |
Case-insensitive; empty if not collected |
body |
std::string & |
Response body; only when capture_response: true |
Script/automation parameters (e.g. ip, light_id) are available inside
on_response and on_error lambdas — they are captured from the enclosing
automation context.
on_error fires when the request cannot be completed successfully:
| Condition | Example |
|---|---|
| Transport failure | DNS error, connection refused, TLS handshake failure |
| Timeout | Server did not respond within timeout |
| HTTP 4xx | 404 Not Found, 401 Unauthorized |
| HTTP 5xx | 500 Internal Server Error, 503 Unavailable |
| Not connected | WiFi not associated when the action fires |
| Queue full | More than 8 requests pending simultaneously |
on_response fires only for 2xx and 3xx responses.
The automation chain always continues after on_response or on_error — a
failed request does not leave the script stalled.
By default, response headers are discarded. List the ones you want to keep in
collect_headers:
- http_request_async.get:
url: "http://api.example.com/data"
capture_response: true
collect_headers:
- content-type
- x-request-id
on_response:
then:
- lambda: |-
auto ct = response->get_response_header("content-type");
auto rid = response->get_response_header("x-request-id");
ESP_LOGI("http", "ct=%s rid=%s", ct.c_str(), rid.c_str());Names in collect_headers are matched case-insensitively. get_response_header()
accepts any casing and returns an empty string for headers that were not collected.
The response body is buffered in RAM before the on_response callback fires.
The default limit is 1 kB. Increase it with max_response_buffer_size:
- http_request_async.get:
url: "http://192.168.1.10/api/large"
capture_response: true
max_response_buffer_size: 8192 # 8 kBBodies larger than the limit are silently truncated (a warning is logged). If you
only need the status code or headers, set capture_response: false to skip
buffering entirely.
With task_count: 1 (the default), requests run one at a time. A slow request
— a 10-second timeout, for example — blocks all queued requests for its full
duration.
Increase task_count to process multiple requests simultaneously:
http_request_async:
task_count: 2Each worker allocates task_stack_size bytes of DRAM at boot, regardless of
whether it is idle:
task_count |
DRAM (8 kB stack) | Concurrent | Typical use |
|---|---|---|---|
| 1 | 8 kB | 1 | Single periodic poll |
| 2 | 16 kB | 2 | Slow + fast endpoints in parallel |
| 3 | 24 kB | 3 | Mixed workloads (REST + telemetry + OTA check) |
| 4 | 32 kB | 4 | Multiple independent APIs |
The ESP32 has 320 kB of DRAM shared with the kernel, Wi-Fi stack, BLE, and application state. Available heap at boot is typically 180–250 kB depending on what else is enabled.
Rule of thumb: task_count: 2 is sufficient for most setups. Use 3–4 only
if you regularly fire requests from independent automations that overlap. The
request queue holds 8 entries — overflow fires on_error immediately.
Rename the hub and action prefixes:
| Before | After |
|---|---|
http_request: |
http_request_async: |
http_request.get: |
http_request_async.get: |
http_request.post: |
http_request_async.post: |
http_request.send: |
http_request_async.send: |
All configuration keys carry over unchanged.
One behaviour difference: the built-in component routes all completed
requests — including 4xx/5xx — through on_response. This component routes
4xx/5xx through on_error instead. Adjust any on_response handlers that
currently check response->status_code >= 400.
TLS is configured at the hub level, not per request. All requests share the
same verify_ssl setting and ca_certificate_path. Per-request CA
certificates are not supported.
If you need to talk to both verified and unverified endpoints, declare two hub
instances with different verify_ssl settings and route requests accordingly.
Once a request is enqueued it runs to completion or until it times out. Stopping
the parent script (script.stop, mode: restart) suppresses the on_response /
on_error triggers of every request it has in flight and does not resume the
chain, but does not abort the underlying network transfer — the worker task keeps
running.
The json: key–value shorthand only accepts string values:
- http_request_async.post:
json:
state: "ON" # fine
brightness: "128" # fine — must be a stringFor integers, booleans, arrays, or nested objects, use the lambda form instead:
- http_request_async.post:
json: |-
root["brightness"] = 128;
root["enabled"] = true;
root["tags"][0] = "sensor";When capture_response: true and the response exceeds max_response_buffer_size,
the body is silently truncated and a WARN is logged — but the callback receives
no flag indicating the data is incomplete. The typical symptom is a silent JSON
parse failure. Work around it by setting max_response_buffer_size large enough
for the largest response you expect, or by comparing body.size() against
response->content_length in the callback.
This is the same behaviour as the upstream http_request component. There is no
open ESPHome issue tracking it (#13669
was related but covered a different read-loop bug, now closed). See
Response body buffering for size guidance.
The pending-request queue holds 8 entries. A 9th enqueue fires on_error
immediately and drops the request. See Concurrency for
sizing and mitigation strategies.
Chaining a second request from inside the on_response of a
capture_response: true request works, and the outer response and body stay
usable in the nested action:
- http_request_async.get:
url: "http://device/status"
capture_response: true
on_response:
then:
- http_request_async.post:
url: "http://logger/report"
json:
upstream: !lambda 'return body;' # outer body — fineWhat does not compile is giving that nested action an on_response or
on_error of its own. ESPHome names trigger lambda parameters positionally and
appends the enclosing trigger's variables, so the inner lambda would be generated
with two parameters called response:
error: redefinition of 'std::shared_ptr<HttpContainer> response'
This is an ESPHome codegen limitation, not specific to this component — the
built-in http_request behaves identically. Work around it by moving the nested
request into its own script and calling script.execute, which gives the inner
triggers a clean parameter scope. Note that script.execute does not wait for the
script to finish; use script.wait afterwards if the chain must stay sequential.
The sequential-by-default guarantee (see Concurrency) applies to actions in
the same chain. A trigger's then: block is a separate automation: loop()
fires on_response and then immediately resumes whatever followed the request.
It does not wait for the trigger's own actions to finish.
So in this script, logger.log runs before the nested POST completes:
- http_request_async.get:
url: "http://device/status"
capture_response: true
on_response:
then:
- http_request_async.post: # still in flight …
url: "http://logger/report"
- logger.log: "done" # … when this already ranActions inside the then: block remain correctly ordered relative to each
other — anything after the nested POST there does wait for it. Only the outer
chain runs ahead. If the outer chain must wait too, set a global at the end of
the inner block and gate on it:
- wait_until:
condition:
lambda: 'return id(nested_done);'
timeout: 25sThis is inherent to how ESPHome triggers work and applies to the built-in
http_request as well; it is only more visible here because the nested request
takes real time without blocking the loop.
verify_ssl: false works by emitting two sdkconfig flags at compile time:
CONFIG_ESP_TLS_INSECURE and CONFIG_ESP_TLS_SKIP_SERVER_CERT_VERIFY. These
tell mbedTLS to skip the certificate chain check at the TLS layer.
If your project has a custom sdkconfig.defaults or sdkconfig.override that
re-enables server certificate verification or otherwise conflicts with these
flags, the conflict is resolved silently at build time by the ESP-IDF Kconfig
merge rules. Your YAML will say verify_ssl: false but TLS verification will
still be active.
Symptom: connections to servers with self-signed certificates fail with a
TLS handshake error even though verify_ssl: false is set.
Fix: inspect your custom sdkconfig files for CONFIG_ESP_TLS_VERIFY_* or
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE entries that re-enable verification, and
remove or reconcile them.
- Development guide — first-time setup, making changes, git workflow
- Testing guide — four-tier test strategy, running tests, hardware testing
- Shelly dimmer example — real-world Shelly API scripts