diff --git a/packages/sqlite_async/build.yaml b/packages/sqlite_async/build.yaml
new file mode 100644
index 0000000..0774cc7
--- /dev/null
+++ b/packages/sqlite_async/build.yaml
@@ -0,0 +1,7 @@
+targets:
+  $default:
+    builders:
+      build_web_compilers:entrypoint:
+        options:
+          # Workers can't be compiled with dartdevc, so use dart2js for the example
+          compiler: dart2js
diff --git a/packages/sqlite_async/example/web/index.html b/packages/sqlite_async/example/web/index.html
new file mode 100644
index 0000000..4bc69de
--- /dev/null
+++ b/packages/sqlite_async/example/web/index.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+    <meta charset="utf-8">
+    <title>sqlite_async web demo</title>
+    <script defer src="main.dart.js"></script>
+</head>
+
+<body>
+
+<h1>sqlite_async demo</h1>
+
+<main>
+This page is used to test the sqlite_async package on the web.
+Use the console to open and interact with databases.
+
+<pre>
+<code>
+const db = await open('test.db');
+const lock = await write_lock(db);
+release_lock(lock);
+</code>
+</pre>
+</main>
+
+
+</body>
+</html>
diff --git a/packages/sqlite_async/example/web/main.dart b/packages/sqlite_async/example/web/main.dart
new file mode 100644
index 0000000..95bd1f1
--- /dev/null
+++ b/packages/sqlite_async/example/web/main.dart
@@ -0,0 +1,41 @@
+import 'dart:async';
+import 'dart:js_interop';
+import 'dart:js_interop_unsafe';
+
+import 'package:sqlite_async/sqlite_async.dart';
+
+void main() {
+  globalContext['open'] = (String path) {
+    return Future(() async {
+      final db = SqliteDatabase(
+        path: path,
+        options: SqliteOptions(
+          webSqliteOptions: WebSqliteOptions(
+            wasmUri:
+                'https://cdn.jsdelivr.net/npm/@powersync/dart-wasm-bundles@latest/dist/sqlite3.wasm',
+            workerUri: 'worker.dart.js',
+          ),
+        ),
+      );
+      await db.initialize();
+      return db.toJSBox;
+    }).toJS;
+  }.toJS;
+
+  globalContext['write_lock'] = (JSBoxedDartObject db) {
+    final hasLock = Completer<void>();
+    final completer = Completer<void>();
+
+    (db.toDart as SqliteDatabase).writeLock((_) async {
+      print('has write lock!');
+      hasLock.complete();
+      await completer.future;
+    });
+
+    return hasLock.future.then((_) => completer.toJSBox).toJS;
+  }.toJS;
+
+  globalContext['release_lock'] = (JSBoxedDartObject db) {
+    (db.toDart as Completer<void>).complete();
+  }.toJS;
+}
diff --git a/packages/sqlite_async/example/web/worker.dart b/packages/sqlite_async/example/web/worker.dart
new file mode 100644
index 0000000..481455d
--- /dev/null
+++ b/packages/sqlite_async/example/web/worker.dart
@@ -0,0 +1,6 @@
+import 'package:sqlite_async/sqlite3_web.dart';
+import 'package:sqlite_async/sqlite3_web_worker.dart';
+
+void main() {
+  WebSqlite.workerEntrypoint(controller: AsyncSqliteController());
+}
diff --git a/packages/sqlite_async/lib/src/web/worker/worker_utils.dart b/packages/sqlite_async/lib/src/web/worker/worker_utils.dart
index af39747..3ecb257 100644
--- a/packages/sqlite_async/lib/src/web/worker/worker_utils.dart
+++ b/packages/sqlite_async/lib/src/web/worker/worker_utils.dart
@@ -56,9 +56,27 @@ class AsyncSqliteDatabase extends WorkerDatabase {
   // these requests for shared workers, so we can assume each database is only
   // opened once and we don't need web locks here.
   final mutex = ReadWriteMutex();
+  final Map<ClientConnection, _ConnectionState> _state = {};
 
   AsyncSqliteDatabase({required this.database});
 
+  _ConnectionState _findState(ClientConnection connection) {
+    return _state.putIfAbsent(connection, _ConnectionState.new);
+  }
+
+  void _markHoldsMutex(ClientConnection connection) {
+    final state = _findState(connection);
+    state.holdsMutex = true;
+    if (!state.hasOnCloseListener) {
+      state.hasOnCloseListener = true;
+      connection.closed.then((_) {
+        if (state.holdsMutex) {
+          mutex.release();
+        }
+      });
+    }
+  }
+
   @override
   Future<JSAny?> handleCustomRequest(
       ClientConnection connection, JSAny? request) async {
@@ -67,9 +85,12 @@ class AsyncSqliteDatabase extends WorkerDatabase {
     switch (message.kind) {
       case CustomDatabaseMessageKind.requestSharedLock:
         await mutex.acquireRead();
+        _markHoldsMutex(connection);
       case CustomDatabaseMessageKind.requestExclusiveLock:
         await mutex.acquireWrite();
+        _markHoldsMutex(connection);
       case CustomDatabaseMessageKind.releaseLock:
+        _findState(connection).holdsMutex = false;
         mutex.release();
       case CustomDatabaseMessageKind.lockObtained:
         throw UnsupportedError('This is a response, not a request');
@@ -123,3 +144,8 @@ class AsyncSqliteDatabase extends WorkerDatabase {
     return resultSetMap;
   }
 }
+
+final class _ConnectionState {
+  bool hasOnCloseListener = false;
+  bool holdsMutex = false;
+}