Repository: zincware/ZnDraw (language: python)

Issue:
update JS to work with `room:set`



Discussion / hints:

Fix patch:
diff --git a/examples/md.ipynb b/examples/md.ipynb
index 80c86ea7b..3c55cc1d7 100644
--- a/examples/md.ipynb
+++ b/examples/md.ipynb
@@ -23,13 +23,16 @@
     "from ase import units\n",
     "\n",
     "from ase.calculators.emt import EMT\n",
+    "\n",
     "size = 2\n",
     "\n",
     "# Set up a crystal\n",
-    "atoms = FaceCenteredCubic(directions=[[1, 0, 0], [0, 1, 0], [0, 0, 1]],\n",
-    "                          symbol=\"Cu\",\n",
-    "                          size=(size, size, size),\n",
-    "                          pbc=True)\n",
+    "atoms = FaceCenteredCubic(\n",
+    "    directions=[[1, 0, 0], [0, 1, 0], [0, 0, 1]],\n",
+    "    symbol=\"Cu\",\n",
+    "    size=(size, size, size),\n",
+    "    pbc=True,\n",
+    ")\n",
     "\n",
     "# Describe the interatomic interactions with the Effective Medium Theory\n",
     "atoms.calc = EMT()\n",
@@ -45,8 +48,10 @@
     "    \"\"\"Function to print the potential, kinetic and total energy.\"\"\"\n",
     "    epot = a.get_potential_energy() / len(a)\n",
     "    ekin = a.get_kinetic_energy() / len(a)\n",
-    "    print('Energy per atom: Epot = %.3feV  Ekin = %.3feV (T=%3.0fK)  '\n",
-    "          'Etot = %.3feV' % (epot, ekin, ekin / (1.5 * units.kB), epot + ekin))"
+    "    print(\n",
+    "        \"Energy per atom: Epot = %.3feV  Ekin = %.3feV (T=%3.0fK)  \"\n",
+    "        \"Etot = %.3feV\" % (epot, ekin, ekin / (1.5 * units.kB), epot + ekin)\n",
+    "    )"
    ]
   },
   {
@@ -55,7 +60,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "vis = ZnDraw(url=\"http://127.0.0.1:47823/\", token=\"fcd45c3917d34b1a8329bd5a5f172c0f\")\n"
+    "vis = ZnDraw(url=\"http://127.0.0.1:47823/\", token=\"fcd45c3917d34b1a8329bd5a5f172c0f\")"
    ]
   },
   {
diff --git a/examples/molecules.ipynb b/examples/molecules.ipynb
index bd74292aa..4f1bd3302 100644
--- a/examples/molecules.ipynb
+++ b/examples/molecules.ipynb
@@ -57,7 +57,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "vis.socket.sleep(0.5) # if we just started the server, we need to wait a bit"
+    "vis.socket.sleep(0.5)  # if we just started the server, we need to wait a bit"
    ]
   },
   {
@@ -126,7 +126,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "vis.display(2) # display the second molecule"
+    "vis.display(2)  # display the second molecule"
    ]
   },
   {
@@ -135,7 +135,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "vis[0] = s22['Water_dimer']"
+    "vis[0] = s22[\"Water_dimer\"]"
    ]
   },
   {
@@ -144,7 +144,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "vis.append(s22['Formic_acid_dimer'])"
+    "vis.append(s22[\"Formic_acid_dimer\"])"
    ]
   },
   {
diff --git a/poetry.lock b/poetry.lock
index 9523dc747..6108a5b02 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -59,6 +59,17 @@ scipy = ">=1.1.0"
 docs = ["pillow", "sphinx", "sphinx-rtd-theme"]
 test = ["pytest (>=5.0.0)", "pytest-mock (>=3.3.0)", "pytest-xdist (>=1.30.0)"]
 
+[[package]]
+name = "async-timeout"
+version = "4.0.3"
+description = "Timeout context manager for asyncio programs"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"},
+    {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"},
+]
+
 [[package]]
 name = "attrs"
 version = "23.2.0"
@@ -2160,6 +2171,87 @@ files = [
 [package.dependencies]
 wcwidth = "*"
 
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.9"
+description = "psycopg2 - Python-PostgreSQL Database Adapter"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"},
+    {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"},
+    {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"},
+    {file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"},
+    {file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"},
+    {file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"},
+    {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"},
+]
+
 [[package]]
 name = "pycparser"
 version = "2.21"
@@ -2619,6 +2711,24 @@ ase = ">=3.22.1,<4.0.0"
 networkx = ">=3.1,<4.0"
 rdkit = ">=2023.3.2,<2024.0.0"
 
+[[package]]
+name = "redis"
+version = "5.0.1"
+description = "Python client for Redis database and key-value store"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "redis-5.0.1-py3-none-any.whl", hash = "sha256:ed4802971884ae19d640775ba3b03aa2e7bd5e8fb8dfaed2decce4d0fc48391f"},
+    {file = "redis-5.0.1.tar.gz", hash = "sha256:0dab495cd5753069d3bc650a0dde8a8f9edde16fc5691b689a566eda58100d0f"},
+]
+
+[package.dependencies]
+async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2\""}
+
+[package.extras]
+hiredis = ["hiredis (>=1.0.0)"]
+ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"]
+
 [[package]]
 name = "requests"
 version = "2.31.0"
@@ -2911,6 +3021,20 @@ files = [
     {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
 ]
 
+[[package]]
+name = "splines"
+version = "0.3.1"
+description = "Splines in Euclidean Space and Beyond"
+optional = false
+python-versions = ">=3.7"
+files = [
+    {file = "splines-0.3.1-py3-none-any.whl", hash = "sha256:52945e8547257a545ce9caf3d628ea6f5552c1c560d6dd6857806405e023ff11"},
+    {file = "splines-0.3.1.tar.gz", hash = "sha256:8a5cf09709254b7872e8bbbfa03aad1dcfa19ec7a49575f3de987c3be17e8a7a"},
+]
+
+[package.dependencies]
+NumPy = "*"
+
 [[package]]
 name = "sqlalchemy"
 version = "2.0.25"
@@ -3348,4 +3472,4 @@ rdkit = ["rdkit2ase"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.9"
-content-hash = "2753655b19fa1a57b44d39de0081fba40e9e0169457b4e1a1b1ed0b5acb3a33b"
+content-hash = "d38615ec635182a283d01346a7c2935a75d30065671d1a564e0b157d9466df52"
diff --git a/pyproject.toml b/pyproject.toml
index 36b15fc95..9927f32b3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,6 +30,9 @@ flask-caching = "^2.1.0"
 decorator = "^5.1.1"
 celery = "^5.3.6"
 sqlalchemy = "^2.0.25"
+psycopg2-binary = "^2.9.9"
+redis = "^5.0.1"
+splines = "^0.3.1"
 
 
 [tool.poetry.group.dev.dependencies]
diff --git a/zndraw/app.py b/zndraw/app.py
index 4ad49fc9d..ed72248c5 100644
--- a/zndraw/app.py
+++ b/zndraw/app.py
@@ -65,6 +65,29 @@ def setup_cache():
 
 def setup_worker() -> list:
     """Setup the worker."""
+    import os
+    import platform
+
+    my_env = os.environ.copy()
+    if platform.system() == "Darwin" and platform.processor() == "arm":
+        # fix celery worker issue on apple silicon
+        my_env["OBJC_DISABLE_INITIALIZE_FORK_SAFETY"] = "YES"
+
+    io_worker = subprocess.Popen(
+        [
+            "celery",
+            "-A",
+            "zndraw.make_celery",
+            "worker",
+            "--loglevel=info",
+            "--concurrency=6",
+            "--hostname=io_worker",
+            "--queues=io",
+            "--prefetch-multiplier=10",
+        ],
+        env=my_env,
+    )
+
     fast_worker = subprocess.Popen(
         [
             "celery",
@@ -72,10 +95,12 @@ def setup_worker() -> list:
             "zndraw.make_celery",
             "worker",
             "--loglevel=info",
-            "--concurrency=16",
+            "--concurrency=6",
             "--hostname=fast_worker",
             "--queues=fast,celery",
-        ]
+            "--prefetch-multiplier=1",
+        ],
+        env=my_env,
     )
 
     slow_worker = subprocess.Popen(
@@ -88,9 +113,10 @@ def setup_worker() -> list:
             "--concurrency=1",
             "--hostname=slow_worker",
             "--queues=slow",
-        ]
+        ],
+        env=my_env,
     )
-    return [fast_worker, slow_worker]
+    return [io_worker, fast_worker, slow_worker]
 
 
 def create_app() -> Flask:
@@ -169,7 +195,10 @@ def run(self, browser=False):
         self._workers = setup_worker()
 
         config = GlobalConfig.load()
-        pathlib.Path(config.database.path).expanduser().unlink(missing_ok=True)
+        try:
+            pathlib.Path(config.database.path).expanduser().unlink(missing_ok=True)
+        except AttributeError:
+            pass  # only for sqlite config
         engine = create_engine(config.database.get_path())
         Base.metadata.create_all(engine)
 
diff --git a/zndraw/base.py b/zndraw/base.py
index a3a2a5540..e2e19bb5a 100644
--- a/zndraw/base.py
+++ b/zndraw/base.py
@@ -1,15 +1,31 @@
-import collections
 import dataclasses
+import logging
 from abc import abstractmethod
+from collections.abc import MutableSequence
 
 import numpy as np
 import socketio
+import splines
+
+log = logging.getLogger(__name__)
 
 
 @dataclasses.dataclass
-class ZnDrawBase(collections.abc.MutableSequence):
-    socket: socketio.Client
+class ZnDrawBase(MutableSequence):
     token: str
+    url: str
+
+    socket: socketio.Client = dataclasses.field(default_factory=socketio.Client)
+
+    def __post_init__(self):
+        self.url = self.url.replace("http", "ws")
+        log.critical(f"Connecting to {self.url}")
+        self.socket.connect(self.url, wait_timeout=1)
+        self.socket.emit("join", str(self.token))
+
+    def reconnect(self):
+        self.socket.connect(self.url)
+        self.socket.emit("join", str(self.token))
 
     @abstractmethod
     def log(self, message: str):
@@ -59,3 +75,23 @@ def points(self, value: np.ndarray):
     @abstractmethod
     def segments(self) -> np.ndarray:
         pass
+
+    @property
+    def figure(self):
+        raise NotImplementedError("Gathering figure from webclient not implemented yet")
+
+    @figure.setter
+    def figure(self, fig: str):
+        data = {"figure": fig, "token": self.token}
+        self.socket.emit("analysis:figure", data)
+
+    @property
+    def atoms(self):
+        return self[self.step]
+
+    @staticmethod
+    def calculate_segments(points: np.ndarray) -> np.ndarray:
+        if points.shape[0] <= 1:
+            return points
+        t = np.linspace(0, len(points) - 1, len(points) * 50)
+        return splines.CatmullRom(points).evaluate(t)
diff --git a/zndraw/data.py b/zndraw/data.py
index 9e6f314cf..e566f008b 100644
--- a/zndraw/data.py
+++ b/zndraw/data.py
@@ -1,6 +1,5 @@
 import dataclasses
 
-import numpy as np
 import znframe
 
 
@@ -16,24 +15,44 @@ class RoomSetData:
         Whether to update the database with the new data.
     """
 
-    points: np.ndarray | None = None
+    points: list[list[float]] | None = None
     bookmarks: dict[int, str] | None = None
     step: int | None = None
     selection: list[int] | None = None
-    frames: dict[int, znframe.Frame | None] | None = None
+    frames: dict[int, znframe.Frame | None] | None = dataclasses.field(
+        default=None, repr=False
+    )
 
     update_database: bool = False
 
+    def to_dict(self) -> dict:
+        return dataclasses.asdict(self)
+
 
 @dataclasses.dataclass
 class RoomGetData:
-    points: bool = False
-    bookmarks: bool = False
-    step: bool = False
-    selection: bool = False
-    length: bool = False
-    segments: bool = False
-    frames: list[int] | None = None
+    points: bool | list[list[float]] = False
+    bookmarks: bool | dict[str, str] = False
+    step: bool | int = False
+    selection: bool | list[int] = False
+    length: bool | int = False
+    segments: bool | list[list[float]] = False
+    frames: list[int] | None | list[dict] = None
+
+    def to_dict(self) -> dict:
+        return dataclasses.asdict(self)
+
+    @classmethod
+    def get_current_state(cls):
+        return cls(
+            points=True,
+            bookmarks=True,
+            step=True,
+            selection=True,
+            length=True,
+            segments=True,
+            frames=["current"],
+        )
 
 
 @dataclasses.dataclass
@@ -61,11 +80,14 @@ class CeleryTaskData:
 
     target: str
     event: str
-    data: dict | None | str
+    data: dict | None | str | int
     disconnect: bool = False
     timeout: int = 60
     authentication: str = None
 
+    def to_dict(self) -> dict:
+        return dataclasses.asdict(self)
+
 
 @dataclasses.dataclass
 class FrameData:
diff --git a/zndraw/db/schema.py b/zndraw/db/schema.py
index 8a90ea19b..11b493790 100644
--- a/zndraw/db/schema.py
+++ b/zndraw/db/schema.py
@@ -1,3 +1,4 @@
+import znframe
 from sqlalchemy import (
     JSON,
     Boolean,
@@ -24,6 +25,7 @@ class Room(Base):
     frames = relationship("Frame", back_populates="room")
     room_modifiers = relationship("RoomModifier", back_populates="room")
     bookmarks = relationship("Bookmark", back_populates="room")
+    launched_jobs = relationship("QueueItem", back_populates="room")
 
     def __repr__(self):
         return f"<Room(token={self.token}, currentStep={self.currentStep})>"
@@ -72,6 +74,9 @@ class Frame(Base):
 
     room = relationship("Room", back_populates="frames")
 
+    def to_frame(self):
+        return znframe.Frame.from_dict(self.data)
+
     def __repr__(self):
         return (
             f"<Frame(id={self.id}, index={self.index}, room_token={self.room_token})>"
@@ -140,7 +145,12 @@ class QueueItem(Base):
     __tablename__ = "queue_items"
 
     id = Column(Integer, primary_key=True, autoincrement=True)
+    job_name = Column(String)
     job_id = Column(String)
+    datetime = Column(String)
+    status = Column(String, default="queued")
 
     queue_name = Column(String, ForeignKey("queues.name"))
     queue = relationship("Queue", back_populates="jobs")
+    room_token = Column(String, ForeignKey("rooms.token"))
+    room = relationship("Room", back_populates="launched_jobs")
diff --git a/zndraw/modify/__init__.py b/zndraw/modify/__init__.py
index aaea5cc71..03d5579c0 100644
--- a/zndraw/modify/__init__.py
+++ b/zndraw/modify/__init__.py
@@ -65,7 +65,8 @@ def run(self, vis: "ZnDraw", **kwargs) -> None:
         points = vis.points
         atom_ids = vis.selection
         atoms = vis.atoms
-        assert len(points) == 2
+        if len(points) != 2:
+            raise ValueError("Please draw exactly 2 points to rotate around.")
 
         angle = self.angle if self.direction == "left" else -self.angle
         angle = angle / self.steps
@@ -76,11 +77,10 @@ def run(self, vis: "ZnDraw", **kwargs) -> None:
         for _ in range(self.steps):
             # rotate the selected atoms around the vector
             atoms_selected.rotate(angle, vector, center=points[0])
-            # merge the selected and remaining atoms
-            atoms = atoms_selected + atoms_remaining
+            # update the positions of the selected atoms
+            atoms.positions[atom_ids] = atoms_selected.positions
             vis.append(atoms)
             time.sleep(self.sleep)
-        vis.selection = []
 
 
 class Delete(UpdateScene):
@@ -114,7 +114,8 @@ def run(self, vis: "ZnDraw", **kwargs) -> None:
             del vis[vis.step + 1 :]
 
         atoms = vis.atoms
-        atoms_selected, atoms_remaining = self.apply_selection(vis.selection, atoms)
+        atoms_ids = vis.selection
+        atoms_selected, atoms_remaining = self.apply_selection(atoms_ids, atoms)
         if self.steps > len(vis.segments):
             raise ValueError(
                 "The number of steps must be less than the number of segments. You can add more points to increase the number of segments."
@@ -129,9 +130,8 @@ def run(self, vis: "ZnDraw", **kwargs) -> None:
             # move the selected atoms along the vector
             atoms_selected.positions += vector
             # merge the selected and remaining atoms
-            atoms = atoms_selected + atoms_remaining
+            atoms.positions[atoms_ids] = atoms_selected.positions
             vis.append(atoms)
-        vis.selection = []
 
 
 class Duplicate(UpdateScene):
diff --git a/zndraw/select/__init__.py b/zndraw/select/__init__.py
index db7e440ca..b03c6faf5 100644
--- a/zndraw/select/__init__.py
+++ b/zndraw/select/__init__.py
@@ -6,6 +6,8 @@
 import numpy as np
 from pydantic import BaseModel, Field
 
+from zndraw import ZnDraw
+
 try:
     from zndraw.select import mda  # noqa: F401
 except ImportError:
@@ -130,6 +132,15 @@ def run(self, vis) -> None:
         vis.selection = list(set(total_ids))
 
 
+class UpdateSelection(SelectionBase):
+    """Reload Selection."""
+
+    discriminator: t.Literal["UpdateSelection"] = Field("UpdateSelection")
+
+    def run(self, vis: ZnDraw) -> None:
+        vis.selection = vis.selection
+
+
 def get_selection_class(methods):
     class Selection(SelectionBase):
         method: methods = Field(
diff --git a/zndraw/server/events.py b/zndraw/server/events.py
index 2c4bb0baf..8726e33fb 100644
--- a/zndraw/server/events.py
+++ b/zndraw/server/events.py
@@ -21,15 +21,15 @@
     CeleryTaskData,
     DeleteAtomsData,
     FrameData,
-    JoinData,
     MessageData,
     ModifierRegisterData,
+    RoomGetData,
+    RoomSetData,
     SceneSetData,
     SceneUpdateData,
     SchemaData,
     SubscribedUserData,
 )
-from .utils import get_queue_position
 
 log = logging.getLogger(__name__)
 
@@ -52,6 +52,7 @@ def _webclients_room(data: dict) -> str:
 def connect():
     try:
         token = str(session["token"])
+        log.critical(f"-------------->>> connecting (webclient) {request.sid}")
         URL = f"http://127.0.0.1:{current_app.config['PORT']}"
         # if you connect through Python, you don't have a token
         read_file_chain = chain(
@@ -146,6 +147,7 @@ def connect():
     except KeyError:
         # clients that connect directly via socketio do not call "join" to
         # register their token
+        log.critical(f"-------------->>> connecting (pyclient) {request.sid}")
         session["token"] = None
 
 
@@ -173,99 +175,25 @@ def celery_task_call(msg: CeleryTaskData):
 
 @io.on("disconnect")
 def disconnect():
-    log.critical(f"-------------->>> disconnecting {request.sid}")
     token = str(session["token"])
 
-    with Session() as ses:
-        room = ses.query(db_schema.Room).filter_by(token=token).first()
-        client = ses.query(db_schema.Client).filter_by(sid=request.sid).first()
-        if client is not None:
-            ses.delete(client)
-            if client.host:
-                new_host = ses.query(db_schema.Client).filter_by(room=room).first()
-                if new_host is not None:
-                    new_host.host = True
-            ses.commit()
-
-    with Session() as ses:
-        room = ses.query(db_schema.Room).filter_by(token=token).first()
-        clients = ses.query(db_schema.Client).filter_by(room=room).all()
-        connected_users = [{"name": client.name} for client in clients]
-
-    emit(
-        "connectedUsers",
-        list(reversed(connected_users)),
-        to=_webclients_room({"token": token}),
-    )
-
-    # ROOM_HOSTS = cache.get("ROOM_HOSTS")
-    # if token in ROOM_HOSTS:
-    #     if request.sid in ROOM_HOSTS[token]:
-    #         ROOM_HOSTS[token].remove(request.sid)
-    #     if len(ROOM_HOSTS[token]) == 0:
-    #         del ROOM_HOSTS[token]
-    #     cache.set("ROOM_HOSTS", ROOM_HOSTS)
-
-    # pyclients only
-    # check if the SID is in MODIFIER_HOSTS or ROOM_MODIFIER_HOSTS
-    with Session() as ses:
-        global_modifier_client = (
-            ses.query(db_schema.GlobalModifierClient).filter_by(sid=request.sid).all()
-        )
-        for gmc in global_modifier_client:
-            ses.delete(gmc)
-
-        room_modifier_client = (
-            ses.query(db_schema.RoomModifierClient).filter_by(sid=request.sid).all()
-        )
-        for rmc in room_modifier_client:
-            ses.delete(rmc)
-        ses.commit()
-
-    # MODIFIER_HOSTS = cache.get("MODIFIER_HOSTS")
-    # for name, sids in MODIFIER_HOSTS.items():
-    #     while request.sid in sids:
-    #         MODIFIER_HOSTS[name].remove(request.sid)
-    # cache.set("MODIFIER_HOSTS", MODIFIER_HOSTS)
-
-    # ROOM_MODIFIER_HOSTS = cache.get("ROOM_MODIFIER_HOSTS")
-    # if token in ROOM_MODIFIER_HOSTS:
-    #     for name, sids in ROOM_MODIFIER_HOSTS[token].items():
-    #         while request.sid in sids:
-    #             ROOM_MODIFIER_HOSTS[token][name].remove(request.sid)
-
-    # # !!!!!!!!!!!!!!
-    # # TODO: remove schema if no more hosts are connected and send to webclients
+    url = request.url_root
+    if current_app.config["upgrade_insecure_requests"] and not "127.0.0.1" in url:
+        url = url.replace("http://", "https://")
+    url = url.replace("http", "ws")
 
-    # cache.set("ROOM_MODIFIER_HOSTS", ROOM_MODIFIER_HOSTS)
+    tasks.on_disconnect.delay(token=token, sid=request.sid, url=url)
 
 
 @io.on("join")
-@typecast
-def join(data: JoinData):
+def join(token: str):
     """
     Arguments:
         data: {"token": str, "uuid": str}
     """
-    # only used by pyclients that only connect via socket (no HTML)
-    # session["authenticated"] = data.auth_token == app.config["AUTH_TOKEN"]
-    # log.debug(f"Client {request.sid} is {session['authenticated'] = }")
-    # PYCLIENTS = cache.get("pyclients")
-    # if data.uuid in PYCLIENTS:
-    #     log.critical(
-    #         f"UUID {data.uuid} is already registered in {app.config['pyclients']}."
-    #     )
-    #     emit("message:log", f"UUID {data.uuid} is already registered", to=request.sid)
-    # PYCLIENTS[data.uuid] = request.sid
-    # cache.set("pyclients", PYCLIENTS)
-    session["token"] = data.token
-    join_room(f"{data.token}")
-    join_room(f"pyclients_{data.token}")
-    # join_room(f"pyclients_{data.token}")
-    # PER_TOKEN_DATA = cache.get("PER-TOKEN-DATA")
-    # if data.token not in PER_TOKEN_DATA:
-    #     PER_TOKEN_DATA[data.token] = {}
-    # cache.set("PER-TOKEN-DATA", PER_TOKEN_DATA)
+    session["token"] = token
+    join_room(f"{token}")
+    join_room(f"pyclients_{token}")
 
 
 @io.on("analysis:figure")
@@ -501,11 +429,11 @@ def debug(data: dict):
     emit("debug", data, include_self=False, to=_webclients_room(data))
 
 
-@io.on("connectedUsers:subscribe:step")
+@io.on("connectedUsers:subscribe:camera")
 @typecast
-def connected_users_subscribe_step(data: SubscribedUserData):
+def connected_users_subscribe_camera(data: SubscribedUserData):
     """
-    Subscribe to step updates for connected users.
+    Subscribe to camera updates for connected users.
 
     data: {user: str}
     """
@@ -520,39 +448,36 @@ def connected_users_subscribe_step(data: SubscribedUserData):
         )
         if (
             controller_client.sid == request.sid
-            or controller_client.step_controller == current_client
+            or controller_client.camera_controller == current_client
         ):
-            current_client.step_controller = None
+            current_client.camera_controller = None
         else:
-            current_client.step_controller = controller_client
+            current_client.camera_controller = controller_client
         ses.commit()
 
 
-@io.on("connectedUsers:subscribe:camera")
-@typecast
-def connected_users_subscribe_camera(data: SubscribedUserData):
-    """
-    Subscribe to camera updates for connected users.
-
-    data: {user: str}
-    """
+@io.on("camera:update")
+def camera_update(data: dict):
     token = str(session["token"])
+    # TODO: store this in the session
     with Session() as ses:
         room = ses.query(db_schema.Room).filter_by(token=token).first()
         if room is None:
             raise ValueError("No room found for token.")
         current_client = ses.query(db_schema.Client).filter_by(sid=request.sid).first()
-        controller_client = (
-            ses.query(db_schema.Client).filter_by(name=data.user).first()
+        camera_subscribers = (
+            ses.query(db_schema.Client)
+            .filter_by(camera_controller=current_client)
+            .all()
         )
-        if (
-            controller_client.sid == request.sid
-            or controller_client.camera_controller == current_client
-        ):
-            current_client.camera_controller = None
-        else:
-            current_client.camera_controller = controller_client
-        ses.commit()
+        camera_subscribers = [client.sid for client in camera_subscribers]
+
+    emit(
+        "camera:update",
+        data,
+        include_self=False,
+        to=camera_subscribers,
+    )
 
 
 @io.on("scene:update")
@@ -646,9 +571,8 @@ def modifier_run(data: dict):
         "modifier:run:enqueue", to=f"webclients_{session['token']}", include_self=False
     )
     # split into separate streams based on the modifier name
-    tasks.run_modifier(
-        f"http://127.0.0.1:{current_app.config['PORT']}", session["token"], data
-    )
+    url = f"http://127.0.0.1:{current_app.config['PORT']}"
+    tasks.run_modifier(url, session["token"], data)
 
 
 def _register_global_modifier(data):
@@ -727,22 +651,57 @@ def modifier_register(data: ModifierRegisterData):
 @io.on("modifier:available")
 def modifier_available(available: bool):
     """Update the modifier availability."""
-    with Session() as ses:
-        global_modifier_client = (
-            ses.query(db_schema.GlobalModifierClient).filter_by(sid=request.sid).first()
-        )
-        room_modifier_client = (
-            ses.query(db_schema.RoomModifierClient).filter_by(sid=request.sid).first()
-        )
-        if global_modifier_client is not None:
-            global_modifier_client.available = available
-        elif room_modifier_client is not None:
-            room_modifier_client.available = available
-        ses.commit()
+    tasks.activate_modifier.delay(request.sid, available)
 
 
-@io.on("modifier:queue:update")
-def modifier_queue_update(data: dict):
-    """Update the modifier queue."""
-    queue_position = get_queue_position(data["queue_name"], data["job_id"])
-    emit("modifier:queue:update", queue_position, to=f"webclients_{session['token']}")
+@io.on("ping")
+def ping() -> str:
+    return "pong"
+
+
+@io.on("room:get")
+def room_get(data: RoomGetData):
+    url = request.url_root
+    if current_app.config["upgrade_insecure_requests"] and not "127.0.0.1" in url:
+        url = url.replace("http://", "https://")
+    tasks.handle_room_get.delay(data, session["token"], url, request.sid)
+
+
+@io.on("room:set")
+@typecast
+def room_set(data: RoomSetData):
+    emit(
+        "room:set",
+        data.to_dict(),
+        include_self=False,
+        to=f"webclients_{session['token']}",
+    )
+    url = request.url_root
+    if current_app.config["upgrade_insecure_requests"] and not "127.0.0.1" in url:
+        url = url.replace("http://", "https://")
+
+    if data.update_database:
+        # TODO: we need to differentiate, if the data comes from a pyclient or a webclient
+        # TODO: for fast updates, e.g. points, step during play this is not fast enough
+        tasks.handle_room_set.delay(data.to_dict(), session["token"], url, request.sid)
+
+
+@io.on("step:update")
+def step_update(step: int):
+    timestamp = datetime.datetime.utcnow().isoformat()
+    session["step-update"] = timestamp
+
+    data = RoomSetData(step=step)
+
+    emit(
+        "room:set",
+        data.to_dict(),
+        include_self=False,
+        to=f"webclients_{session['token']}",
+    )
+    io.sleep(1)
+    if session["step-update"] == timestamp:
+        url = request.url_root
+        if current_app.config["upgrade_insecure_requests"] and not "127.0.0.1" in url:
+            url = url.replace("http://", "https://")
+        tasks.handle_room_set.delay(data.to_dict(), session["token"], url, request.sid)
diff --git a/zndraw/server/tasks.py b/zndraw/server/tasks.py
index 6a66d8a33..1f4653239 100644
--- a/zndraw/server/tasks.py
+++ b/zndraw/server/tasks.py
@@ -4,10 +4,9 @@
 from dataclasses import asdict
 
 import ase.io
-import tqdm
 import znframe
 import znh5md
-from celery import shared_task
+from celery import chain, shared_task
 from socketio import Client
 
 from zndraw.analyse import get_analysis_class
@@ -21,8 +20,9 @@
 from zndraw.zndraw import ZnDraw
 
 from ..app import cache
-from ..data import CeleryTaskData, FrameData
-from .utils import insert_into_queue, remove_job_from_queue
+from ..data import CeleryTaskData, RoomGetData, RoomSetData
+from ..utils import typecast
+from .utils import get_queue_position, insert_into_queue, update_job_status
 
 log = logging.getLogger(__name__)
 
@@ -64,8 +64,6 @@ def update_atoms(token: str, data: list) -> None:
 
 @shared_task
 def get_selection_schema(url: str, target: str):
-    print(f"emitting selection_schema to {target}")
-
     config = GlobalConfig.load()
     cls = get_selection_class(config.get_selection_methods())
     schema = cls.model_json_schema()
@@ -148,7 +146,6 @@ class Scene(BaseModel):
         disconnect=True,
     )
     con = get_client(url)
-    print(f"emitting scene_schema to {target}")
     con.emit("celery:task:emit", asdict(msg))
 
 
@@ -161,7 +158,6 @@ def geometries_schema(url: str, target: str):
         disconnect=True,
     )
     con = get_client(url)
-    print(f"emitting scene_schema to {target}")
     con.emit("celery:task:emit", asdict(msg))
 
 
@@ -183,7 +179,6 @@ def analysis_schema(url: str, token: str):
         disconnect=True,
     )
     con = get_client(url)
-    print(f"emitting analysis_schema to {vis.token}")
     con.emit("celery:task:emit", asdict(msg))
     vis.socket.disconnect()
 
@@ -219,13 +214,14 @@ def modifier_schema(url: str, token: str):
         disconnect=True,
     )
     con = get_client(url)
-    print(f"emitting modifier_schema to {token}")
     con.emit("celery:task:emit", asdict(msg))
 
 
 @shared_task
 def scene_trash(url: str, token: str):
-    vis = ZnDraw(url=url, token=token)
+    from zndraw.zndraw_worker import ZnDrawWorker
+
+    vis = ZnDrawWorker(token=token, url=url)
     del vis[vis.step + 1 :]
     if len(vis.selection) == 0:
         vis.append(ase.Atoms())
@@ -238,128 +234,71 @@ def scene_trash(url: str, token: str):
         vis.append(atoms)
     vis.selection = []
     vis.points = []
+    vis.step = len(vis) - 1
 
-    vis.socket.sleep(10)
+    vis.socket.sleep(1)
     vis.socket.disconnect()
 
 
 @shared_task
 def read_file(url: str, target: str, token: str):
-    vis = ZnDraw(url=url, token=token)
-    con = vis.socket
+    from zndraw.zndraw_worker import ZnDrawWorker
 
-    with Session() as ses:
-        room = ses.query(db_schema.Room).filter_by(token=token).first()
-        # get all frames and iterate over them
-        frames = ses.query(db_schema.Frame).filter_by(room=room)
-        if frames.count() == 0:
-            pass
+    config = GlobalConfig.load()
+
+    vis = ZnDrawWorker(token=token, url=url)
+
+    if len(vis) == 0:
+        fileio = cache.get("FILEIO")
+        if fileio.remote is not None:
+            node_name, attribute = fileio.name.split(".", 1)
+            try:
+                import zntrack
+
+                node = zntrack.from_rev(node_name, remote=fileio.remote, rev=fileio.rev)
+                generator = getattr(node, attribute)
+            except ImportError as err:
+                raise ImportError(
+                    "You need to install ZnTrack to use the remote feature"
+                ) from err
+        elif pathlib.Path(fileio.name).suffix == ".h5":
+            reader = znh5md.ASEH5MD(fileio.name)
+            generator = reader.get_atoms_list()
         else:
-            batch_size = GlobalConfig.load().read_batch_size
-            data_to_send = []
-            for frame in frames.all():
-                data_to_send.append(
-                    FrameData(
-                        index=frame.index,
-                        data=frame.data,
-                        update=True,
-                        update_database=False,
-                    )
-                )
-                if len(data_to_send) == batch_size:
-                    msg = CeleryTaskData(
-                        target=target,
-                        event="atoms:upload",
-                        data=data_to_send,
-                    )
-                    con.emit("celery:task:emit", asdict(msg))
-                    data_to_send = []
-            msg = CeleryTaskData(
-                target=target,
-                event="atoms:upload",
-                data=data_to_send,
-                disconnect=True,
-            )
-            con.emit("celery:task:emit", asdict(msg))
-            return
+            generator = ase.io.iread(fileio.name)
 
-    fileio = cache.get("FILEIO")
-    if fileio.name is None:
-        msg = CeleryTaskData(
-            target=target,
-            event="atoms:upload",
-            data=FrameData(
-                index=0,
-                data=znframe.Frame.from_atoms(ase.Atoms()).to_dict(
-                    built_in_types=False
-                ),
-                update=True,
-                update_database=True,
-            ),
-            disconnect=True,
-        )
-        con.emit("celery:task:emit", asdict(msg))
-        return
+        atoms_list = []
 
-    if fileio.remote is not None:
-        node_name, attribute = fileio.name.split(".", 1)
-        try:
-            import zntrack
-
-            node = zntrack.from_rev(node_name, remote=fileio.remote, rev=fileio.rev)
-            generator = getattr(node, attribute)
-        except ImportError as err:
-            raise ImportError(
-                "You need to install ZnTrack to use the remote feature"
-            ) from err
-    elif pathlib.Path(fileio.name).suffix == ".h5":
-        reader = znh5md.ASEH5MD(fileio.name)
-        generator = reader.get_atoms_list()
-    else:
-        generator = ase.io.iread(fileio.name)
+        for idx, atoms in enumerate(generator):
+            if fileio.start and idx < fileio.start:
+                continue
+            if fileio.stop and idx >= fileio.stop:
+                break
+            if fileio.step and idx % fileio.step != 0:
+                continue
+            atoms_list.append(atoms)
 
-    frame = 0
-    batch_size = GlobalConfig.load().read_batch_size
-    data_to_send = []
-    for idx, atoms in tqdm.tqdm(enumerate(generator), ncols=100):
-        if fileio.start and idx < fileio.start:
-            continue
-        if fileio.stop and idx >= fileio.stop:
-            break
-        if fileio.step and idx % fileio.step != 0:
-            continue
+            if len(atoms_list) == config.read_batch_size:
+                vis.extend(atoms_list)
+                atoms_list = []
 
-        data_to_send.append(
-            FrameData(
-                index=frame,
-                data=znframe.Frame.from_atoms(atoms).to_dict(built_in_types=False),
-                update=True,
-                update_database=True,
-            )
-        )
-        frame += 1
-        if len(data_to_send) == batch_size:
-            msg = CeleryTaskData(
-                target=target,
-                event="atoms:upload",
-                data=data_to_send,
-            )
-            con.emit("celery:task:emit", asdict(msg))
-            data_to_send = []
+        if len(atoms_list) > 0:
+            vis.extend(atoms_list)
 
-    msg = CeleryTaskData(
-        target=target,
-        event="atoms:upload",
-        data=data_to_send,
-        disconnect=True,
-    )
-    con.emit("celery:task:emit", asdict(msg))
+        vis.step = len(vis) - 1
+    else:
+        vis.upload(target)
+
+    vis.socket.sleep(1)
+    vis.socket.disconnect()
 
 
 @shared_task
 def run_selection(url: str, token: str, data: dict):
+    from zndraw.zndraw_worker import ZnDrawWorker
+
+    vis = ZnDrawWorker(token=str(token), url=url)
     print(datetime.datetime.now().isoformat())
-    vis = ZnDraw(url=url, token=token)
 
     config = GlobalConfig.load()
     cls = get_selection_class(config.get_selection_methods())
@@ -372,13 +311,16 @@ def run_selection(url: str, token: str, data: dict):
 
     print(datetime.datetime.now().isoformat())
 
-    vis.socket.sleep(10)
+    vis.socket.sleep(1)
     vis.socket.disconnect()
 
 
 @shared_task
 def run_analysis(url: str, token: str, data: dict):
+    from zndraw.zndraw_worker import ZnDrawWorker
+
     vis = ZnDraw(url=url, token=token)
+    vis = ZnDrawWorker(token=str(token), url=url)
 
     msg = CeleryTaskData(
         target=f"webclients_{vis.token}", event="analysis:run:running", data=None
@@ -405,35 +347,34 @@ def run_analysis(url: str, token: str, data: dict):
     vis.socket.emit("celery:task:emit", asdict(msg))
 
 
-def get_vis_obj(
-    url: str,
-    token: str,
-    queue_name: str,
-    request_id: str,
-):
-    """
-    Return a celery task that runs the modifier. The decorator constructs complex python objects for the celery task
-    """
-    vis = ZnDraw(url=url, token=token)
-    insert_into_queue(queue_name=queue_name, job_id=request_id)
-
-    def on_finished():
-        # TODO: disconnect the modifier, release the worker
-        remove_job_from_queue(queue_name, request_id)
-        vis.socket.disconnect()
+@shared_task
+def update_queue_positions(queue_name, url):
+    from zndraw.zndraw_worker import ZnDrawWorker
 
-    vis.socket.on("modifier:run:finished", on_finished)
-    return vis
+    if queue_name == "slow":
+        queue_positions = get_queue_position(queue_name)
+        worker = ZnDrawWorker(token="None", url=url)
+        for position, room_token in queue_positions:
+            msg = CeleryTaskData(
+                target=f"webclients_{room_token}",
+                event="modifier:queue:update",
+                data=position,
+                disconnect=False,
+            )
+            worker.socket.emit("celery:task:emit", msg.to_dict())
+        worker.socket.sleep(0.5)
+        worker.socket.disconnect()
+    else:
+        return None
 
 
 @shared_task(bind=True)
-def _run_global_modifier(self, url: str, token: str, data):
-    vis = get_vis_obj(
-        url,
-        token,
-        queue_name="slow",
-        request_id=self.request.id,
-    )
+def _run_global_modifier(self, url: str, token: str, data, queue_job_id: str):
+    from zndraw.zndraw_worker import ZnDrawWorker
+
+    vis = ZnDrawWorker(token=str(token), url=url)
+    vis.socket.on("modifier:run:finished", lambda: vis.socket.disconnect())
+
     name = data["method"]["discriminator"]
     while True:
         with Session() as ses:
@@ -451,6 +392,7 @@ def _run_global_modifier(self, url: str, token: str, data):
             )
 
         if assigned_hosts == 0:
+            update_job_status(job_id=queue_job_id, status="failed:no_host")
             msg = CeleryTaskData(
                 target=f"webclients_{vis.token}",
                 event="modifier:run:finished",
@@ -467,22 +409,20 @@ def _run_global_modifier(self, url: str, token: str, data):
             return
 
         if host is None:
-            vis.socket.emit(
-                "modifier:queue:update",
-                {"queue_name": "slow", "job_id": self.request.id},
-            )
             vis.socket.sleep(1)
             log.critical("No modifier available")
             continue
 
         # run the modifier
+        to_get = RoomGetData.get_current_state()
+        cache = vis.get_properties(**to_get.to_dict())
         msg = CeleryTaskData(
             target=host.sid,
             event="modifier:run",
-            data={"params": data, "token": vis.token},
+            data={"params": data, "token": vis.token, "cache": cache},
         )
         vis.socket.emit("celery:task:emit", asdict(msg))
-
+        update_job_status(job_id=queue_job_id, status="running")
         # add additional 5 seconds for communication overhead
         for _ in range(int(host.timeout + 5)):
             if vis.socket.connected:
@@ -491,6 +431,10 @@ def _run_global_modifier(self, url: str, token: str, data):
                 vis.socket.sleep(1)
             else:
                 log.critical("Modifier finished")
+                status = "finished"
+                log.critical("SETTING ")
+                update_job_status(job_id=queue_job_id, status=status)
+                update_queue_positions(queue_name="slow", url=url)
                 return
 
         print("modifier timed out")
@@ -508,18 +452,18 @@ def _run_global_modifier(self, url: str, token: str, data):
             disconnect=True,
         )
         vis.socket.emit("celery:task:emit", asdict(msg))
+        update_job_status(job_id=queue_job_id, status="failed:timeout")
+        update_queue_positions(queue_name="slow", url=url)
         return
 
 
 @shared_task(bind=True)
-def _run_room_modifier(self, url: str, token: str, data):
+def _run_room_modifier(self, url: str, token: str, data, queue_job_id: str):
+    from zndraw.zndraw_worker import ZnDrawWorker
+
+    vis = ZnDrawWorker(token=str(token), url=url)
+
     name = data["method"]["discriminator"]
-    vis = get_vis_obj(
-        url,
-        token,
-        queue_name="custom",
-        request_id=self.request.id,
-    )
     with Session() as ses:
         room = ses.query(db_schema.Room).filter_by(token=vis.token).first()
         room_modifier = (
@@ -557,13 +501,10 @@ def _run_room_modifier(self, url: str, token: str, data):
 
 
 @shared_task(bind=True)
-def _run_default_modifier(self, url: str, token: str, data):
-    vis = get_vis_obj(
-        url,
-        token,
-        queue_name="fast",
-        request_id=self.request.id,
-    )
+def _run_default_modifier(self, url: str, token: str, data: dict, queue_job_id: str):
+    from zndraw.zndraw_worker import ZnDrawWorker
+
+    vis = ZnDrawWorker(token=str(token), url=url)
     config = GlobalConfig.load()
     cls = get_modify_class(config.get_modify_methods())
     modifier = cls(**data)
@@ -576,8 +517,12 @@ def _run_default_modifier(self, url: str, token: str, data):
 
     try:
         modifier.run(vis)
+        status = "finished"
     except Exception as err:
-        vis.log(f"Error: {err}")
+        # exception type
+        exception_type = type(err).__name__
+        vis.log(f"Error: {exception_type}:{err}")
+        status = f"failed: {exception_type}:{err}"
 
     msg = CeleryTaskData(
         target=f"webclients_{vis.token}",
@@ -587,20 +532,159 @@ def _run_default_modifier(self, url: str, token: str, data):
     )
 
     vis.socket.emit("celery:task:emit", asdict(msg))
+    update_job_status(job_id=queue_job_id, status=status)
+    vis.socket.disconnect()
 
 
-def run_modifier(url: str, token: str, data: dict):
-    name = data["method"]["discriminator"]
+def route_modifier_to_queue(name: str, token: str) -> str:
     with Session() as ses:
         room = ses.query(db_schema.Room).filter_by(token=token).first()
         room_modifiers = ses.query(db_schema.RoomModifier).filter_by(room=room).all()
         modifiers = ses.query(db_schema.GlobalModifier).all()
         custom_global_modifiers = [modifier.name for modifier in modifiers]
-
+        custom_room_modifiers = [modifier.name for modifier in room_modifiers]
     if name in custom_global_modifiers:
-        _run_global_modifier.delay(url, token, data)
-    elif name in room_modifiers:
-        _run_room_modifier.delay(url, token, data)
+        queue_name = "slow"
+    elif name in custom_room_modifiers:
+        queue_name = "custom"
+    else:
+        queue_name = "default"
+    return queue_name
+
+
+def run_modifier(url: str, token: str, data: dict):
+    name = data["method"]["discriminator"]
+    queue_name = route_modifier_to_queue(name, token)
+    queue_job_id = insert_into_queue(
+        queue_name=queue_name, job_name=name, room_token=token
+    )
+    if queue_name == "slow":
+        task_chain = chain(
+            update_queue_positions.si(queue_name, url),
+            _run_global_modifier.si(url, token, data, queue_job_id),
+        )
+    elif queue_name == "custom":
+        task_chain = chain(
+            update_queue_positions.si(queue_name, url),
+            _run_room_modifier.si(url, token, data, queue_job_id),
+        )
     else:
-        _run_default_modifier.delay(url, token, data)
-    return
+        task_chain = chain(
+            update_queue_positions.si(queue_name, url),
+            _run_default_modifier.si(url, token, data, queue_job_id),
+        )
+    task_chain.delay()
+
+
+@shared_task
+@typecast
+def handle_room_get(data: RoomGetData, token: str, url: str, target: str):
+    from zndraw.zndraw_worker import ZnDrawWorker
+
+    worker = ZnDrawWorker(token=token, url=url)
+    answer = worker.get_properties(**data.to_dict())
+    msg = CeleryTaskData(target=target, event="room:get", data=answer, disconnect=True)
+    worker.socket.emit("celery:task:emit", msg.to_dict())
+
+
+@shared_task
+@typecast
+def handle_room_set(data: RoomSetData, token: str, url: str, source: str):
+    from zndraw.zndraw_worker import ZnDrawWorker
+
+    worker = ZnDrawWorker(token=token, url=url, emit=False)
+    if data.frames:
+        # must be first, before step
+        # frames should either all be None or all be not None
+        if any(frame is None for frame in data.frames.values()) and any(
+            frame is not None for frame in data.frames.values()
+        ):
+            raise ValueError("All frames must be None or not None")
+        is_removing = all(frame is None for frame in data.frames.values())
+        indices = list(data.frames.keys())
+        if is_removing:
+            log.critical(f"Removing frames {indices}")
+            del worker[indices]
+        else:
+            frames = [znframe.Frame.from_dict(frame) for frame in data.frames.values()]
+            worker[indices] = frames
+    if data.step is not None:
+        worker.step = data.step
+    if data.points is not None:
+        worker.points = data.points
+    if data.bookmarks is not None:
+        worker.bookmarks = data.bookmarks
+    if data.selection is not None:
+        worker.selection = data.selection
+
+    msg = CeleryTaskData(
+        target=source,
+        event="room:set:finished",
+        data=None,
+        disconnect=True,
+    )
+    worker.socket.emit("celery:task:emit", msg.to_dict())
+    # worker.commit() and a mode, that waits for all updates before commiting
+
+
+@shared_task
+def activate_modifier(sid: str, available: bool):
+    log.critical(f"modifier:available {sid} {available}")
+    with Session() as ses:
+        global_modifier_client = (
+            ses.query(db_schema.GlobalModifierClient).filter_by(sid=sid).first()
+        )
+        room_modifier_client = (
+            ses.query(db_schema.RoomModifierClient).filter_by(sid=sid).first()
+        )
+        if global_modifier_client is not None:
+            global_modifier_client.available = available
+        elif room_modifier_client is not None:
+            room_modifier_client.available = available
+        ses.commit()
+
+
+@shared_task
+def on_disconnect(token: str, sid: str, url: str):
+    # from zndraw.zndraw_worker import ZnDrawWorker
+
+    # worker = ZnDrawWorker(token=token, url=url)
+    with Session() as ses:
+        room = ses.query(db_schema.Room).filter_by(token=token).first()
+        client = ses.query(db_schema.Client).filter_by(sid=sid).first()
+        if client is not None:
+            ses.delete(client)
+            if client.host:
+                new_host = ses.query(db_schema.Client).filter_by(room=room).first()
+                if new_host is not None:
+                    new_host.host = True
+            ses.commit()
+
+    with Session() as ses:
+        global_modifier_client = (
+            ses.query(db_schema.GlobalModifierClient).filter_by(sid=sid).all()
+        )
+        for gmc in global_modifier_client:
+            ses.delete(gmc)
+
+        room_modifier_client = (
+            ses.query(db_schema.RoomModifierClient).filter_by(sid=sid).all()
+        )
+        for rmc in room_modifier_client:
+            ses.delete(rmc)
+        ses.commit()
+
+    with Session() as ses:
+        room = ses.query(db_schema.Room).filter_by(token=token).first()
+        clients = ses.query(db_schema.Client).filter_by(room=room).all()
+        connected_users = [{"name": client.name} for client in clients]
+
+    # msg = CeleryTaskData(
+    #     target=f"webclients_{token}",
+    #     event="connectedUsers",
+    #     data=list(reversed(connected_users)),
+    #     disconnect=True,
+    # )
+    # # TODO this can not work, because it triggers itself
+
+    # worker.socket.emit("celery:task:emit", msg.to_dict())
diff --git a/zndraw/server/utils.py b/zndraw/server/utils.py
index 44de722c8..835be104e 100644
--- a/zndraw/server/utils.py
+++ b/zndraw/server/utils.py
@@ -1,7 +1,10 @@
+import uuid
+from datetime import datetime
+
 from sqlalchemy.orm import Session
 
 from ..db import Session as ses
-from ..db.schema import Queue, QueueItem
+from ..db.schema import Queue, QueueItem, Room
 
 
 def get_queue(session: Session, queue_name: str) -> Queue:
@@ -13,28 +16,40 @@ def get_queue(session: Session, queue_name: str) -> Queue:
     return queue
 
 
-def insert_into_queue(queue_name: str, job_id: str) -> None:
+def get_room_by_token(session: Session, token: str):
+    return session.query(Room).filter_by(token=token).one()
+
+
+def insert_into_queue(queue_name: str, job_name: str, room_token: str) -> str:
+    job_id = uuid.uuid4().hex
     with ses() as session:
         queue = get_queue(session, queue_name)
-        job = QueueItem(job_id=job_id)
+        room = get_room_by_token(session, room_token)
+        job = QueueItem(job_name=job_name, job_id=job_id, datetime=datetime.utcnow())
         queue.jobs.append(job)
+        room.launched_jobs.append(job)
         session.commit()
+    return job_id
 
 
-def remove_job_from_queue(queue_name: str, job_id: str) -> None:
+def update_job_status(job_id: str | int, status: str) -> None:
     with ses() as session:
-        queue = get_queue(session, queue_name)
-        for job in queue.jobs:
-            if job.job_id == job_id:
-                queue.jobs.remove(job)
-
+        session.query(QueueItem).filter_by(job_id=job_id).update(dict(status=status))
         session.commit()
 
 
-def get_queue_position(queue_name: str, job_id: str) -> int:
+def get_queue_position(queue_name: str) -> list[tuple[int, str]]:
+    results = []
     with ses() as session:
-        queue = get_queue(session, queue_name)
-        for i, job in enumerate(queue.jobs):
-            if job.job_id == job_id:
-                return i
-        return -1
+        queueing_jobs = (
+            session.query(QueueItem)
+            .filter_by(status="queued", queue_name=queue_name)
+            .all()
+        )
+        rooms = [job.room_token for job in queueing_jobs]
+        for room in rooms:
+            largest_idx = max(
+                [i + 1 for i, job in enumerate(queueing_jobs) if job.room_token == room]
+            )
+            results.append((largest_idx, room))
+    return results
diff --git a/zndraw/settings.py b/zndraw/settings.py
index 17e10b8e6..d7521c691 100644
--- a/zndraw/settings.py
+++ b/zndraw/settings.py
@@ -40,6 +40,7 @@
     "zndraw.select.Random",
     "zndraw.select.IdenticalSpecies",
     "zndraw.select.Neighbour",
+    "zndraw.select.UpdateSelection",
 ]
 
 
@@ -59,16 +60,43 @@ def to_dict(self):
         )
 
 
-class CeleryConfig(pydantic.BaseModel):
-    data_folder: str = "~/.zincware/zndraw/celery/out"
-    data_folder_processed: str = "~/.zincware/zndraw/celery/processed"
+class CeleryBaseConfig(pydantic.BaseModel):
+    name: t.Literal["CeleryBaseConfig"] = "CeleryBaseConfig"
     broker: str = "filesystem://"
     result_backend: str = "cache"
     cache_backend: str = "memory"
     task_ignore_result: bool = True
-    task_routes: dict = {
-        "*._run_global_modifier": {"queue": "slow"},
-    }
+
+    @property
+    def task_routes(self):
+        return {
+            "*._run_global_modifier": {"queue": "slow"},
+            "*.update_atoms": {"queue": "io"},
+            "*.get_selection_schema": {"queue": "io"},
+            "*.scene_schema": {"queue": "io"},
+            "*.geometries_schema": {"queue": "io"},
+            "*.analysis_schema": {"queue": "io"},
+            "*.handle_room_get": {"queue": "io"},
+            "*.handle_room_set": {"queue": "io"},
+            "*.activate_modifier": {"queue": "io"},
+            "*on_disconnect": {"queue": "io"},
+        }
+
+    def to_dict(self):
+        return dict(
+            broker_url=self.broker,
+            result_backend=self.result_backend,
+            cache_backend=self.cache_backend,
+            task_ignore_result=self.task_ignore_result,
+            task_routes=self.task_routes,
+        )
+
+
+class CeleryFileSystemConfig(CeleryBaseConfig):
+    name: t.Literal["CeleryFileSystemConfig"] = "CeleryFileSystemConfig"
+    data_folder: str = "~/.zincware/zndraw/celery/out"
+    data_folder_processed: str = "~/.zincware/zndraw/celery/processed"
+    task_ignore_result: bool = True
 
     def to_dict(self):
         return dict(
@@ -85,27 +113,50 @@ def to_dict(self):
         )
 
 
-class DatabaseConfig(pydantic.BaseModel):
+class DatabaseSQLiteConfig(pydantic.BaseModel):
+    name: t.Literal["DatabaseSQLiteConfig"] = "DatabaseSQLiteConfig"
+
     path: str = "~/.zincware/zndraw/database.sqlite"
 
     def get_path(self) -> str:
         path = pathlib.Path(self.path).expanduser()
         path.parent.mkdir(parents=True, exist_ok=True)
         return f"sqlite:///{path}"
-        # return f"sqlite:///{path}?check_same_thread=False"
+
+
+class DatabasePostgresConfig(pydantic.BaseModel):
+    name: t.Literal["DatabasePostgresConfig"] = "DatabasePostgresConfig"
+
+    host: str = "localhost"
+    port: int = 5432
+    database: str = "zndraw"
+    user: str = "zndraw"
+    password: str = "zndraw"
+
+    def get_path(self) -> str:
+        return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
 
 
 class GlobalConfig(pydantic.BaseModel):
-    cache: CacheSettings = CacheSettings()
-    celery: CeleryConfig = CeleryConfig()
-    database: DatabaseConfig = DatabaseConfig()
+    cache: CacheSettings = pydantic.Field(default_factory=CacheSettings)
+    celery: t.Union[CeleryBaseConfig, CeleryFileSystemConfig] = pydantic.Field(
+        default_factory=CeleryFileSystemConfig, discriminator="name"
+    )
+    database: t.Union[DatabasePostgresConfig, DatabaseSQLiteConfig] = pydantic.Field(
+        default_factory=DatabaseSQLiteConfig, discriminator="name"
+    )
+
+    # Socket settings
+    read_batch_size: int = 1
+    max_socket_data_size: int | float = 1024 * 8
 
+    # Webclient Interface
     analysis_functions: t.List[str] = _ANALYSIS_FUNCTIONS
     modify_functions: t.List[str] = _MODIFY_FUNCTIONS
     bonds_functions: t.List[str] = _BONDS_FUNCTIONS
     selection_functions: t.List[str] = _SELECTION_FUNCTIONS
     function_schema: t.Dict[str, dict] = {}
-    read_batch_size: int = 1
+    read_batch_size: int = 8
 
     def save(self, path="~/.zincware/zndraw/config.json"):
         save_path = pathlib.Path(path).expanduser()
diff --git a/zndraw/static/UI/UI.js b/zndraw/static/UI/UI.js
index c2daf85ba..ade1ba594 100644
--- a/zndraw/static/UI/UI.js
+++ b/zndraw/static/UI/UI.js
@@ -89,6 +89,7 @@ function setupDragDrop(socket) {
 function setupTrashClick(socket) {
   document.getElementById("trashBtn").addEventListener("click", () => {
     socket.emit("scene:trash");
+    // TODO: should this be a modifier?
   });
 }
 
diff --git a/zndraw/static/UI/json_editor.js b/zndraw/static/UI/json_editor.js
index 88a539ff1..146212cad 100644
--- a/zndraw/static/UI/json_editor.js
+++ b/zndraw/static/UI/json_editor.js
@@ -158,21 +158,6 @@ function modifier_editor(socket, cache, world) {
     schema: { type: "object", title: "ZnDraw", properties: {} },
   });
 
-  // let responseReceived = false;
-
-  // socket.on("modifier:run:submitted", () => {
-  //   console.log(new Date().toISOString(), "modifier:run:submitted");
-  //   setTimeout(() => {
-  //     if (!responseReceived) {
-  //       console.warn("No response on 'modifier:run:running' within 1 second");
-  //       alert("No response from server. Please try again.");
-  //       document.getElementById("interaction-json-editor-submit").disabled =
-  //         false;
-  //       socket.emit("modifier:run:failed");
-  //     }
-  //   }, 1000);
-  // });
-
   socket.on("modifier:queue:update", (position) => {
     document.getElementById("interaction-json-editor-submit").disabled = true;
     document.getElementById("interaction-json-editor-submit").innerHTML =
@@ -200,14 +185,6 @@ function modifier_editor(socket, cache, world) {
       '<i class="fa-solid fa-hourglass-start"></i> Job queued';
   });
 
-  // socket.on("modifier:run:criticalfail", (data) => {
-  //   console.log(new Date().toISOString(), "modifier:run:failed");
-  //   alert("Modifier failed. Please try again with different settings.");
-  //   document.getElementById("interaction-json-editor-submit").innerHTML =
-  //     '<i class="fa-solid fa-play"></i> Run Modifier';
-  //   document.getElementById("interaction-json-editor-submit").disabled = false;
-  // });
-
   document
     .getElementById("interaction-json-editor-submit")
     .addEventListener("click", () => {
diff --git a/zndraw/static/World/World.js b/zndraw/static/World/World.js
index aab61544d..132a499a2 100644
--- a/zndraw/static/World/World.js
+++ b/zndraw/static/World/World.js
@@ -35,19 +35,6 @@ class Player {
     this.loop = false;
     this.bookmarks = bookmarks;
 
-    socket.on("scene:set", (index) => {
-      this.world.setStep(index);
-    });
-
-    socket.on("scene:play", () => {
-      this.playing = true;
-      this.play();
-    });
-
-    socket.on("scene:pause", () => {
-      this.playing = false;
-    });
-
     // detect playBtn click
     document.getElementById("playBtn").addEventListener("click", () => {
       this.toggle();
@@ -160,6 +147,8 @@ class Player {
   }
 
   play() {
+    // TODO: do not update the database upon play but only on stop,
+    //  because otherwise, the db will not keep up
     if (this.playing) {
       this.go_forward();
       setTimeout(() => this.play(), 1000 / this.fps);
@@ -245,64 +234,40 @@ class World {
     this.step = loop.step;
     this.socket = socket;
 
-    this.socket.on(
-      "points:get",
-      function (callback) {
-        const { points, segments } = this.getLineData();
-        callback(points);
-      }.bind(this),
-    );
-
-    this.socket.on(
-      "points:set",
-      function (points) {
-        this.line3D.updateAllPoints(points);
-      }.bind(this),
-    );
-
-    this.socket.on(
-      "scene:segments",
-      function (callback) {
-        const { points, segments } = this.getLineData();
-        callback(segments);
-      }.bind(this),
-    );
-
-    this.socket.on(
-      "scene:step",
-      function (callback) {
-        callback(this.getStep());
-      }.bind(this),
-    );
-
-    this.socket.on(
-      "selection:get",
-      function (callback) {
-        callback(this.getSelection());
-      }.bind(this),
-    );
-
-    this.socket.on("scene:update", (data) => {
-      if (data.step !== undefined) {
-        this.setStep(data.step);
+    this.socket.on("room:set", (data) => {
+      if (data.step !== null) {
+        // small timeout to ensure the step is set after the cache is updated
+        setTimeout(() => this.setStep(data.step, false), 100);
+      }
+      if (data.frames !== null) {
+        cache.setFrames(data.frames);
       }
-      if (data.camera !== undefined) {
-        camera.position.set(...data.camera.position);
-        controls.target.set(...data.camera.target);
-        controls.update();
+      if (data.selection !== null) {
+        const particlesGroup = this.scene.getObjectByName("particlesGroup");
+        particlesGroup.selection = data.selection;
+        particlesGroup.step();
+      }
+      if (data.bookmarks !== null) {
+        console.log(data.bookmarks);
+        bookmarks.set(data.bookmarks);
+      }
+      if (data.points !== null) {
+        this.line3D.updateAllPoints(data.points);
       }
     });
 
-    // on camera move send the camera position to the server
+    this.socket.on("camera:update", (data) => {
+      camera.position.set(...data.position);
+      controls.target.set(...data.target);
+      controls.update();
+    });
+
     controls.addEventListener("change", () => {
-      this.socket.emit("scene:update", {
-        camera: {
-          position: camera.position.toArray(),
-          target: controls.target.toArray(),
-        },
+      this.socket.emit("camera:update", {
+        position: camera.position.toArray(),
+        target: controls.target.toArray(),
       });
     });
-    // renderer.render(scene, camera);
   }
 
   /**
@@ -342,16 +307,17 @@ class World {
       bonds_size,
     );
     this.cell_grp.set_visibility(simulation_box);
-    this.setStep(loop.step);
+    this.setStep(loop.step, false);
     this.index_grp.rebuild(label_offset);
     this.player.fps = fps;
     this.line3D.show_label = line_label;
   }
 
-  setStep(step) {
+  setStep(step, emit = true) {
     step = parseInt(step);
     loop.setStep(step);
     const slider = document.getElementById("frameProgress");
+    const currentStep = parseInt(slider.ariaValueNow);
     slider.ariaValueNow = step;
     // update slider.style with with the percentage of the slider.value
     const percentage = (slider.ariaValueNow / slider.ariaValueMax) * 100;
@@ -359,7 +325,9 @@ class World {
     sliderprogress.style.width = `${percentage}%`;
     document.getElementById("info").innerHTML =
       `${slider.ariaValueNow} / ${slider.ariaValueMax}`;
-    this.socket.emit("scene:update", { step: step });
+    if (emit) {
+      this.socket.emit("step:update", step);
+    }
   }
 
   getStep() {
diff --git a/zndraw/static/World/systems/select.js b/zndraw/static/World/systems/select.js
index 2ee597487..61f441893 100644
--- a/zndraw/static/World/systems/select.js
+++ b/zndraw/static/World/systems/select.js
@@ -21,7 +21,7 @@ class Selection {
       let points = this.line3D.anchorPoints.children.map((x) => x.position);
       // convert x, y, z to [x, y, z]
       points = points.map((x) => [x.x, x.y, x.z]);
-      this.socket.emit("points:set", points);
+      this.socket.emit("room:set", { points: points, update_database: true });
     };
 
     this.raycaster = new THREE.Raycaster();
@@ -64,12 +64,6 @@ class Selection {
       return particlesGroup.get_center(selection);
     };
 
-    this.socket.on("selection:set", (data) => {
-      const particlesGroup = this.scene.getObjectByName("particlesGroup");
-      particlesGroup.selection = data;
-      particlesGroup.step();
-    });
-
     window.addEventListener("wheel", this.onWheel.bind(this));
 
     this.transform_controls.addEventListener("dragging-changed", (event) => {
@@ -85,8 +79,7 @@ class Selection {
       }
     });
 
-    window.addEventListener("pointerdown", this.onPointerDown.bind(this));
-    window.addEventListener("dblclick", this.onDoubleClick.bind(this));
+    window.addEventListener("click", this.onClick.bind(this));
   }
 
   getIntersections(object) {
@@ -106,12 +99,6 @@ class Selection {
 
     const particleIntersects = this.getIntersections(particlesGroup);
     if (particleIntersects.length > 0) {
-      const instanceId = particleIntersects[0].instanceId;
-      particlesGroup.click(
-        instanceId,
-        this.shift_pressed,
-        particleIntersects[0].object,
-      );
       const params = document.getElementById(
         "selection-json-editor",
       ).parameters;
@@ -171,7 +158,12 @@ class Selection {
     return false;
   }
 
-  onPointerDown(event) {
+  onClick(event) {
+    // detect double click
+    if (event.detail === 2) {
+      this.onDoubleClick(event);
+      return;
+    }
     const particlesGroup = this.scene.getObjectByName("particlesGroup");
     const anchorPoints = this.scene.getObjectByName("AnchorPoints");
     const canvas3D = this.scene.getObjectByName("canvas3D");
@@ -219,7 +211,10 @@ class Selection {
           this.shift_pressed,
           particleIntersects[0].object,
         );
-        this.socket.emit("selection:set", particlesGroup.selection);
+        this.socket.emit("room:set", {
+          selection: particlesGroup.selection,
+          update_database: true,
+        });
       }
     }
   }
diff --git a/zndraw/static/main.js b/zndraw/static/main.js
index a3c3bec02..724543b55 100644
--- a/zndraw/static/main.js
+++ b/zndraw/static/main.js
@@ -11,6 +11,8 @@ function setupSocket() {
   });
 
   socket.on("debug", (msg) => {
+    // simulate sending a message from the client
+    // to the server, triggerd by the server itself
     console.log(msg);
     socket.emit(msg["event"], msg["data"]);
   });
diff --git a/zndraw/static/pycom/Bookmarks.js b/zndraw/static/pycom/Bookmarks.js
index 2a256afc5..10c6697cf 100644
--- a/zndraw/static/pycom/Bookmarks.js
+++ b/zndraw/static/pycom/Bookmarks.js
@@ -5,11 +5,6 @@ class Bookmarks {
     this.socket = socket;
     this.bookmarks = {};
 
-    this.socket.on("bookmarks:set", (bookmarks) => {
-      this.bookmarks = bookmarks;
-      this.updateBookmarks();
-    });
-
     // on keypress b set the bookmark
     window.addEventListener("keypress", (event) => {
       if (document.activeElement === document.body && event.key === "b") {
@@ -19,7 +14,10 @@ class Bookmarks {
         this.bookmarks[step] = `Bookmark ${step}`;
         // // update the bookmarks
         this.updateBookmarks();
-        this.socket.emit("bookmarks:set", this.bookmarks);
+        this.socket.emit("room:set", {
+          bookmarks: this.bookmarks,
+          update_database: true,
+        });
       }
     });
   }
@@ -68,6 +66,11 @@ class Bookmarks {
     }
   }
 
+  set(bookmarks) {
+    this.bookmarks = bookmarks;
+    this.updateBookmarks();
+  }
+
   step() {
     this.updateBookmarks();
   }
diff --git a/zndraw/static/pycom/Cache.js b/zndraw/static/pycom/Cache.js
index 10d50b1f1..f4f3e4306 100644
--- a/zndraw/static/pycom/Cache.js
+++ b/zndraw/static/pycom/Cache.js
@@ -88,130 +88,6 @@ class Cache {
     this._socket = socket;
     this._cache = {};
     this.world;
-
-    this._socket.on("atoms:upload", (data) => {
-      // console.log(new Date().toISOString(), "Received atoms from Python");
-
-      // pop key from data dict
-      const slider = document.getElementById("frameProgress");
-      // iterate through data which is a list
-      for (const x of data) {
-        this._cache[x.index] = new Atoms({
-          positions: x.data.positions,
-          cell: x.data.cell,
-          numbers: x.data.numbers,
-          colors: x.data.arrays.colors,
-          radii: x.data.arrays.radii,
-          connectivity: x.data.connectivity,
-          calc: x.data.calc,
-          pbc: x.data.pbc,
-        });
-
-        slider.ariaValueMax = Object.keys(this._cache).length - 1;
-        if (x.update) {
-          this.world.setStep(x.index);
-        }
-      }
-    });
-
-    this._socket.on("atoms:delete", (ids) => {
-      for (const id of ids) {
-        delete this._cache[id];
-      }
-      // move all keys after id one step back
-      const remainingKeys = Object.keys(this._cache);
-      for (let i = ids[0]; i < remainingKeys.length; i++) {
-        const currentKey = remainingKeys[i];
-        const newIndex = i;
-        if (currentKey !== newIndex) {
-          this._cache[newIndex] = this._cache[currentKey];
-          delete this._cache[currentKey];
-        }
-      }
-      // update slider
-      const slider = document.getElementById("frameProgress");
-
-      // update world
-      if (this.world.getStep() >= slider.ariaValueMax) {
-        this.world.setStep(remainingKeys.length - 1);
-      } else {
-        let newStep = this.world.getStep();
-        ids.forEach((id) => {
-          if (this.world.getStep() > id) {
-            newStep = newStep - 1;
-          }
-        });
-        this.world.setStep(newStep);
-      }
-
-      slider.ariaValueMax = remainingKeys.length - 1;
-      document.getElementById("info").innerHTML =
-        `${slider.ariaValueNow} / ${slider.ariaValueMax}`;
-    });
-
-    this._socket.on("atoms:insert", (data) => {
-      // move all keys after id one step forward
-      const remainingKeys = Object.keys(this._cache);
-      const id = parseInt(Object.keys(data)[0]);
-      for (let i = remainingKeys.length - 1; i >= id; i--) {
-        const currentKey = remainingKeys[i];
-        const newIndex = i + 1;
-        this._cache[newIndex] = this._cache[currentKey];
-        delete this._cache[currentKey];
-      }
-      // insert new atoms
-      this._cache[id] = new Atoms({
-        positions: data[id].positions,
-        cell: data[id].cell,
-        numbers: data[id].numbers,
-        colors: data[id].arrays.colors,
-        radii: data[id].arrays.radii,
-        connectivity: data[id].connectivity,
-        calc: data[id].calc,
-        pbc: data[id].pbc,
-      });
-      // update slider
-      const slider = document.getElementById("frameProgress");
-      slider.ariaValueMax = Object.keys(this._cache).length - 1;
-      document.getElementById("info").innerHTML =
-        `${slider.ariaValueNow} / ${slider.ariaValueMax}`;
-    });
-
-    this._socket.on("atoms:download", (ids, callback) => {
-      // send all atoms at once
-      const data = {};
-      ids.forEach((x) => {
-        try {
-          const { colors, radii, length, ...rest } = this._cache[x];
-          data[x] = {
-            ...rest,
-            arrays: {
-              colors,
-              radii,
-            },
-          };
-        } catch (error) {
-          data[x] = undefined;
-        }
-      });
-      callback(data);
-    });
-
-    this._socket.on("atoms:clear", (start_index) => {
-      // remove everything from the cache starting from start_index
-      Object.keys(this._cache).forEach((key) => {
-        if (parseInt(key) >= start_index) {
-          delete this._cache[key];
-        }
-      });
-    });
-
-    this._socket.on(
-      "atoms:length",
-      function (callback) {
-        callback(this.get_length());
-      }.bind(this),
-    );
   }
 
   get(id) {
@@ -231,6 +107,40 @@ class Cache {
   attachWorld(world) {
     this.world = world;
   }
+
+  setFrames(data) {
+    const slider = document.getElementById("frameProgress");
+    const indicesToDelete = [];
+    for (const [index, atoms] of Object.entries(data)) {
+      if (atoms === null) {
+        delete this._cache[index];
+      } else {
+        this._cache[index] = new Atoms({
+          positions: atoms.positions,
+          cell: atoms.cell,
+          numbers: atoms.numbers,
+          colors: atoms.arrays.colors,
+          radii: atoms.arrays.radii,
+          connectivity: atoms.connectivity,
+          calc: atoms.calc,
+          pbc: atoms.pbc,
+        });
+        slider.ariaValueMax = Object.keys(this._cache).length - 1;
+      }
+    }
+    // reset the keys of the cache to go from 0 to n
+    const newCache = {};
+    let i = 0;
+    for (const key in this._cache) {
+      console.log(key);
+      newCache[i] = this._cache[key];
+      i++;
+    }
+    this._cache = newCache;
+    // no longer required?
+    // this.world.setStep(this.world.getStep());
+    slider.ariaValueMax = Object.keys(this._cache).length - 1;
+  }
 }
 
 export { Cache };
diff --git a/zndraw/utils.py b/zndraw/utils.py
index c68cabfe1..ea227e2d9 100644
--- a/zndraw/utils.py
+++ b/zndraw/utils.py
@@ -12,6 +12,7 @@
 import ase
 import datamodel_code_generator
 import numpy as np
+import znframe
 from decorator import decorator
 
 SHARED = {"atoms": None}
@@ -23,6 +24,15 @@ def split_list_into_chunks(lst, n):
         yield lst[i : i + n]
 
 
+def estimate_max_batch_size_for_socket(frames: list[znframe.Frame]):
+    from .settings import GlobalConfig
+
+    max_size = GlobalConfig().max_socket_data_size
+    sizes = [sys.getsizeof(frame) for frame in frames]
+    largest_frame = max(sizes)
+    return int(max_size / largest_frame * 0.9)
+
+
 def rgb2hex(value):
     r, g, b = np.array(value * 255, dtype=int)
     return "#%02x%02x%02x" % (r, g, b)
@@ -115,7 +125,6 @@ def hide_discriminator_field(d: dict) -> None:
 @decorator
 def typecast(func, *args, **kwargs):
     annotations = get_type_hints(func)
-    print(args, kwargs, annotations)
     updated_args = []
     updated_kwargs = {}
     for arg, arg_type in zip(args, annotations.values()):
@@ -151,3 +160,18 @@ def ensure_path(path: str):
     p = pathlib.Path(path).expanduser()
     p.mkdir(parents=True, exist_ok=True)
     return p.as_posix()
+
+
+def wrap_and_check_index(index: int | slice | list[int], length: int) -> list[int]:
+    is_slice = isinstance(index, slice)
+    if is_slice:
+        index = list(range(*index.indices(length)))
+    index = [index] if isinstance(index, int) else index
+    index = [i if i >= 0 else length + i for i in index]
+    # check if index is out of range
+    for i in index:
+        if i >= length:
+            raise IndexError(f"Index {i} out of range for length {length}")
+        if i < 0:
+            raise IndexError(f"Index {i-length} out of range for length {length}")
+    return index
diff --git a/zndraw/zndraw.py b/zndraw/zndraw.py
index 75a9ac6be..fb11b29cc 100644
--- a/zndraw/zndraw.py
+++ b/zndraw/zndraw.py
@@ -1,22 +1,27 @@
 import dataclasses
 import logging
-import threading
 import typing as t
+from threading import Lock
 
 import ase
 import ase.io
 import numpy as np
 import socketio
+from socketio import exceptions as socketio_exceptions
 from znframe.frame import Frame
 
-from zndraw.data import CeleryTaskData, FrameData, ModifierRegisterData
+from zndraw.data import CeleryTaskData, ModifierRegisterData
 from zndraw.modify import UpdateScene, get_modify_class
 from zndraw.settings import GlobalConfig
-from zndraw.utils import (
-    ZnDrawLoggingHandler,
-)
 
-from .utils import split_list_into_chunks
+from .base import ZnDrawBase
+from .data import RoomGetData, RoomSetData
+from .utils import (
+    estimate_max_batch_size_for_socket,
+    split_list_into_chunks,
+    wrap_and_check_index,
+)
+from .zndraw_frozen import FrozenZnDraw
 
 log = logging.getLogger(__name__)
 
@@ -36,7 +41,7 @@ class Config:
 
 
 @dataclasses.dataclass
-class ZnDrawBase:  # collections.abc.MutableSequence
+class ZnDraw(ZnDrawBase):
     """
 
     Attributes
@@ -52,23 +57,26 @@ class ZnDrawBase:  # collections.abc.MutableSequence
         not just the current session.
     """
 
-    url: str
-    token: str = None
     display_new: bool = True
-    auth_token: str = None
+    auth_token: str | None = None
     config: Config = dataclasses.field(default_factory=Config)
     _modifiers: dict = dataclasses.field(default_factory=dict)
 
-    _target_sid: str = None
+    _target_sid: str | None = None
+
+    _lock: Lock = dataclasses.field(default_factory=Lock)
+    _data = None
 
     def __post_init__(self):
-        self.socket = socketio.Client()
         if isinstance(self.config, dict):
             self.config = Config(**self.config)
         self.socket.on("disconnect", self._on_disconnect)
         self.socket.on("connect", self._on_connect)
         self.socket.on("modifier:run", self._pre_modifier_run)
         self.socket.on("message:log", lambda data: print(data))
+        self.socket.on("room:get", lambda data: setattr(self, "_data", data))
+        self.socket.on("room:set:finished", lambda *args: self._lock.release())
+        super().__post_init__()
 
     def _on_disconnect(self):
         log.critical(f"Disconnected from server: {self._modifiers}")
@@ -95,53 +103,66 @@ def _connect(self):
 
     def reconnect(self) -> None:
         """Reconnect to the server."""
-        log.critical("Reconnecting to server")
-        self.socket.disconnect()
-        self._connect()
-        self._on_connect()
-        self.socket.sleep(0.1)
-        log.critical("Reconnected to server")
+        super().reconnect()
+
+        for k, v in self._modifiers.items():
+            log.critical(f"Re-registering modifier {k}")
+            msg = ModifierRegisterData(
+                schema=v["cls"].model_json_schema(),
+                name=k,
+                default=v["default"],
+                timeout=v["run_kwargs"]["timeout"],
+            )
+            self.socket.emit(
+                "modifier:register",
+                dataclasses.asdict(msg),
+            )
+
+            self.socket.emit(
+                "modifier:available",
+                True,
+            )
+
+    def get_data(self, **data: dict) -> RoomGetData:
+        data = RoomGetData(**data)
+        with self._lock:
+            self._data = None
+            self.socket.emit("room:get", data.to_dict())
+            while self._data is None:
+                self.socket.sleep(seconds=1)
+                # generous timeout
+            # self._data.pop("update_database", None) # TODO: this should not happen
+            data = RoomGetData(**self._data)
+            self._data = None
+            return data
+
+    def set_data(self, **data: dict) -> None:
+        data = RoomSetData(**data)
+        self._lock.acquire(blocking=True)  # might need to have a while loop here
+        self.socket.emit("room:set", data.to_dict())
 
     def __len__(self) -> int:
-        return int(self.socket.call("atoms:length", timeout=self.config.call_timeout))
+        return self.get_data(length=True).length
 
     def __setitem__(self, index, value):
         assert isinstance(index, int), "Index must be an integer"
         if isinstance(value, ase.Atoms):
             value = Frame.from_atoms(value)
-        self.socket.emit(
-            "atoms:upload",
-            [
-                dataclasses.asdict(
-                    FrameData(
-                        index=index,
-                        data=value.to_dict(built_in_types=False),
-                        update=True,
-                        update_database=True,
-                    )
-                )
-            ],
+        self.set_data(
+            frames={index: value.to_dict(built_in_types=False)},
+            step=index,
+            update_database=True,
         )
 
-    def __delitem__(self, index):
+    def __delitem__(self, index: int | slice | list[int]):
         if (
             isinstance(index, int)
             or isinstance(index, slice)
             or isinstance(index, list)
         ):
             length = len(self)
-            is_slice = isinstance(index, slice)
-            if is_slice:
-                index = range(*index.indices(length))
-
-            index = [index] if isinstance(index, int) else index
-            index = [i if i >= 0 else length + i for i in index]
-
-            data = {"index": index, "token": self.token}
-
-            self.socket.emit("atoms:delete", data)
-            if not is_slice and (index[0] >= length or index[-1] >= length):
-                raise IndexError("Index out of range")
+            index = wrap_and_check_index(index, length)
+            self.set_data(frames={i: None for i in index}, update_database=True)
         else:
             raise TypeError("Index must be an integer, slice or list[int]")
 
@@ -149,14 +170,14 @@ def insert(self, index, value: t.Union[ase.Atoms, Frame]):
         """Insert atoms before index"""
         if isinstance(value, ase.Atoms):
             value = Frame.from_atoms(value)
-
-        data = list(self)
-        data.insert(index, value)
-        for idx, val in enumerate(data):
-            self[idx] = val
-
-        # TODO: why is this not working at the moment?
-        # self.socket.emit("atoms:insert", {index: value.to_dict()})
+        log.warning(
+            "Currently `insert` is very taxing on the server, use with caution!"
+        )
+        index = wrap_and_check_index(index, len(self))[0]
+        data_after = self[index:]
+        self[index] = value
+        del self[index + 1 :]
+        self.extend(data_after)
 
     def append(self, value: t.Union[ase.Atoms, Frame]) -> None:
         """Append atoms to the end of the list"""
@@ -174,50 +195,30 @@ def extend(
         else:
             if isinstance(values[0], ase.Atoms):
                 values = [Frame.from_atoms(val) for val in values]
+            batch_size = estimate_max_batch_size_for_socket(values)
             indices = list(range(size, size + len(values)))
-            data = [
-                dataclasses.asdict(
-                    FrameData(
-                        index=i,
-                        data=val.to_dict(built_in_types=False),
-                        update=True,
-                        update_database=True,
-                    )
-                )
+            all_data = [
+                (i, val.to_dict(built_in_types=False))
                 for i, val in zip(indices, values)
             ]
-            batch_size = GlobalConfig.load().read_batch_size
-            for chunk in split_list_into_chunks(data, batch_size):
-                self.socket.emit(
-                    "atoms:upload",
-                    chunk,
-                )
+
+            for chunk in split_list_into_chunks(all_data, batch_size):
+                batch = {tup[0]: tup[1] for tup in chunk}
+                self.set_data(frames=batch, update_database=True)
 
     def __getitem__(self, index) -> t.Union[ase.Atoms, list[ase.Atoms]]:
         length = len(self)
-        is_scalar = isinstance(index, int)
-        is_sclice = isinstance(index, slice)
-        if is_sclice:
-            index = range(*index.indices(length))
-
-        index = [index] if isinstance(index, int) else index
-        index = [i if i >= 0 else length + i for i in index]
-
-        downloaded_data = self.socket.call(
-            "atoms:download", index, timeout=self.config.call_timeout
-        )
+        index = wrap_and_check_index(index, length)
+        data = self.get_data(frames=index).frames
 
         atoms_list = []
-
-        for val in downloaded_data.values():
+        for idx, val in zip(index, data):
             if val is None:
-                raise IndexError("Index out of range")
+                raise IndexError(f"Index {idx} out of range")
             atoms_list.append(Frame.from_dict(val).to_atoms())
 
-        data = atoms_list[0] if is_scalar else atoms_list
-        if data == [] and not is_sclice:
-            raise IndexError("Index out of range")
-        return data
+        return_data = atoms_list[0] if len(index) == 1 else atoms_list
+        return return_data
 
     def log(self, message: str) -> None:
         """Log a message to the console"""
@@ -233,11 +234,11 @@ def log(self, message: str) -> None:
     @property
     def atoms(self) -> ase.Atoms:
         """Return the atoms at the current step."""
-        return self[self.step]
+        return self[self.step]  # type: ignore
 
     @property
     def points(self) -> np.ndarray:
-        data = self.socket.call("points:get", timeout=self.config.call_timeout)
+        data = self.get_data(points=True).points
         return np.array(data)
 
     @points.setter
@@ -249,37 +250,29 @@ def points(self, value: t.Union[np.ndarray, list]) -> None:
                 assert len(value[0]) == 3
             except (TypeError, AssertionError):
                 raise ValueError("Points must be a list of 3D coordinates")
-        self.socket.emit("points:set", value)
+        self.set_data(points=value, update_database=True)
 
     @property
     def segments(self) -> np.ndarray:
-        data = self.socket.call("scene:segments", timeout=self.config.call_timeout)
-        return np.array(data)
+        return self.calculate_segments(self.points)
 
     @property
     def step(self) -> int:
-        step = int(
-            self.socket.call(
-                "scene:step",
-                timeout=self.config.call_timeout,
-            )
-        )
+        step = self.get_data(step=True).step
         return step
 
     @step.setter
     def step(self, index):
-        if index > len(self) - 1:
-            raise IndexError(f"Index {index} out of range for length {len(self)}")
-        data = {"index": index, "token": self.token}
-        self.socket.emit("scene:set", data)
+        index = wrap_and_check_index(index, len(self))[0]
+        self.set_data(step=index, update_database=True)
 
     @property
     def selection(self) -> list[int]:
-        return self.socket.call("selection:get", timeout=self.config.call_timeout)
+        return self.get_data(selection=True).selection
 
     @selection.setter
     def selection(self, value: list[int]):
-        self.socket.emit("selection:set", value)
+        self.set_data(selection=value, update_database=True)
 
     def play(self):
         self.socket.emit("scene:play")
@@ -298,21 +291,25 @@ def figure(self, fig: str):
 
     @property
     def bookmarks(self) -> dict:
-        return self.socket.call("bookmarks:get", timeout=self.config.call_timeout)
+        return self.get_data(bookmarks=True).bookmarks
 
     @bookmarks.setter
     def bookmarks(self, value: dict):
-        self.socket.emit("bookmarks:set", value)
+        self.set_data(bookmarks=value, update_database=True)
 
     def _pre_modifier_run(self, data) -> None:
         self.socket.emit("modifier:available", False)
-        vis = type(self)(self.url, data["token"])
         msg = CeleryTaskData(
-            target=f"webclients_{vis.token}", event="modifier:run:running", data=None
+            target=f"webclients_{data['token']}",
+            event="modifier:run:running",
+            data=None,
         )
 
-        vis.socket.emit("celery:task:emit", dataclasses.asdict(msg))
+        self.socket.emit("celery:task:emit", dataclasses.asdict(msg))
         try:
+            vis = FrozenZnDraw(
+                url=self.url, token=data["token"], cached_data=data["cache"]
+            )
             config = GlobalConfig.load()
             cls = get_modify_class(
                 config.get_modify_methods(
@@ -320,105 +317,35 @@ def _pre_modifier_run(self, data) -> None:
                 )
             )
             modifier = cls(**data["params"])
-            modifier.run(
-                vis,
-                **self._modifiers[modifier.method.__class__.__name__]["run_kwargs"],
+            try:
+                modifier.run(
+                    vis,
+                    **self._modifiers[modifier.method.__class__.__name__]["run_kwargs"],
+                )
+            except Exception as err:
+                vis.log(f"Modifier failed with error: {repr(err)}")
+
+            vis.socket.sleep(1)
+            vis.socket.disconnect()
+        except socketio_exceptions.ConnectionError as err:
+            msg = CeleryTaskData(
+                target=f"webclients_{data['token']}",
+                event="message:log",
+                data="Could not establish connection " + str(err),
+                disconnect=False,
             )
-        except Exception as err:
-            vis.log(f"Modifier failed with error: {repr(err)}")
+            self.socket.emit("celery:task:emit", dataclasses.asdict(msg))
+
         msg = CeleryTaskData(
-            target=f"{vis.token}",
+            target=f"{data['token']}",
             event="modifier:run:finished",
             data=None,
-            disconnect=True,
+            disconnect=False,
         )
-        vis.socket.emit("celery:task:emit", dataclasses.asdict(msg))
+        self.socket.emit("celery:task:emit", dataclasses.asdict(msg))
         self.socket.emit("modifier:available", True)
         print("Modifier finished!!!!!!!!")
 
-
-@dataclasses.dataclass
-class ZnDraw(ZnDrawBase):
-    """ZnDraw client."""
-
-    url: str = None
-
-    jupyter: bool = False
-
-    def __post_init__(self):
-        super().__post_init__()
-        if self.url is None:
-            from zndraw.utils import get_port
-            from zndraw.view import view
-
-            port = get_port()
-            self._view_thread = threading.Thread(
-                target=view,
-                kwargs={
-                    "filename": None,
-                    "port": port,
-                    "open_browser": False,
-                    "webview": False,
-                    "fullscreen": False,
-                    "start": 0,
-                    "stop": None,
-                    "step": 1,
-                    "compute_bonds": True,
-                },
-            )
-            self._view_thread.start()
-            self.url = f"http://127.0.0.1:{port}"
-
-        self._connect()
-
-    def close(self):
-        import time
-        import urllib.request
-
-        self.socket.disconnect()
-
-        time.sleep(1)
-
-        # open self.url/exit
-        try:
-            urllib.request.urlopen(f"{self.url}/exit")
-        except Exception:
-            pass
-        if hasattr(self, "_view_thread"):
-            log.debug("Waiting for ZnDraw client to close")
-            # self._view_thread.terminate()
-            self._view_thread.join()
-            # raise ValueError("ZnDraw client closed")
-
-    def _repr_html_(self):
-        from IPython.display import IFrame
-
-        return IFrame(src=self.url, width="100%", height="600px")._repr_html_()
-
-    def get_logging_handler(self) -> ZnDrawLoggingHandler:
-        return ZnDrawLoggingHandler(self)
-
-    def reconnect(self) -> None:
-        super().reconnect()
-
-        for k, v in self._modifiers.items():
-            log.critical(f"Re-registering modifier {k}")
-            msg = ModifierRegisterData(
-                schema=v["cls"].model_json_schema(),
-                name=k,
-                default=v["default"],
-                timeout=v["run_kwargs"]["timeout"],
-            )
-            self.socket.emit(
-                "modifier:register",
-                dataclasses.asdict(msg),
-            )
-
-            self.socket.emit(
-                "modifier:available",
-                True,
-            )
-
     def register_modifier(
         self,
         cls: UpdateScene,
diff --git a/zndraw/zndraw_frozen.py b/zndraw/zndraw_frozen.py
new file mode 100644
index 000000000..6c4eea360
--- /dev/null
+++ b/zndraw/zndraw_frozen.py
@@ -0,0 +1,165 @@
+import logging
+import typing as t
+
+import ase
+import numpy as np
+import socketio
+from znframe import Frame as ZnFrame
+
+from .base import ZnDrawBase
+from .data import RoomSetData
+from .utils import (
+    estimate_max_batch_size_for_socket,
+    split_list_into_chunks,
+    wrap_and_check_index,
+)
+
+log = logging.getLogger(__name__)
+
+
+class FrozenZnDraw(ZnDrawBase):
+    # TODO: take in _original instead and change the RoomSetData to accept token instead. Will remove a lot of the boilerplate for pushing data
+    def __init__(self, token, url, cached_data: dict):
+        self.socket = socketio.Client()
+        self.socket.connect(url, wait_timeout=5)
+        self.socket.emit("join", str(token))
+        self.url = url
+        self.token = token
+        self._cached_data = self.cache_from_dict(cached_data)
+
+    def __len__(self):
+        return self._cached_data["length"]
+
+    def __setitem__(self, index, value):
+        assert isinstance(index, int), "Index must be an integer"
+        if isinstance(value, ase.Atoms):
+            value = ZnFrame.from_atoms(value)
+        self.set_data(
+            frames={index: value.to_dict(built_in_types=False)},
+            step=index,
+            update_database=True,
+        )
+
+    def __getitem__(self, *args, **kwargs):
+        raise NotImplementedError(
+            "This method is not implemented on the frozen object. Please use the zndraw.ZnDraw object instead."
+        )
+
+    def insert(self, *args, **kwargs):
+        raise NotImplementedError("Don't use inserts. Use append or extend instead.")
+
+    def __delitem__(self, index: int | slice | list[int]):
+        if (
+            isinstance(index, int)
+            or isinstance(index, slice)
+            or isinstance(index, list)
+        ):
+            length = len(self)
+            index = wrap_and_check_index(index, length)
+            self.set_data(frames={i: None for i in index}, update_database=True)
+            self._cached_data["length"] -= len(index)
+        else:
+            raise TypeError("Index must be an integer, slice or list[int]")
+
+    def append(self, value: t.Union[ase.Atoms, ZnFrame]) -> None:
+        """Append atoms to the end of the list"""
+        if isinstance(value, ase.Atoms):
+            value = ZnFrame.from_atoms(value)
+        self[len(self)] = value
+        self._cached_data["length"] += 1
+
+    def extend(
+        self, values: t.Union[ase.Atoms, ZnFrame, list[ase.Atoms], list[ZnFrame]]
+    ) -> None:
+        """Extend the list by appending all the items in the given list"""
+        size = len(self)
+        if not isinstance(values, list):
+            self.append(values)
+        else:
+            if isinstance(values[0], ase.Atoms):
+                values = [ZnFrame.from_atoms(val) for val in values]
+            batch_size = estimate_max_batch_size_for_socket(values)
+            log.critical(f"Batch size: {batch_size}")
+            indices = list(range(size, size + len(values)))
+            all_data = [
+                (i, val.to_dict(built_in_types=False))
+                for i, val in zip(indices, values)
+            ]
+            chunks = list(split_list_into_chunks(all_data, batch_size))
+            for i, chunk in enumerate(chunks):
+                self._cached_data["length"] += len(chunk)
+                batch = {tup[0]: tup[1] for tup in chunk}
+                if i == len(chunks) - 1:
+                    # Only send the step if it's the last batch, otherwise it will be set to the last index of the batch
+                    self.set_data(
+                        frames=batch, update_database=True, step=len(self) - 1
+                    )
+                else:
+                    self.set_data(frames=batch, update_database=True)
+
+    def log(self, message: str) -> None:
+        """Log a message to the console"""
+        print(message)
+        self.socket.emit(
+            "message:log",
+            {
+                "message": message,
+                "token": self.token,
+            },
+        )
+
+    def set_data(self, **data: dict) -> None:
+        data = RoomSetData(**data)
+        log.critical("Trying to set data")
+        self.socket.emit("room:set", data.to_dict())
+
+    @property
+    def atoms(self):
+        return self._cached_data["atoms"]
+
+    @property
+    def points(self):
+        return self._cached_data["points"]
+
+    @points.setter
+    def points(self, value):
+        self.set_data(points=value, update_database=True)
+
+    @property
+    def segments(self):
+        return self.calculate_segments(self._cached_data["points"])
+
+    @property
+    def step(self):
+        return self._cached_data["step"]
+
+    @step.setter
+    def step(self, value):
+        self.set_data(step=value, update_database=True)
+
+    @property
+    def selection(self):
+        return self._cached_data["selection"]
+
+    @selection.setter
+    def selection(self, value):
+        self.set_data(selection=value, update_database=True)
+
+    @property
+    def bookmarks(self):
+        return self._cached_data["bookmarks"]
+
+    @bookmarks.setter
+    def bookmarks(self, value):
+        self.set_data(bookmarks=value, update_database=True)
+
+    @staticmethod
+    def cache_from_dict(data: dict):
+        return dict(
+            atoms=ZnFrame.from_dict(data["frames"][0]).to_atoms(),
+            points=np.array(data["points"]),
+            step=data["step"],
+            selection=data["selection"],
+            bookmarks=data["bookmarks"],
+            length=data["length"],
+        )
diff --git a/zndraw/zndraw_worker.py b/zndraw/zndraw_worker.py
new file mode 100644
index 000000000..4e69d05da
--- /dev/null
+++ b/zndraw/zndraw_worker.py
@@ -0,0 +1,327 @@
+import dataclasses
+import logging
+from typing import List, Union
+
+import ase
+import numpy as np
+from sqlalchemy import func as sql_func
+from znframe.frame import Frame as ZnFrame
+
+from zndraw.data import CeleryTaskData, RoomSetData
+from zndraw.settings import GlobalConfig
+
+log = logging.getLogger(__name__)
+
+from .base import ZnDrawBase
+from .db import Session
+from .db.schema import Bookmark, Frame, Room
+from .server.utils import get_room_by_token
+from .utils import wrap_and_check_index
+
+
+def _any_to_list(
+    value: ZnFrame | ase.Atoms | list[ase.Atoms] | list[ZnFrame],
+) -> list[ZnFrame]:
+    if isinstance(value, ase.Atoms):
+        return [ZnFrame.from_atoms(value)]
+    elif isinstance(value, ZnFrame):
+        return [value]
+    elif isinstance(value, list):
+        data = []
+        for v in value:
+            if isinstance(v, ase.Atoms):
+                data.append(ZnFrame.from_atoms(v))
+            elif isinstance(v, ZnFrame):
+                data.append(v)
+            else:
+                raise ValueError(f"Invalid type for value {v}")
+        return data
+    raise ValueError("Invalid type for value")
+
+
+@dataclasses.dataclass
+class ZnDrawWorker(ZnDrawBase):
+    emit: bool = True
+
+    def __len__(self) -> int:
+        with Session() as session:
+            return self._get_len(session, self.token)
+
+    @staticmethod
+    def _get_len(session, token) -> int:
+        max_idx = (
+            session.query(sql_func.max(Frame.index))
+            .filter(Frame.room_token == token)
+            .scalar()
+        )
+        return max_idx + 1 if max_idx is not None else 0
+
+    def __setitem__(
+        self,
+        index: int | list[int] | slice,
+        value: ZnFrame | ase.Atoms | list[ase.Atoms] | list[ZnFrame],
+    ):
+        value = _any_to_list(value)
+        if isinstance(index, str):
+            index = int(index)
+        if isinstance(index, int):
+            index = [index]
+        if isinstance(index, slice):
+            index = list(range(len(self)))[index]
+        if len(index) != len(value):
+            # TODO: support all the ways python lists can be slice set
+            raise ValueError(
+                f"Length of index ({len(index)}) and value ({len(value)}) must match"
+            )
+        log.critical(82 * "-")
+        log.critical(f"Index: {index}")
+        # we emit first, because sending the data takes longer, but emit is faster
+        if self.emit:
+            self.socket.emit(
+                "room:set",
+                RoomSetData(
+                    frames={
+                        idx: frame.to_dict(built_in_types=False)
+                        for idx, frame in zip(index, value)
+                    },
+                    step=index[-1],
+                ).to_dict(),
+            )
+        with Session() as session:
+            room = session.query(Room).get(self.token)
+            room.currentStep = index[-1]
+            for _index, _value in zip(index, value):
+                frame = session.query(Frame).filter_by(index=_index, room=room).first()
+                if frame is None:
+                    # add new frame
+                    frame = Frame(index=_index, room=room)
+                    session.add(frame)
+                frame.data = _value.to_dict(built_in_types=False)
+            session.commit()
+
+    def __getitem__(self, index: int | list[int] | slice) -> ase.Atoms:
+        single_index = False
+        if isinstance(index, int):
+            index = [index]
+            single_index = True
+        if isinstance(index, slice):
+            index = list(range(len(self)))[index]
+
+        with Session() as session:
+            room = session.query(Room).get(self.token)
+            frames = (
+                session.query(Frame)
+                .filter_by(room=room)
+                .filter(Frame.index.in_(index))
+                .all()
+            )
+            if frames is None:
+                raise IndexError(f"Index {index} not found")
+            frames = sorted(frames, key=lambda f: f.index)
+            data = [ZnFrame.from_dict(frame.data).to_atoms() for frame in frames]
+            if single_index:
+                return data[0]
+            return data
+
+    def __delitem__(self, index: int | list[int] | slice):
+        if isinstance(index, int):
+            index = [index]
+        if isinstance(index, slice):
+            index = list(range(len(self)))[index]
+
+        with Session() as session:
+            room = session.query(Room).get(self.token)
+            for _index in index:
+                session.query(Frame).filter_by(index=_index, room=room).delete()
+            # ensure indices are contiguous
+            frames = session.query(Frame).filter_by(room=room).all()
+            for idx, frame in enumerate(frames):
+                frame.index = idx
+            session.commit()
+        if self.emit:
+            self.socket.emit(
+                "room:set", RoomSetData(frames={idx: None for idx in index}).to_dict()
+            )
+
+    def append(self, data: ase.Atoms | ZnFrame):
+        if isinstance(data, ase.Atoms):
+            data = ZnFrame.from_atoms(data)
+        self[len(self)] = data
+
+    def extend(self, atoms_list: list[ase.Atoms] | list[ZnFrame]):
+        indices = list(range(len(self), len(self) + len(atoms_list)))
+        frames = _any_to_list(atoms_list)
+        self[indices] = frames
+
+    @property
+    def points(self) -> np.ndarray:
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+            return np.array(room.points)
+
+    @points.setter
+    def points(self, value: Union[np.ndarray, list]):
+        if isinstance(value, np.ndarray):
+            value = value.tolist()
+        if len(value) > 0:
+            try:
+                assert len(value[0]) == 3
+            except (TypeError, AssertionError):
+                raise ValueError("Points must be a list of 3D coordinates")
+
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+            room.points = value  # type: ignore
+            session.commit()
+        if self.emit:
+            self.socket.emit("room:set", RoomSetData(points=value).to_dict())
+
+    @property
+    def segments(self) -> np.ndarray:
+        return self.calculate_segments(self.points)
+
+    @property
+    def step(self) -> int:
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+            return room.currentStep
+
+    @step.setter
+    def step(self, idx: int):
+        length = len(self)
+        if idx < 0 or idx >= length:
+            raise IndexError(f"Index {idx} out of range for {length} frames")
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+            room.currentStep = idx
+            session.commit()
+        if self.emit:
+            self.socket.emit("room:set", RoomSetData(step=idx).to_dict())
+
+    @property
+    def selection(self) -> Union[List[int], List[None]]:
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+            return room.selection
+
+    @selection.setter
+    def selection(self, value: Union[List[int], List[None]]):
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+            room.selection = value
+            session.commit()
+        if self.emit:
+            self.socket.emit("room:set", RoomSetData(selection=value).to_dict())
+
+    @property
+    def bookmarks(self) -> dict:
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+            return {bm.step: bm.text for bm in room.bookmarks}
+
+    @bookmarks.setter
+    def bookmarks(self, value: dict) -> None:
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+            # delete all bookmarks
+            session.query(Bookmark).filter_by(room=room).delete()
+            for step, text in value.items():
+                bookmark = Bookmark(step=step, text=text, room=room)
+                session.add(bookmark)
+            session.commit()
+        if self.emit:
+            self.socket.emit("room:set", RoomSetData(bookmarks=value).to_dict())
+
+    def insert(self, index: int, atoms: ase.Atoms | ZnFrame):
+        if index < 0 or index > len(self):
+            raise IndexError(f"Index {index} out of range")
+        data_after = self[index:]
+        self[index] = atoms
+        del self[index + 1 :]
+        self.extend(data_after)
+
+    def get_properties(self, **kwargs):
+        with Session() as session:
+            room = get_room_by_token(session, self.token)
+
+            answer = {}
+            for key, collect in kwargs.items():
+                if collect:
+                    if key == "frames":
+                        indices = [
+                            x if not x == "current" else room.currentStep
+                            for x in kwargs["frames"]
+                        ]
+                        indices = wrap_and_check_index(
+                            indices, self._get_len(session, self.token)
+                        )
+                        log.critical(f"Indices: {indices}")
+                        collected_frames = (
+                            session.query(Frame)
+                            .filter(Frame.index.in_(indices), Frame.room == room)
+                            .all()
+                        )
+                        answer["frames"] = [
+                            ZnFrame.from_dict(frame.data).to_dict(built_in_types=False)
+                            for frame in collected_frames
+                        ]
+                    if key == "length":
+                        answer["length"] = len(room.frames)
+                    elif key == "points":
+                        answer["points"] = room.points
+                    elif key == "step":
+                        answer["step"] = room.currentStep
+                    elif key == "selection":
+                        answer["selection"] = room.selection
+                    elif key == "bookmarks":
+                        answer["bookmarks"] = {
+                            bm.step: bm.text for bm in room.bookmarks
+                        }
+        return answer
+
+    def log(self, message: str):
+        self.socket.emit("message:log", {"message": message, "token": self.token})
+
+    def upload(self, target: str):
+        """Emit all frames to the target (webclient)."""
+        if not self.emit:
+            raise ValueError("Emit is disabled")
+        config = GlobalConfig.load()
+        frame_list = []
+        for idx in range(len(self)):
+            frame_list.append(self[idx])
+            if len(frame_list) == config.read_batch_size:
+                msg = CeleryTaskData(
+                    target=target,
+                    event="room:set",
+                    data=RoomSetData(
+                        frames={
+                            idx
+                            - jdx: ZnFrame.from_atoms(atoms).to_dict(
+                                built_in_types=False
+                            )
+                            for jdx, atoms in enumerate(reversed(frame_list))
+                        },
+                        step=idx if idx < self.step else self.step,
+                    ).to_dict(),
+                )
+
+                self.socket.emit("celery:task:emit", msg.to_dict())
+                frame_list = []
+
+        msg = CeleryTaskData(
+            target=target,
+            event="room:set",
+            data=RoomSetData(
+                frames={
+                    idx - jdx: ZnFrame.from_atoms(atoms).to_dict(built_in_types=False)
+                    for jdx, atoms in enumerate(reversed(frame_list))
+                },
+                selection=self.selection,
+                points=self.points.tolist(),
+                bookmarks=self.bookmarks,
+                step=self.step,
+            ).to_dict(),
+        )
+
+        self.socket.emit("celery:task:emit", msg.to_dict())


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 363b51338..dbb1d2a83 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,12 @@
 import multiprocessing as mp
+import threading
 import time
 
 import ase.build
 import ase.collections
+import eventlet
 import pytest
+import socketio
 from selenium import webdriver
 from selenium.webdriver.chrome.service import Service as ChromeService
 from webdriver_manager.chrome import ChromeDriverManager
@@ -53,3 +56,28 @@ def server():
     finally:
         server_proc.terminate()
         server_proc.join()
+
+
+@pytest.fixture(scope="session")
+def sio_server():
+    port = get_port()
+
+    def run_server(port):
+        sio = socketio.Server(cors_allowed_origins="*")
+        app = socketio.WSGIApp(sio)
+
+        # react on every event
+        @sio.on("*")
+        def push_back(event, sid, data):
+            sio.emit(event, data, to=sid)
+
+        @sio.on("ping")
+        def ping(sid):
+            return "pong"
+
+        eventlet.wsgi.server(eventlet.listen(("", port)), app)
+
+    t = threading.Thread(target=run_server, args=(port,), daemon=True)
+    t.start()
+    time.sleep(1)
+    yield f"http://localhost:{port}"
diff --git a/tests/test_db.py b/tests/test_db.py
new file mode 100644
index 000000000..78cd77f2c
--- /dev/null
+++ b/tests/test_db.py
@@ -0,0 +1,302 @@
+import unittest.mock as mock
+
+import numpy.testing as npt
+import pytest
+import znframe
+from ase.collections import s22
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+
+from zndraw.data import RoomGetData, RoomSetData
+from zndraw.db import schema
+from zndraw.db.schema import Base
+from zndraw.utils import typecast
+from zndraw.zndraw_worker import ZnDrawWorker
+
+s22 = list(s22)
+
+
+@pytest.fixture
+def session() -> sessionmaker:
+    """pytest fixture to setup the database"""
+    engine = create_engine("sqlite:///:memory:")
+    Base.metadata.create_all(engine)
+    return sessionmaker(bind=engine)
+
+
+@pytest.fixture
+def room_session(session) -> sessionmaker:
+    with session() as s:
+        room = schema.Room(
+            token="test_token",
+            currentStep=5,
+            selection=[1, 2],
+            points=[[0, 0, 0], [1, 1, 1]],
+        )
+        s.add(room)
+        for idx, atoms in enumerate(s22):
+            frame = schema.Frame(
+                data=znframe.Frame.from_atoms(atoms).to_dict(built_in_types=False),
+                index=idx,
+                room=room,
+            )
+            s.add(frame)
+
+        # add two bookmarks
+        bookmark1 = schema.Bookmark(step=1, text="bm-1", room=room)
+        s.add(bookmark1)
+        bookmark2 = schema.Bookmark(step=2, text="bm-2", room=room)
+        s.add(bookmark2)
+        s.commit()
+
+    return session
+
+
+def test_room(room_session):
+    with room_session() as s:
+        room = s.query(schema.Room).filter_by(token="test_token").first()
+        assert room.token == "test_token"
+        assert room.currentStep == 5
+        assert room.selection == [1, 2]
+
+
+def test_frame(room_session):
+    with room_session() as s:
+        room = s.query(schema.Room).filter_by(token="test_token").first()
+        assert len(room.frames) == 22
+        for idx, (frame, atoms) in enumerate(zip(room.frames, s22)):
+            assert frame.room == room
+            assert frame.to_frame() == znframe.Frame.from_atoms(atoms)
+            assert frame.index == idx
+
+
+def test_zndraw_worker_get(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        assert len(worker) == 22
+        for idx, atoms in enumerate(s22):
+            assert worker[idx] == atoms
+        for idx, atoms in enumerate(worker):
+            assert atoms == s22[idx]
+        assert worker.step == 5
+        assert worker.selection == [1, 2]
+        npt.assert_array_equal(worker.points, [[0, 0, 0], [1, 1, 1]])
+        assert worker.bookmarks == {1: "bm-1", 2: "bm-2"}
+
+        with pytest.raises(IndexError):
+            worker[22]
+
+        # TODO: test slicing
+
+
+def test_zndraw_worker_get_multiple(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        request = RoomGetData(points=True, bookmarks=True, selection=True, step=True)
+        answer = worker.get_properties(**request.to_dict())
+        npt.assert_array_equal(answer["points"], [[0, 0, 0], [1, 1, 1]])
+        assert answer["bookmarks"] == {1: "bm-1", 2: "bm-2"}
+        assert answer["selection"] == [1, 2]
+        assert answer["step"] == 5
+
+        request_idx = [0, 1, 5]
+        request = RoomGetData(
+            points=True, bookmarks=True, selection=True, step=True, frames=request_idx
+        )
+        answer = worker.get_properties(**request.to_dict())
+        assert len(answer["frames"]) == len(request_idx)
+        for i, frame in enumerate(answer["frames"]):
+            atoms = znframe.Frame.from_dict(frame).to_atoms()
+            assert atoms == s22[request_idx[i]]
+
+
+def test_zndraw_worker_set_atoms(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        del worker[:]
+        assert len(worker) == 0
+        worker[0] = s22[0]
+        assert len(worker) == 1
+        assert worker[0] == s22[0]
+        worker.extend(s22[1:3])
+        assert len(worker) == 3
+        assert worker[1] == s22[1]
+        assert worker[2] == s22[2]
+        assert worker[:] == s22[0:3]
+        worker.append(s22[3])
+        assert len(worker) == 4
+        assert worker[3] == s22[3]
+        del worker[1]
+        assert len(worker) == 3
+        assert worker[:] == [s22[0], s22[2], s22[3]]
+        worker[0] = s22[1]
+        assert len(worker) == 3
+        assert worker[:] == [s22[1], s22[2], s22[3]]
+
+        worker.insert(0, s22[0])
+        assert len(worker) == 4
+        assert worker[:] == s22[0:4]
+
+        with pytest.raises(IndexError):
+            worker.insert(5, s22[0])
+
+
+def test_zndraw_worker_set(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        assert worker.step == 5
+        worker.step = 6
+        assert worker.step == 6
+        assert worker.selection == [1, 2]
+        worker.selection = [2, 3]
+        assert worker.selection == [2, 3]
+        npt.assert_array_equal(worker.points, [[0, 0, 0], [1, 1, 1]])
+        worker.points = [[1, 1, 1], [2, 2, 2]]
+        npt.assert_array_equal(worker.points, [[1, 1, 1], [2, 2, 2]])
+        assert worker.bookmarks == {1: "bm-1", 2: "bm-2"}
+        worker.bookmarks = {2: "bm-3", 3: "bm-4"}
+        assert worker.bookmarks == {2: "bm-3", 3: "bm-4"}
+
+
+def test_set_bookmarks(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        global answer
+        answer = None
+
+        @typecast
+        def on_answer(data: RoomSetData):
+            global answer
+            answer = data
+
+        worker.socket.on("room:set", on_answer)
+
+        worker.bookmarks = {2: "bm-3", 3: "bm-4"}
+        worker.socket.sleep(0.1)
+        # TODO: keys being converted to strings somewhere
+        assert answer.bookmarks == {"2": "bm-3", "3": "bm-4"}
+        assert worker.bookmarks == {2: "bm-3", 3: "bm-4"}
+
+
+def test_set_step(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        global answer
+        answer = None
+
+        @typecast
+        def on_answer(data: RoomSetData):
+            global answer
+            answer = data
+
+        worker.socket.on("room:set", on_answer)
+
+        worker.step = 6
+        worker.socket.sleep(0.1)
+        assert answer.step == 6
+        assert worker.step == 6
+
+
+def test_set_selection(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        global answer
+        answer = None
+
+        @typecast
+        def on_answer(data: RoomSetData):
+            global answer
+            answer = data
+
+        worker.socket.on("room:set", on_answer)
+
+        worker.selection = [2, 3]
+        worker.socket.sleep(0.1)
+        assert answer.selection == [2, 3]
+        assert worker.selection == [2, 3]
+
+
+def test_set_points(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        global answer
+        answer = None
+
+        @typecast
+        def on_answer(data: RoomSetData):
+            global answer
+            answer = data
+
+        worker.socket.on("room:set", on_answer)
+
+        worker.points = [[1, 1, 1], [2, 2, 2]]
+        worker.socket.sleep(0.1)
+        npt.assert_array_equal(answer.points, [[1, 1, 1], [2, 2, 2]])
+        npt.assert_array_equal(worker.points, [[1, 1, 1], [2, 2, 2]])
+
+
+def test_set_atoms(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        global answer
+        answer = None
+
+        @typecast
+        def on_answer(data: RoomSetData):
+            global answer
+            answer = data
+
+        worker.socket.on("room:set", on_answer)
+
+        worker[0] = s22[10]
+        worker.socket.sleep(0.1)
+        # TODO: keys being converted to strings somewhere
+        assert answer.frames["0"] == znframe.Frame.from_atoms(s22[10]).to_dict(
+            built_in_types=False
+        )
+        assert len(answer.frames) == 1
+        assert worker[0] == s22[10]
+
+        worker.extend(s22[11:13])
+        worker.socket.sleep(0.1)
+        assert len(answer.frames) == 2
+        assert answer.frames.keys() == {str(len(worker) - 1), str(len(worker) - 2)}
+        assert len(worker) == 24
+        assert answer.frames["22"] == znframe.Frame.from_atoms(s22[11]).to_dict(
+            built_in_types=False
+        )
+        assert answer.frames["23"] == znframe.Frame.from_atoms(s22[12]).to_dict(
+            built_in_types=False
+        )
+        assert worker[22] == s22[11]
+        assert worker[23] == s22[12]
+
+
+def test_del_atoms(room_session, sio_server):
+    with mock.patch("zndraw.zndraw_worker.Session", room_session):
+        worker = ZnDrawWorker(token="test_token", url=sio_server)
+        global answer
+        answer = None
+
+        @typecast
+        def on_answer(data: RoomSetData):
+            global answer
+            answer = data
+
+        worker.socket.on("room:set", on_answer)
+
+        del worker[0]
+        worker.socket.sleep(0.1)
+        assert len(answer.frames) == 1
+        assert answer.frames.keys() == {"0"}
+        assert answer.frames["0"] == None
+        assert len(worker) == 21
+
+        del worker[4:7]
+        worker.socket.sleep(0.1)
+        assert len(answer.frames) == 3
+        assert answer.frames.keys() == {"4", "5", "6"}
+        assert answer.frames["4"] == None
+        assert answer.frames["5"] == None
+        assert answer.frames["6"] == None
+        assert len(worker) == 18
diff --git a/tests/test_socket.py b/tests/test_socket.py
new file mode 100644
index 000000000..f827b21ca
--- /dev/null
+++ b/tests/test_socket.py
@@ -0,0 +1,67 @@
+import pytest
+from socketio import Client
+
+from zndraw.app import create_app, socketio
+from zndraw.zndraw_worker import ZnDrawWorker
+
+
+@pytest.fixture()
+def app():
+    app = create_app()
+    app.config.update(
+        {
+            "TESTING": True,
+            "USE_TOKEN": False,
+            "SECRET_KEY": "test",
+            "upgrade_insecure_requests": False,
+            "TUTORIAL": None,
+        }
+    )
+
+    # other setup can go here
+
+    yield app
+
+    # clean up / reset resources here
+
+
+@pytest.fixture()
+def client(app):
+    return app.test_client()
+
+
+@pytest.fixture()
+def sio_client(app):
+    return socketio.test_client(app)
+
+
+@pytest.fixture()
+def runner(app):
+    return app.test_cli_runner()
+
+
+def test_socket_connection_test_client(sio_client):
+    assert sio_client.emit("ping", callback=True) == "pong"
+
+
+def test_socket_connection(sio_server):
+    client = Client()
+    client.connect(sio_server)
+    assert client.call("ping") == "pong"
+
+
+def test_zndraw_worker_log(sio_server):
+    worker = ZnDrawWorker(token="test_token", url=sio_server)
+    assert worker.socket.call("ping") == "pong"
+    global answer
+    answer = None
+
+    def on_answer(data):
+        global answer
+        answer = data
+
+    worker.socket.on("message:log", on_answer)
+    worker.log("test")
+    while answer is None:
+        worker.socket.sleep(0.1)
+    assert answer == {"message": "test", "token": "test_token"}
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 29a085f20..f83b13909 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,7 @@
 import pytest
 
 from zndraw.data import RoomGetData
-from zndraw.utils import typecast_kwargs
+from zndraw.utils import typecast_kwargs, wrap_and_check_index
 
 
 def test_typecast_kwargs():
@@ -23,3 +23,30 @@ def func(text: str, **data: RoomGetData):
 
     with pytest.raises(TypeError):
         data = func(text="Hello World", points=True, bookmarks=True)
+
+
+@pytest.mark.parametrize(
+    "inp, expected",
+    [
+        ((0, 10), [0]),
+        ((-1, 10), [9]),
+        (([0, 1, 2], 10), [0, 1, 2]),
+        ((slice(0, 10), 10), list(range(0, 10))),
+        ((slice(0, 10, 2), 10), list(range(0, 10, 2))),
+    ],
+)
+def test_wrap_and_check_index(inp, expected):
+    assert wrap_and_check_index(*inp) == expected
+
+
+@pytest.mark.parametrize(
+    "inp",
+    [
+        ((10, 10)),
+        ((-11, 10)),
+        (([0, 1, 2], 2)),
+    ],
+)
+def test_wrap_and_check_index_raises_exceptions(inp):
+    with pytest.raises(IndexError):
+        wrap_and_check_index(*inp)