Skip to content

Web: dedicated sqlite3_worker is never terminated, leaks one per Flutter Web hot restart #427

Description

@khawarizmus

Summary

On Flutter Web, every time a PowerSyncDatabase is opened, sqlite3_web spawns a dedicated web worker named sqlite3_worker that is never terminate()d. close()/dispose() only sends a CloseDatabase message — it does not tear the worker down. Because a hot restart re-runs main() in the same JS VM (Dart state is wiped, but the page and its workers persist), each hot restart spawns a fresh sqlite3_worker and orphans the previous one. They accumulate until a full browser refresh recreates the JS VM.

Impact is development-only (duplicated logs, harder debugging after hot restart); production is unaffected since the VM isn't repeatedly re-initialised. Reported on powersync: 2.2.0.

Context: https://discord.com/channels/1138230179878154300/1511710494766141540

Environment

  • powersync 2.2.0 (pulls sqlite3_web 0.8.1, sqlite_async 0.14.2)
  • Flutter Web, debug, hot restart (not hot reload — hot reload doesn't re-run main())
  • Reproduced on plain desktop Chromium (Chrome for Testing 127), no special headers

Steps to reproduce

  1. Minimal Flutter Web app on powersync 2.2.0. In main(), open a PowerSyncDatabase and initialize() it. No backend, no connect(), no schema specifics required — the worker is spawned at open.
  2. Run on web (flutter run -d chrome).
  3. Press R (hot restart) a few times.
  4. Count dedicated workers named sqlite3_worker (Chrome Task Manager, or programmatically).

Expected vs actual

  • Expected: hot restart leaves a single live sqlite3_worker; the previous VM run's worker is torn down.
  • Actual: the count of sqlite3_worker instances grows by one per hot restart and is only cleared by a full browser refresh.

Root cause

sqlite3_web 0.8.1's DatabaseClient.startWorkers() (lib/src/client.dart:429) runs both _startDedicated() and _startShared() during feature detection — neither is gated on which access mode ultimately hosts the DB. _startDedicated() spawns, via _DefaultWorkerConnector.spawnDedicatedWorker():

// sqlite3_web/lib/src/worker_connector.dart:183
Worker(_workerUrl.toJS, WorkerOptions(name: 'sqlite3_worker'))

and nothing in sqlite3_web ever calls .terminate() (zero terminate references in the package). PowerSync's web teardown (close() / sync controller close()) sends CloseDatabase over the channel but never retains or terminates the dedicated Worker handle. So:

  • The dedicated sqlite3_worker is spawned regardless of which access mode hosts the DB — even when a shared worker is selected for the actual database — and is never torn down.
  • Within a single JS-VM / Dart program state the worker is created once and shared across opens: sqlite_async caches the WebSqlite instance in a library-global map keyed by wasm+worker URL (sqlite_async/lib/src/web/web_sqlite_open_factory.dart:14-32). (This is why opening N databases in one VM does not spawn N workers.)
  • A Flutter-web hot restart re-runs main(), resetting that Dart-side cache, so the next open rebuilds WebSqlite and spawns a fresh sqlite3_worker while the prior one is orphaned in the surviving page — one extra worker per restart, cleared only by a full browser refresh (new JS VM).

This is the web analogue of the native fix in powersync 2.2.0 ("Fix disconnect() call leaving stale database isolates behind") — the same teardown gap, in the web worker execution context.

AI Disclosure

Used Claude Code with Opus 4.7 to research, debug the issue, and assist in writing a test for reproducing it. Verified with the team internally first.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions