diff --git a/components/modules/Kconfig b/components/modules/Kconfig
index 7741e37ba..0e4463830 100644
--- a/components/modules/Kconfig
+++ b/components/modules/Kconfig
@@ -126,6 +126,14 @@ menu "NodeMCU modules"
             Includes the HTTPD module. This module uses the regular IDF
             http server component internally.
 
+    config NODEMCU_CMODULE_HTTPD_WS
+        bool "Include websocket support." if NODEMCU_CMODULE_HTTPD
+        default "n"
+        select HTTPD_WS_SUPPORT
+        help
+            Includes the websocket support. This module uses the regular IDF
+            http server component internally.
+
     config NODEMCU_CMODULE_HTTPD_MAX_RESPONSE_HEADERS
         int "Max response header fields" if NODEMCU_CMODULE_HTTPD
         default 5
diff --git a/components/modules/httpd.c b/components/modules/httpd.c
index 6fcbc299d..8fe96c881 100644
--- a/components/modules/httpd.c
+++ b/components/modules/httpd.c
@@ -73,6 +73,9 @@ typedef enum {
   READ_BODY_CHUNK,
   SEND_RESPONSE,
   SEND_PARTIAL_RESPONSE,
+  SEND_ERROR,
+  SEND_OK,
+  FREE_WS_OBJECT,
 } request_type_t;
 
 typedef struct {
@@ -99,6 +102,11 @@ typedef struct {
   const char *query_str;
   int method;
   size_t body_len;
+  httpd_req_t *req;
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+  httpd_ws_frame_t ws_pkt;
+  int reference;
+#endif
 } request_data_t;
 
 
@@ -107,6 +115,23 @@ typedef struct {
   uint32_t guard;
 } req_udata_t;
 
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+#define WS_DEBUG(...) 
+#define WS_METATABLE "httpd.ws"
+#define HTTP_WEBSOCKET 1234
+#define HTTP_WEBSOCKET_GET 1235
+
+typedef struct {
+  bool closed;
+  httpd_handle_t handle;
+  int fd;
+  int self_ref;       // only present if at least one callback registered
+  int self_weak_ref;
+  int text_fn_ref;
+  int binary_fn_ref;
+  int close_fn_ref;
+} ws_connection_t;
+#endif
 
 typedef enum { INDEX_NONE, INDEX_ROOT, INDEX_ALL } index_mode_t;
 
@@ -240,10 +265,14 @@ static esp_err_t auto_index_handler(httpd_req_t *req)
   return ESP_OK;
 }
 
-
 static esp_err_t dynamic_handler_httpd(httpd_req_t *req)
 {
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+  size_t query_len = req->method != HTTP_WEBSOCKET ? httpd_req_get_url_query_len(req) : 0;
+#else
   size_t query_len = httpd_req_get_url_query_len(req);
+#endif
+
   char *query = query_len ? malloc(query_len + 1) : NULL;
   if (query_len)
     httpd_req_get_url_query_str(req, query, query_len + 1);
@@ -254,7 +283,34 @@ static esp_err_t dynamic_handler_httpd(httpd_req_t *req)
     .query_str = query,
     .method = req->method,
     .body_len = req->content_len,
+    .req = req,
   };
+
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+  memset(&req_data.ws_pkt, 0, sizeof(httpd_ws_frame_t));
+  if (req->method == HTTP_WEBSOCKET) {
+    WS_DEBUG("Handling callback for websocket\n");
+    req_data.ws_pkt.type = HTTPD_WS_TYPE_TEXT;
+    /* Set max_len = 0 to get the frame len */
+    esp_err_t ret = httpd_ws_recv_frame(req, &req_data.ws_pkt, 0);
+    if (ret != ESP_OK) {
+        return ret;
+    }
+
+    WS_DEBUG("About to allocate %d bytes for buffer\n", req_data.ws_pkt.len);
+    char *buf = malloc(req_data.ws_pkt.len);
+    if (!buf) {
+      return ESP_ERR_NO_MEM;
+    }
+    req_data.ws_pkt.payload = (void *) buf;
+    ret = httpd_ws_recv_frame(req, &req_data.ws_pkt, req_data.ws_pkt.len);
+    if (ret != ESP_OK) {
+      free(buf);
+      return ret;
+    }
+  }
+  #endif
+
   // Pass the req info over to the LVM thread
   task_post_medium(dynamic_task, (task_param_t)&req_data);
 
