Tutorials

Publish to MQTT from AWS Lambda and Serverless Functions

Publish MQTT messages from AWS Lambda: reuse connections across warm invocations, survive freezes and cold starts, pick unique client IDs, or use an HTTP bridge.

Updated · 9 min read

Publish to MQTT from AWS Lambda and Serverless Functions

You can publish to an MQTT broker from AWS Lambda (or any serverless function) with an ordinary MQTT client library — the catch is the lifecycle. A function lives for a few hundred milliseconds, is frozen between invocations and may run in dozens of parallel copies. Connect lazily, cache the client across warm invocations, wait for the publish acknowledgement before returning, and give every execution environment its own client ID. If you only ever publish, an HTTP bridge can be even simpler.

This tutorial shows a production-ready Python handler (paho-mqtt 2.x), the same idea in Node.js, the HTTP alternative, and how to test the whole thing.

The serverless lifecycle versus MQTT

MQTT assumes long-lived TCP connections with keep-alive pings. Lambda assumes short, stateless executions. The mismatch shows up in four places:

Lambda behaviourEffect on an MQTT client
Cold start (new execution environment)Full TCP + TLS + CONNECT handshake before the first publish
Warm invocation (environment reused)Globals survive, so a cached connection can be reused
Freeze between invocationsBackground threads stop; no keep-alive pings are sent
Parallel environments under loadMany simultaneous clients, each needing its own client ID

The freeze is the subtle one. If an environment sits idle for longer than about 1.5× the keep-alive interval, the broker closes the connection. When the environment thaws, the client still believes it is connected until it tries to write. A naive cached client will therefore fail or hang on the first invocation after a quiet period.

Strategy 1: connect per invocation

The simplest correct pattern: connect, publish, wait for PUBACK, disconnect. Nothing survives between calls, so nothing can go stale.

  • Pros: no stale-connection bugs, no client ID collisions if you use a random ID, easy to reason about.
  • Cons: every invocation pays the connection handshake — noticeably more with TLS and from a distant region — and a burst of invocations becomes a burst of new connections on the broker.

It is a fine choice for low-volume triggers: a nightly job, an S3 upload notification, an occasional webhook.

Strategy 2: reuse the connection across warm invocations

For anything frequent, create the client in the global scope so warm invocations skip the handshake, and treat the cached connection with suspicion after an idle gap.

handler.py
import json, os, threading, time, uuid
import paho.mqtt.client as mqtt