@@ -319,13 +375,113 @@ static esp_err_t dynamic_handler_httpd(httpd_req_t *req)
       }
     }
 
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+    if (req_data.ws_pkt.payload) {
+      free(req_data.ws_pkt.payload);
+      req_data.ws_pkt.payload = 0;
+    }
+#endif
+
     // Request processed, release LVM thread
     xSemaphoreGive(done);
-  } while(tr.request_type != SEND_RESPONSE); // done
+  } while(tr.request_type != SEND_RESPONSE && tr.request_type != SEND_ERROR && tr.request_type != SEND_OK); // done
+
+  if (tr.request_type == SEND_ERROR) {
+    return ESP_FAIL;
+  }
 
   return ESP_OK;
 }
 
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+// returns a reference to a table with the [1] value being a weak
+// ref to the top of the stack
+static int register_weak_ref(lua_State *L)
+{
+  lua_newtable(L);
+
+  lua_newtable(L); // metatable={}
+
+  lua_pushliteral(L, "__mode");
+  lua_pushliteral(L, "v");
+  lua_rawset(L, -3); // metatable._mode='v'
+
+  lua_setmetatable(L, -2); // setmetatable(new_table,metatable)
+
+  lua_pushvalue(L, -2); // push the previous top of stack
+  lua_rawseti(L, -2, 1); // new_table[1]=original value on top of the stack
+
+  lua_remove(L, -2);   // Remove the original ivalue
+
+  int result = luaL_ref(L, LUA_REGISTRYINDEX); // this pops the new_table
+  WS_DEBUG("register_weak_ref returning %d\n", result);
+  return result;
+}
+
+// Returns the value of the weak ref on the stack
+// but returns false with nothing on the stack has been GC'ed
+static bool deref_weak_ref(lua_State *L, int ref)
+{
+  lua_rawgeti(L, LUA_REGISTRYINDEX, ref);
+  if (lua_isnil(L, -1)) {
+    lua_pop(L, 1);
+    WS_DEBUG("deref_weak_ref(%d) has nothing in registry\n", ref);
+    return false;
+  }
+  lua_rawgeti(L, -1, 1);
+
+  // Either we have nil, or we have the underlying object
+  if (lua_isnil(L, -1)) {
+    lua_pop(L, 2);
+    WS_DEBUG("deref_weak_ref(%d) has nothing in weak table\n", ref);
+    return false;
+  }
+
+  lua_remove(L, -2);  // remove the table
+
+  return true;
+}
+
+static esp_err_t websocket_handler_httpd(httpd_req_t *req)
+{
+  if (req->method == HTTP_GET) {
+    req->method = HTTP_WEBSOCKET_GET;
+  } else {
+    req->method = HTTP_WEBSOCKET;
+  }
+  return dynamic_handler_httpd(req);
+}
+
+static void free_sess_ctx(void *ctx) {
+  int ref = (int) ctx;
+
+  request_data_t req_data = {
+    .method = FREE_WS_OBJECT,
+    .reference = ref,
+  };
+  task_post_medium(dynamic_task, (task_param_t)&req_data);
+  thread_request_t tr;
+  xQueueReceive(queue, &tr, portMAX_DELAY);   // Receive the reply
+  xSemaphoreGive(done);
+}
+
+static void ws_clear(lua_State *L, ws_connection_t *ws)
+{
+  luaL_unref2(L, LUA_REGISTRYINDEX, ws->self_weak_ref);
+  luaL_unref2(L, LUA_REGISTRYINDEX, ws->self_ref);
+  if (ws->text_fn_ref > 0) {
+    luaL_unref2(L, LUA_REGISTRYINDEX, ws->text_fn_ref);
+  }
+  if (ws->binary_fn_ref > 0) {
+    luaL_unref2(L, LUA_REGISTRYINDEX, ws->binary_fn_ref);
+  }
+  if (ws->close_fn_ref > 0) {
+    luaL_unref2(L, LUA_REGISTRYINDEX, ws->close_fn_ref);
+  }
+}
+
+#endif
+
 
 // ---- helper functions ----------------------------------------------
 