HOST = os.environ.get("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.environ.get("MQTT_PORT", "8883"))
KEEPALIVE = 60
MAX_IDLE = KEEPALIVE            # after this gap, assume the broker dropped us

_client = None
_connected = threading.Event()
_last_used = 0.0

def _on_connect(client, userdata, flags, reason_code, properties):
    if reason_code.is_failure:
        print(f"connect refused: {reason_code}")
    else:
        _connected.set()

def _on_disconnect(client, userdata, flags, reason_code, properties):
    _connected.clear()

def _new_client():
    c = mqtt.Client(
        mqtt.CallbackAPIVersion.VERSION2,
        client_id=f"lambda-{uuid.uuid4().hex[:12]}",   # unique per environment
        protocol=mqtt.MQTTv5,
    )
    c.username_pw_set(os.environ["MQTT_USER"], os.environ["MQTT_PASS"])
    c.tls_set()                                         # system CA bundle
    c.on_connect, c.on_disconnect = _on_connect, _on_disconnect
    c.connect_async(HOST, PORT, keepalive=KEEPALIVE)
    c.loop_start()
    return c

def _get_client(timeout=5.0):
    global _client
    if _client is not None and time.time() - _last_used > MAX_IDLE:
        _client.disconnect()                            # stale after a freeze
        _client.loop_stop()
        _connected.clear()
        _client = None
    if _client is None:
        _client = _new_client()
    if not _connected.wait(timeout):
        raise TimeoutError("MQTT connect timed out")
    return _client

def handler(event, context):
    global _last_used
    client = _get_client()
    payload = json.dumps({"source": "lambda", "event": event, "ts": time.time()})
    info = client.publish("testmqtt/lambda/events", payload, qos=1)
    info.wait_for_publish(timeout=5)
    if not info.is_published():
        raise RuntimeError("PUBACK not received")       # let Lambda retry
    _last_used = time.time()
    return {"statusCode": 200, "body": json.dumps({"mid": info.mid})}

What each piece does:

  • connect_async + loop_start — the network loop runs in a background thread, which handles CONNACK, PUBACK and reconnects while the function is running.
  • MAX_IDLE — if the environment was idle longer than the keep-alive, the code discards the client and reconnects instead of writing into a dead socket.
  • wait_for_publish — never return before the QoS 1 acknowledgement arrives. Once the handler returns, the environment may be frozen with the message still in the client's buffer.
  • Raising on failure — for asynchronous triggers, Lambda retries failed invocations, which is exactly what you want for a lost publish.
  • Random client ID — two concurrent environments with the same ID would keep disconnecting each other (duplicate client ID).

Package it with pip install paho-mqtt -t package/ and zip the folder with the handler, or use a Lambda layer. Set the function timeout comfortably above the connect timeout (10–15 s is a reasonable starting point), and store credentials in environment variables backed by Secrets Manager or Parameter Store.

The same pattern in Node.js

With MQTT.js 5 the cached client is a module-level variable. The library reconnects on its own, but after a freeze you should still verify the connection before publishing.

index.mjs
import mqtt from 'mqtt';
import { randomUUID } from 'node:crypto';

let client;
let lastUsed = 0;

async function getClient() {
  if (client && Date.now() - lastUsed > 60_000) { await client.endAsync(true); client = undefined; }
  if (!client) {
    client = await mqtt.connectAsync('mqtts://broker.connectmq.com:8883', {
      clientId: `lambda-${randomUUID().slice(0, 12)}`,
      username: process.env.MQTT_USER,
      password: process.env.MQTT_PASS,
      keepalive: 60,
      connectTimeout: 5000,
    });
  }
  return client;
}

export const handler = async (event) => {
  const c = await getClient();
  await c.publishAsync('testmqtt/lambda/events', JSON.stringify({ source: 'lambda', event }), { qos: 1 });
  lastUsed = Date.now();
  return { statusCode: 200 };
};

publishAsync with QoS 1 resolves only after the PUBACK, so returning after the await is safe.

Strategy 3: publish over HTTP instead

If your functions only publish and never need MQTT semantics on the client side, an HTTP request is a natural fit for serverless: no connection state, no client IDs, no freeze problems.

  • Broker HTTP APIs. Some brokers expose a REST endpoint that publishes a message on your behalf — for example EMQX's HTTP API, and AWS IoT Core's HTTPS data-plane endpoint. Check your broker's documentation for authentication and payload format.
  • Your own bridge. A tiny always-on service that holds one MQTT connection and accepts authenticated POST /publish requests. It centralises credentials and turns a thousand short-lived Lambda connections into one persistent one.

The trade-off: you lose end-to-end QoS — the HTTP 200 tells you the bridge accepted the message, not that a subscriber received it — and you add a component to operate. For a broader comparison of the two protocols see MQTT vs HTTP.

What about subscribing?

A Lambda function cannot be a long-running MQTT subscriber: it only runs while handling an invocation. Reverse the direction instead — let something always-on subscribe and invoke the function:

  • A broker rule or integration that forwards matching messages to an HTTP endpoint (API Gateway or a Lambda function URL). See MQTT to webhook for the general pattern.
  • A small subscriber service that pushes messages to SQS, which then triggers Lambda in batches.

Common errors

  • Task timed out after 3.00 seconds — the default Lambda timeout is shorter than a cold TLS connect from another region. Raise it, and check that the broker port is reachable.
  • Connect hangs in a VPC — VPC-attached functions need a NAT gateway for internet egress. See MQTT connection timeout.
  • Not authorized / reason code 0x87 — wrong credentials or an ACL that does not allow the topic; the reason code reference lists the exact meaning.
  • Messages missing after the function returned — you returned before the PUBACK. Always wait for the publish to complete.

Testing it

  1. Open the online MQTT client, connect to broker.connectmq.com and subscribe to testmqtt/lambda/#.
  2. Invoke the function from the console or with aws lambda invoke --function-name mqtt-publisher --payload '{"hello":"world"}' --cli-binary-format raw-in-base64-out out.json.
  3. The message should appear in the browser within a second. Invoke again immediately: the warm invocation should be noticeably faster in the function's duration metric.
  4. Wait a few minutes, invoke again and confirm the stale-connection path reconnects cleanly instead of timing out.

Watch your Lambda's messages arrive

The shared test endpoint is rate-limited and keeps no sessions, which is fine for this check; for load tests with many parallel invocations, point the function at your own private broker instance.

Serverless and MQTT work well together once you respect the lifecycle: connect lazily, distrust connections after a freeze, wait for the acknowledgement and give each environment its own identity. When you only need fire-and-forget publishing at scale, an HTTP bridge removes the connection management entirely.

Frequently asked questions

Can an AWS Lambda function subscribe to MQTT topics?

Not in a useful way. A function only runs while handling an invocation and is frozen in between, so it cannot hold a subscription. Let a long-running service or broker integration subscribe and invoke the function over HTTP or a queue.

Should I open a new MQTT connection on every Lambda invocation?

It is the simplest correct approach, but every call pays the TCP and TLS handshake. Caching the client in the global scope removes that cost on warm invocations, as long as you detect connections that died while the environment was frozen.

Why does my Lambda publish time out from inside a VPC?

Functions attached to a VPC have no internet access unless the subnet routes through a NAT gateway (or the broker is reachable privately). Without it, the TCP connect to the broker hangs until the function times out.