@@ -434,7 +590,6 @@ static int lhttpd_req_index(lua_State *L)
 #undef KEY_IS
 }
 
-
 static void dynamic_handler_lvm(task_param_t param, task_prio_t prio)
 {
   UNUSED(prio);
@@ -452,84 +607,164 @@ static void dynamic_handler_lvm(task_param_t param, task_prio_t prio)
     .response = &resp,
   };
 
-  lua_rawgeti(L, LUA_REGISTRYINDEX, dynamic_handlers_table_ref); // +1
-  lua_getfield(L, -1, req_info->key); // +1
-  if (lua_isfunction(L, -1))
-  {
-    // push req
-    req_udata_t *ud =
-      (req_udata_t *)lua_newuserdata(L, sizeof(req_udata_t)); // +1
-    ud->req_info = req_info;
-    ud->guard = guard;
-    luaL_getmetatable(L, REQUEST_METATABLE); // +1
-    lua_setmetatable(L, -2); // -1
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+  if (req_info->method == FREE_WS_OBJECT) {
+    WS_DEBUG("Freeing WS Object %d\n", req_info->reference);
+    if (deref_weak_ref(L, req_info->reference)) {
+      ws_connection_t *ws = (ws_connection_t *) luaL_checkudata(L, -1, WS_METATABLE);
+
+      if (!ws->closed) {
+        WS_DEBUG("First close\n");
+        ws->closed = true;
+        if (ws->close_fn_ref > 0) {
+          WS_DEBUG("Calling close handler\n");
+          lua_rawgeti(L, LUA_REGISTRYINDEX, ws->close_fn_ref);
+          luaL_pcallx(L, 0, 0);
+        }
+      }
+
+      ws_clear(L, ws);
+    }
+
+    tr.request_type = SEND_OK;
+  } else if (req_info->method == HTTP_WEBSOCKET) {
+    WS_DEBUG("Handling websocket callbacks\n");
+    // Just handle the callbacks here
+    if (req_info->req->sess_ctx) {
+      // Websocket event arrived
+      WS_DEBUG("Sess_ctx = %d\n", (int) req_info->req->sess_ctx);
+      if (deref_weak_ref(L, (int) req_info->req->sess_ctx)) {
+        ws_connection_t *ws = (ws_connection_t *) luaL_checkudata(L, -1, WS_METATABLE);
+        int fn = 0;
+
+        if (req_info->ws_pkt.type == HTTPD_WS_TYPE_TEXT) {
+          fn = ws->text_fn_ref;
+        } else if (req_info->ws_pkt.type == HTTPD_WS_TYPE_BINARY) {
+          fn = ws->binary_fn_ref;
+        }
 
-    int err = luaL_pcallx(L, 1, 1); // -1 +1
-    if (!err && lua_istable(L, -1))
+        if (fn) {
+          lua_rawgeti(L, LUA_REGISTRYINDEX, fn);
+
+          lua_pushlstring(L, (const char *) req_info->ws_pkt.payload, (size_t) req_info->ws_pkt.len);
+
+          luaL_pcallx(L, 1, 0);
+        }
+      }
+    }
+    tr.request_type = SEND_OK;
+  } else
+#endif
+  {
+    lua_rawgeti(L, LUA_REGISTRYINDEX, dynamic_handlers_table_ref); // +1
+    lua_getfield(L, -1, req_info->key); // +1
+    if (lua_isfunction(L, -1))
     {
-      // pull out response data
-      int t = lua_gettop(L); // response table index
-      lua_getfield(L, t, "status"); // +1
-      resp.status_str = luaL_optstring(L, -1, "200 OK");
-      lua_getfield(L, t, "type"); // +1
-      resp.content_type = luaL_optstring(L, -1, NULL);
-      lua_getfield(L, t, "body"); // +1
-      resp.body_data = luaL_optlstring(L, -1, NULL, &resp.body_len);
-      if (!resp.body_data)
-        resp.body_len = 0;
-      lua_getfield(L, t, "headers"); // +1
-      if (lua_istable(L, -1))
-      {
-        lua_pushnil(L); // +1
-        for (unsigned i = 0; lua_next(L, -2); ++i) // +1
-        {
-          if (i >= MAX_RESPONSE_HEADERS)
-          {
-            printf("Warning - too many response headers, ignoring some!\n");
-            break;
+      // push req
+      req_udata_t *ud =
+        (req_udata_t *)lua_newuserdata(L, sizeof(req_udata_t)); // +1
+      ud->req_info = req_info;
+      ud->guard = guard;
+      luaL_getmetatable(L, REQUEST_METATABLE); // +1
+      lua_setmetatable(L, -2); // -1
+
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+      if (strncmp(req_info->key, "[WS]", 4) == 0) {
+        if (req_info->method == HTTP_WEBSOCKET_GET) {
+          // web socket
+          ws_connection_t *ws = (ws_connection_t *) lua_newuserdata(L, sizeof(*ws));
+          WS_DEBUG("Created new WS object\n");
+          memset(ws, 0, sizeof(*ws));
+          luaL_getmetatable(L, WS_METATABLE);
+          lua_setmetatable(L, -2);
+          lua_pushvalue(L, -1);
+          ws->self_weak_ref = register_weak_ref(L);
+          ws->handle = req_info->req->handle;
+          ws->fd = httpd_req_to_sockfd(req_info->req);
+          ws->self_ref = LUA_NOREF;
+
+          int err = luaL_pcallx(L, 2, 0);
+          if (err) {
+            tr.request_type = SEND_ERROR;
+            ws_clear(L, ws);
+          } else {
+            tr.request_type = SEND_OK;
+            // Set the session context so we know what is going on.
+            req_info->req->sess_ctx = (void *)ws->self_weak_ref;
+            req_info->req->free_ctx = free_sess_ctx;
           }
-          resp.headers[i].key = lua_tostring(L, -2);
-          resp.headers[i].value = lua_tostring(L, -1);
-          lua_pop(L, 1); // drop value, keep key for lua_next()
         }
-      }
-      lua_getfield(L, t, "getbody"); // +1
-      if (lua_isfunction(L, -1))
+      } else
+#endif
       {
-        // Okay, we're doing a chunked body send, so we have to repeatedly
-        // call the provided getbody() function until it returns nil
-        bool headers_cleared = false;
-        tr.request_type = SEND_PARTIAL_RESPONSE;
-next_chunk:
-        resp.body_data = NULL;
-        resp.body_len = 0;
-        err = luaL_pcallx(L, 0, 1); // -1 +1
-        resp.body_data =
-          err ? NULL : luaL_optlstring(L, -1, NULL, &resp.body_len);
-        if (resp.body_data)
+        int err = luaL_pcallx(L, 1, 1); // -1 +1
+        if (!err && lua_istable(L, -1))
         {
-          // Toss this bit of response data over to the httpd thread
-          xQueueSend(queue, &tr, portMAX_DELAY);
-          // ...and wait until it's done sending it
-          xSemaphoreTake(done, portMAX_DELAY);
-
-          lua_pop(L, 1); // -1
-
-          if (!headers_cleared)
+          // pull out response data
+          int t = lua_gettop(L); // response table index
+          lua_getfield(L, t, "status"); // +1
+          resp.status_str = luaL_optstring(L, -1, "200 OK");
+          lua_getfield(L, t, "type"); // +1
+          resp.content_type = luaL_optstring(L, -1, NULL);
+          lua_getfield(L, t, "body"); // +1
+          resp.body_data = luaL_optlstring(L, -1, NULL, &resp.body_len);
+          if (!resp.body_data)
+            resp.body_len = 0;
+          lua_getfield(L, t, "headers"); // +1
+          if (lua_istable(L, -1))
           {
-            // Clear the header data; it's only used for the first chunk
-            resp.status_str = NULL;
-            resp.content_type = NULL;
-            for (unsigned i = 0; i < MAX_RESPONSE_HEADERS; ++i)
-              resp.headers[i].key = resp.headers[i].value = NULL;
-
-            headers_cleared = true;
+            lua_pushnil(L); // +1
+            for (unsigned i = 0; lua_next(L, -2); ++i) // +1
+            {
+              if (i >= MAX_RESPONSE_HEADERS)
+              {
+                printf("Warning - too many response headers, ignoring some!\n");
+                break;
+              }
+              resp.headers[i].key = lua_tostring(L, -2);
+              resp.headers[i].value = lua_tostring(L, -1);
+              lua_pop(L, 1); // drop value, keep key for lua_next()
+            }
           }
           lua_getfield(L, t, "getbody"); // +1
-          goto next_chunk;
+          if (lua_isfunction(L, -1))
+          {
+            // Okay, we're doing a chunked body send, so we have to repeatedly
+            // call the provided getbody() function until it returns nil
+            bool headers_cleared = false;
+            tr.request_type = SEND_PARTIAL_RESPONSE;
+          next_chunk:
+            resp.body_data = NULL;
+            resp.body_len = 0;
+            err = luaL_pcallx(L, 0, 1); // -1 +1
+            resp.body_data =
+              err ? NULL : luaL_optlstring(L, -1, NULL, &resp.body_len);
+            if (resp.body_data)
+            {
+              // Toss this bit of response data over to the httpd thread
+              xQueueSend(queue, &tr, portMAX_DELAY);
+              // ...and wait until it's done sending it
+              xSemaphoreTake(done, portMAX_DELAY);
+
+              lua_pop(L, 1); // -1
+
+              if (!headers_cleared)
+              {
+                // Clear the header data; it's only used for the first chunk
+                resp.status_str = NULL;
+                resp.content_type = NULL;
+                for (unsigned i = 0; i < MAX_RESPONSE_HEADERS; ++i)
+                  resp.headers[i].key = resp.headers[i].value = NULL;
+
+                headers_cleared = true;
+              }
+              lua_getfield(L, t, "getbody"); // +1
+              goto next_chunk;
+            }
+            // else, getbody() returned nil, so let the normal exit path
+            // toss the final SEND_PARTIAL_RESPONSE request over to the httpd
+          }
         }
-        // else, getbody() returned nil, so let the normal exit path
-        // toss the final SEND_PARTIAL_RESPONSE request over to the httpd
       }
     }
   }
@@ -584,6 +819,46 @@ static int lhttpd_static(lua_State *L)
   return 1;
 }
 
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+// add websocket route: httpd.websocket(uri, handler)
+static int lhttpd_websocket(lua_State *L)
+{
+  if (!server)
+    return luaL_error(L, "Server not started");
+
+  const char *match = luaL_checkstring(L, 1);
+  luaL_checkfunction(L, 2);
+  lua_settop(L, 2);
+
+  if (!match[0])
+    return luaL_error(L, "Null route not supported");
+
+  // Create a key for this entry
+  const char *key = lua_pushfstring(L, "[WS]%s", match);
+
+  // Store this in our dynamic handlers table, so the ref lives on
+  // on, but so that we can also free it after server shutdown.
+  lua_rawgeti(L, LUA_REGISTRYINDEX, dynamic_handlers_table_ref);
+  lua_pushvalue(L, -2); // key
+  lua_pushvalue(L, 2); // handler
+  lua_settable(L, -3);
+
+  httpd_uri_t websocket_handler = {
+    .uri = match,
+    .method = HTTP_GET,
+    .handler = websocket_handler_httpd,
+    .user_ctx = (void *)key,
+    .is_websocket = true,
+    .handle_ws_control_frames = false,
+  };
+  if (httpd_register_uri_handler(server, &websocket_handler) == 1)
+    lua_pushinteger(L, 1);
+  else
+    lua_pushnil(L);
+
+  return 1;
+}
+#endif
 
 // add dynamic route: httpd.dynamic(method, uri, handler)
 static int lhttpd_dynamic(lua_State *L)
@@ -715,17 +990,162 @@ static int lhttpd_stop(lua_State *L)
   return 0;
 }
 
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+// Websocket functions
+
+typedef struct {
+  httpd_handle_t hd;
+  int fd;
+  int type;
+  size_t len;
+  char *data;
+} async_send_t;
+
+/*
+ * async send function, which we put into the httpd work queue
+ */
+static void ws_async_send(void *arg)
+{
+    async_send_t *async_send = arg;
+    httpd_handle_t hd = async_send->hd;
+    int fd = async_send->fd;
+    httpd_ws_frame_t ws_pkt;
+    memset(&ws_pkt, 0, sizeof(httpd_ws_frame_t));
+    ws_pkt.payload = (uint8_t*)async_send->data;
+    ws_pkt.len = async_send->len;
+    ws_pkt.type = async_send->type;
+
+    httpd_ws_send_frame_async(hd, fd, &ws_pkt);
+    free(async_send);
+}
+
+static esp_err_t trigger_async_send(ws_connection_t *ws, int type, const char *data, size_t len) {
+    async_send_t *async_send = malloc(sizeof(async_send_t) + len);
+    async_send->hd = ws->handle;
+    async_send->fd = ws->fd;
+    async_send->type = type;
+    async_send->len = len;
+    async_send->data = (char *) (async_send + 1);
+    memcpy(async_send->data, data, len);
+    return httpd_queue_work(ws->handle, ws_async_send, async_send);
+}
+
+static void ws_async_close(void *arg) {
+  async_send_t *async_close = arg;
+
+  WS_DEBUG("About to trigger close on %d\n", async_close->fd);
+
+  if (httpd_sess_trigger_close(async_close->hd, async_close->fd) != ESP_OK) {
+    WS_DEBUG("Failed to trigger close\n");
+  }
+  free(async_close);
+}
+
+static int ws_close(lua_State *L) {
+  ws_connection_t *ws = (ws_connection_t *)luaL_checkudata(L, 1, WS_METATABLE);
+
+  if (!ws->closed) {
+    ws->closed = true;
+    async_send_t *async_close = malloc(sizeof(async_send_t));
+    async_close->hd = ws->handle;
+    async_close->fd = ws->fd;
+    WS_DEBUG("ws_close called and now marked closed\n");
+    httpd_queue_work(ws->handle, ws_async_close, async_close);
+  } else {
+    WS_DEBUG("ws_close called when already closed\n");
+  }
+  return 0;
+}
+
+static int ws_gcclose(lua_State *L) {
+  WS_DEBUG("gc cleaning up block\n");
+  return ws_close(L);
+}
+
+// event types: text, binary, close
+static int ws_on(lua_State *L) {
+  ws_connection_t *ws = (ws_connection_t*)luaL_checkudata(L, 1, WS_METATABLE);
+  const char *event = lua_tostring(L, 2);
+
+  int *slot = NULL;
+  if (strcmp(event, "text") == 0) {
+    slot = &ws->text_fn_ref;
+  } else if (strcmp(event, "binary") == 0) {
+    slot = &ws->binary_fn_ref;
+  } else if (strcmp(event, "close") == 0) {
+    slot = &ws->close_fn_ref;
+  } else {
+    return luaL_error(L, "Incorrect event argument");
+  }
+
+  if (*slot) {
+    luaL_unref(L, LUA_REGISTRYINDEX, *slot);
+    *slot = 0;
+  }
+
+  if (!lua_isnil(L, 3)) {
+    luaL_checkfunction(L, 3);
+    lua_pushvalue(L, 3);
+    *slot = luaL_ref(L, LUA_REGISTRYINDEX);
+  }
+
+  if (ws->text_fn_ref || ws->binary_fn_ref || ws->close_fn_ref) {
+    // We need a self_ref
+    if (ws->self_ref <= 0) {
+      lua_pushvalue(L, 1);
+      ws->self_ref = luaL_ref(L, LUA_REGISTRYINDEX);
+    }
+  } else {
+    luaL_unref2(L, LUA_REGISTRYINDEX, ws->self_ref);
+  }
+
+  return 0;
+}
+
+static int ws_textbinary(lua_State *L, int type) {
+  ws_connection_t *ws = (ws_connection_t*)luaL_checkudata(L, 1, WS_METATABLE);
+
+  size_t len;
+  const char *data = lua_tolstring(L, 2, &len);
+
+  esp_err_t rc = trigger_async_send(ws, type, data, len);
+  if (rc) {
+    return luaL_error(L, "Failed to send to websocket");
+  }
+
+  return 0;
+}
+
+static int ws_text(lua_State *L) {
+  return ws_textbinary(L, HTTPD_WS_TYPE_TEXT);
+}
+
+static int ws_binary(lua_State *L) {
+  return ws_textbinary(L, HTTPD_WS_TYPE_BINARY);
+}
+
+LROT_BEGIN(httpd_ws_mt, NULL, LROT_MASK_GC_INDEX)
+  LROT_TABENTRY( __index, httpd_ws_mt )
+  LROT_FUNCENTRY( __gc,        ws_gcclose )
+  LROT_FUNCENTRY( close,       ws_close )
+  LROT_FUNCENTRY( on,          ws_on )
+  LROT_FUNCENTRY( text,        ws_text )
+  LROT_FUNCENTRY( binary,      ws_binary )
+LROT_END(httpd_ws_mt, NULL, LROT_MASK_GC_INDEX)
+#endif
 
 LROT_BEGIN(httpd_req_mt, NULL, LROT_MASK_INDEX)
   LROT_FUNCENTRY( __index, lhttpd_req_index )
 LROT_END(httpd_req_mt, NULL, LROT_MASK_INDEX)
 
-
 LROT_BEGIN(httpd, NULL, 0)
   LROT_FUNCENTRY( start,      lhttpd_start )
   LROT_FUNCENTRY( stop,       lhttpd_stop )
   LROT_FUNCENTRY( static,     lhttpd_static )
   LROT_FUNCENTRY( dynamic,    lhttpd_dynamic )
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+  LROT_FUNCENTRY( websocket,  lhttpd_websocket )
+#endif
   LROT_FUNCENTRY( unregister, lhttpd_unregister )
 
   LROT_NUMENTRY( GET,         HTTP_GET )
@@ -748,6 +1168,10 @@ static int lhttpd_init(lua_State *L)
 
   luaL_rometatable(L, REQUEST_METATABLE, LROT_TABLEREF(httpd_req_mt));
 
+#ifdef CONFIG_NODEMCU_CMODULE_HTTPD_WS
+  luaL_rometatable(L, WS_METATABLE, LROT_TABLEREF(httpd_ws_mt));
+#endif
+
   return 0;
 }
 
diff --git a/docs/modules/httpd.md b/docs/modules/httpd.md
index f651f5c37..1ac118d33 100644
--- a/docs/modules/httpd.md
+++ b/docs/modules/httpd.md
@@ -76,7 +76,7 @@ configured.
 httpd.start({
   webroot = "<static file prefix>",
   max_handlers = 20,
-  auto_index = httpd.INDEX_NONE || httpd.INDEX_ROOT || httpd.INDEX_ALL,
+  auto_index = httpd.INDEX_ALL,
 })
 ```
 
@@ -256,6 +256,66 @@ end
 httpd.dynamic(httpd.PUT, "/foo", put_foo)
 ```
 
+## httpd.websocket()
+
+Registers a websocket route handler. This is optional, and must be selected explicitly in the configuration.
+
+#### Syntax
+```lua
+httpd.websocket(route, handler)
+```
+
+#### Parameters
+- `route` The route prefix. Be mindful of any trailing "/" as that may interact
+with the `auto_index` functionality.
+- `handler` The route handler function - `handler(req, ws)`. The `req` object is
+the same as for a regular dynamic route. The provided websocket
+object `ws` has the following fields/functions:
+  - `on` This allows registration of handlers when data is received. This is invoked with
+  two arguments -- the name of the event and the handler for that event. The allowable names are:
+    - `text` The handler is called with a single string argument whenever a text message is received.
+    - `binary` The handler is called with a single string argument whenever a binary message is received.
+    - `close` The handler is called when the client wants to close the connection.
+  - `text` This can be called with a string argument and it sends a text message.
+  - `binary` This can be called with a string argument and it sends a binary message.
+  - `close` The connection to the client is closed.
+
+#### Returns
+nil
+
+#### Example
+```lua
+httpd.start({
+  webroot = "<static file prefix>",
+  max_handlers = 20,
+  auto_index = httpd.INDEX_ALL,
+})
+
+function echo_ws(req, ws)
+  ws:on('text', function(data) print(data) ws:text(data) end)
+end
+
+httpd.websocket("/echo", echo_ws)
+
+function tick(ws, n) 
+  ws:text("tick: " .. n)
+  n = n + 1
+  if n < 6 then
+    tmr.create():alarm(1000, tmr.ALARM_SINGLE, function () 
+       tick(ws ,n)
+    end)
+  else
+    ws:close()
+  end
+end
+
+function heartbeat(req, ws) 
+  tick(ws, 0)
+end
+
+httpd.websocket("/beat", heartbeat)
+```
+
 ## httpd.unregister()
 
 Unregisters a previously registered handler. The default handlers may be