diff --git a/backend/src/baserow/contrib/builder/api/domains/public_views.py b/backend/src/baserow/contrib/builder/api/domains/public_views.py
index e8570fb59..331eeb87e 100644
--- a/backend/src/baserow/contrib/builder/api/domains/public_views.py
+++ b/backend/src/baserow/contrib/builder/api/domains/public_views.py
@@ -239,7 +239,7 @@ class PublicDataSourcesView(APIView):
 
         handler = BuilderHandler()
         public_properties = handler.get_builder_public_properties(
-            request.user, page.builder
+            request.user_source_user, page.builder
         )
 
         allowed_fields = []
diff --git a/backend/src/baserow/contrib/builder/apps.py b/backend/src/baserow/contrib/builder/apps.py
index 0d2c695b2..f5376b6bc 100644
--- a/backend/src/baserow/contrib/builder/apps.py
+++ b/backend/src/baserow/contrib/builder/apps.py
@@ -315,4 +315,5 @@ class BuilderConfig(AppConfig):
 
         # The signals must always be imported last because they use the registries
         # which need to be filled first.
+        import baserow.contrib.builder.signals  # noqa: F403, F401
         import baserow.contrib.builder.ws.signals  # noqa: F403, F401
diff --git a/backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py b/backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py
index 1e6c56a41..28402be09 100644
--- a/backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py
+++ b/backend/src/baserow/contrib/builder/data_sources/builder_dispatch_context.py
@@ -273,6 +273,6 @@ class BuilderDispatchContext(DispatchContext):
             return None
 
         return BuilderHandler().get_builder_public_properties(
-            self.request.user,
+            self.request.user_source_user,
             self.page.builder,
         )
diff --git a/backend/src/baserow/contrib/builder/handler.py b/backend/src/baserow/contrib/builder/handler.py
index 7ecee5203..bac5e96c7 100644
--- a/backend/src/baserow/contrib/builder/handler.py
+++ b/backend/src/baserow/contrib/builder/handler.py
@@ -3,7 +3,6 @@ from typing import Dict, List, Optional
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import AbstractUser
-from django.core.cache import cache
 
 from baserow.contrib.builder.formula_property_extractor import (
     get_builder_used_property_names,
@@ -11,9 +10,11 @@ from baserow.contrib.builder.formula_property_extractor import (
 from baserow.contrib.builder.models import Builder
 from baserow.contrib.builder.theme.registries import theme_config_block_registry
 from baserow.core.handler import CoreHandler
+from baserow.core.utils import invalidate_versioned_cache, safe_get_or_set_cache
 
 User = get_user_model()
 CACHE_KEY_PREFIX = "used_properties_for_page"
+BUILDER_PREVIEW_USED_PROPERTIES_CACHE_TTL_SECONDS = 60
 
 
 class BuilderHandler:
@@ -41,6 +42,10 @@ class BuilderHandler:
             .specific
         )
 
+    @classmethod
+    def _get_builder_version_cache(cls, builder: Builder):
+        return f"{CACHE_KEY_PREFIX}_version_{builder.id}"
+
     def get_builder_used_properties_cache_key(
         self, user: AbstractUser, builder: Builder
     ) -> Optional[str]:
@@ -54,9 +59,7 @@ class BuilderHandler:
         attribute, unlike the User Source User.
         """
 
-        if isinstance(user, User):
-            return None
-        elif user.is_anonymous:
+        if user.is_anonymous or not user.role:
             # When the user is anonymous, only use the prefix + page ID.
             role = ""
         else:
@@ -64,6 +67,10 @@ class BuilderHandler:
 
         return f"{CACHE_KEY_PREFIX}_{builder.id}{role}"
 
+    @classmethod
+    def invalidate_builder_public_properties_cache(cls, builder):
+        invalidate_versioned_cache(cls._get_builder_version_cache(builder))
+
     def get_builder_public_properties(
         self, user: AbstractUser, builder: Builder
     ) -> Dict[str, Dict[int, List[str]]]:
@@ -80,15 +87,11 @@ class BuilderHandler:
         (required only by the backend).
         """
 
-        cache_key = self.get_builder_used_properties_cache_key(user, builder)
-        properties = cache.get(cache_key) if cache_key else None
-        if properties is None:
-            properties = get_builder_used_property_names(user, builder)
-            if cache_key:
-                cache.set(
-                    cache_key,
-                    properties,
-                    timeout=settings.BUILDER_PUBLICLY_USED_PROPERTIES_CACHE_TTL_SECONDS,
-                )
-
-        return properties
+        return safe_get_or_set_cache(
+            self.get_builder_used_properties_cache_key(user, builder),
+            self._get_builder_version_cache(builder),
+            default=lambda: get_builder_used_property_names(user, builder),
+            timeout=settings.BUILDER_PUBLICLY_USED_PROPERTIES_CACHE_TTL_SECONDS
+            if builder.workspace_id
+            else BUILDER_PREVIEW_USED_PROPERTIES_CACHE_TTL_SECONDS,
+        )
diff --git a/backend/src/baserow/contrib/builder/signals.py b/backend/src/baserow/contrib/builder/signals.py
new file mode 100644
index 000000000..81b6625ea
--- /dev/null
+++ b/backend/src/baserow/contrib/builder/signals.py
@@ -0,0 +1,124 @@
+from django.dispatch import receiver
+
+from baserow.contrib.builder.data_sources import signals as ds_signals
+from baserow.contrib.builder.elements import signals as element_signals
+from baserow.contrib.builder.handler import BuilderHandler
+from baserow.contrib.builder.models import Builder
+from baserow.contrib.builder.pages import signals as page_signals
+from baserow.contrib.builder.workflow_actions import signals as wa_signals
+from baserow.core.user_sources import signals as us_signals
+
+__all__ = [
+    "element_created",
+    "elements_created",
+    "element_deleted",
+    "element_updated",
+    "wa_created",
+    "wa_updated",
+    "wa_deleted",
+    "ds_created",
+    "ds_updated",
+    "ds_deleted",
+    "page_deleted",
+]
+
+# Elements
+
+
+@receiver(element_signals.element_created)
+def element_created(sender, element, user, before_id=None, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(element.page.builder)
+
+
+@receiver(element_signals.elements_created)
+def elements_created(sender, elements, page, user, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(page.builder)
+
+
+@receiver(element_signals.element_updated)
+def element_updated(sender, element, user, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(element.page.builder)
+
+
+@receiver(element_signals.element_deleted)
+def element_deleted(sender, page, element_id, user, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(page.builder)
+
+
+# Workflow actions
+
+
+@receiver(wa_signals.workflow_action_created)
+def wa_created(sender, workflow_action, user, before_id=None, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(
+        workflow_action.page.builder
+    )
+
+
+@receiver(wa_signals.workflow_action_updated)
+def wa_updated(sender, workflow_action, user, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(
+        workflow_action.page.builder
+    )
+
+
+@receiver(wa_signals.workflow_action_deleted)
+def wa_deleted(sender, workflow_action_id, page, user, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(page.builder)
+
+
+# Data sources
+
+
+@receiver(ds_signals.data_source_created)
+def ds_created(sender, data_source, user, before_id=None, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(
+        data_source.page.builder
+    )
+
+
+@receiver(ds_signals.data_source_updated)
+def ds_updated(sender, data_source, user, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(
+        data_source.page.builder
+    )
+
+
+@receiver(ds_signals.data_source_deleted)
+def ds_deleted(sender, data_source_id, page, user, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(page.builder)
+
+
+# Page
+
+
+@receiver(page_signals.page_deleted)
+def page_deleted(sender, builder, page_id, user, **kwargs):
+    BuilderHandler().invalidate_builder_public_properties_cache(builder)
+
+
+# User sources
+
+
+@receiver(us_signals.user_source_created)
+def us_created(sender, user_source, user, before_id=None, **kwargs):
+    if isinstance(user_source.application.specific, Builder):
+        BuilderHandler().invalidate_builder_public_properties_cache(
+            user_source.application.specific
+        )
+
+
+@receiver(us_signals.user_source_updated)
+def us_updated(sender, user_source, user, **kwargs):
+    if isinstance(user_source.application.specific, Builder):
+        BuilderHandler().invalidate_builder_public_properties_cache(
+            user_source.application.specific
+        )
+
+
+@receiver(us_signals.user_source_deleted)
+def us_deleted(sender, user_source_id, application, user, **kwargs):
+    if isinstance(application.specific, Builder):
+        BuilderHandler().invalidate_builder_public_properties_cache(
+            application.specific
+        )
diff --git a/backend/src/baserow/core/registry.py b/backend/src/baserow/core/registry.py
index 3baf6c42a..c9c2153ab 100644
--- a/backend/src/baserow/core/registry.py
+++ b/backend/src/baserow/core/registry.py
@@ -798,7 +798,7 @@ class ModelRegistryMixin(Generic[DjangoModel, InstanceSubClass]):
         if isinstance(model_instance, type):
             clazz = model_instance
         else:
-            clazz = type(model_instance)
+            clazz = model_instance.__class__
 
         return self.get_for_class(clazz)
 
diff --git a/backend/src/baserow/core/utils.py b/backend/src/baserow/core/utils.py
index 6d0c700ae..35838b453 100644
--- a/backend/src/baserow/core/utils.py
+++ b/backend/src/baserow/core/utils.py
@@ -14,14 +14,27 @@ from decimal import Decimal
 from fractions import Fraction
 from itertools import chain, islice
 from numbers import Number
-from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, Union
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Set,
+    Tuple,
+    Type,
+    Union,
+)
 
 from django.conf import settings
+from django.core.cache import cache
 from django.db import transaction
 from django.db.models import ForeignKey, ManyToManyField, Model
 from django.db.models.fields import NOT_PROVIDED
 from django.db.transaction import get_connection
 
+from redis.exceptions import LockNotOwnedError
 from requests.utils import guess_json_utf
 
 from baserow.contrib.database.db.schema import optional_atomic
@@ -1193,3 +1206,81 @@ def are_hostnames_same(hostname1: str, hostname2: str) -> bool:
     ips1 = get_all_ips(hostname1)
     ips2 = get_all_ips(hostname2)
     return not ips1.isdisjoint(ips2)
+
+
+def safe_get_or_set_cache(
+    cache_key: str,
+    version_cache_key: str = None,
+    default: Any | Callable = None,
+    timeout: int = 60,
+) -> Any:
+    """
+    Retrieves a value from the cache if it exists; otherwise, sets it using the
+    provided default value. If a version cache key is provided, the function uses
+    a versioned key to manage cache invalidation.
+
+    This function also uses a lock (if available on the cache backend) to ensure
+    multi call safety when setting a new value.
+
+    :param cache_key: The base key to look up in the cache.
+    :param version_cache_key: An optional key used to version the cache. If
+                              provided,.
+    :param default: The default value to store in the cache if the key is absent.
+                    Can be either a literal value or a callable. If it's a callable,
+                    the function is called to retrieve the default value.
+    :param timeout: The cache timeout in seconds for newly set values. Defaults to 60.
+    :return: The cached value if it exists; otherwise, the newly set value.
+    """
+
+    cached = cache.get(cache_key)
+
+    cache_key_to_use = cache_key
+    if version_cache_key is not None:
+        version = cache.get(version_cache_key, 0)
+        cache_key_to_use = f"{cache_key}__version_{version}"
+
+    if cached is None:
+        use_lock = hasattr(cache, "lock")
+        if use_lock:
+            cache_lock = cache.lock(f"{cache_key_to_use}__lock", timeout=10)
+            cache_lock.acquire()
+        try:
+            cached = cache.get(cache_key_to_use)
+            # We check again to make sure it hasn't been populated in the meantime
+            # while acquiring the lock
+            if cached is None:
+                if callable(default):
+                    cached = default()
+                else:
+                    cached = default
+
+                cache.set(
+                    cache_key_to_use,
+                    cached,
+                    timeout=timeout,
+                )
+        finally:
+            if use_lock:
+                try:
+                    cache_lock.release()
+                except LockNotOwnedError:
+                    # If the lock release fails, it might be because of the timeout
+                    # and it's been stolen so we don't really care
+                    pass
+
+    return cached
+
+
+def invalidate_versioned_cache(version_cache_key: str):
+    """
+    Invalidates (or increments) the version associated with a versioned cache,
+    forcing future reads on this versioned key to miss the cache.
+
+    :param version_cache_key: The key whose version is to be incremented in the cache.
+    """
+
+    try:
+        cache.incr(version_cache_key, 1)
+    except ValueError:
+        # No cache key, we create one
+        cache.set(version_cache_key, 1)
diff --git a/backend/tests/baserow/contrib/builder/data_sources/test_data_source_handler.py b/backend/tests/baserow/contrib/builder/data_sources/test_data_source_handler.py
index 6a83d28f2..c8dc452ba 100644
--- a/backend/tests/baserow/contrib/builder/data_sources/test_data_source_handler.py
+++ b/backend/tests/baserow/contrib/builder/data_sources/test_data_source_handler.py
@@ -574,7 +574,7 @@ def test_dispatch_data_source_doesnt_return_formula_field_names(
         {},
         HTTP_USERSOURCEAUTHORIZATION=f"JWT {user_source_user_token}",
     )
-    fake_request.user = user_source_user
+    fake_request.user_source_user = user_source_user
     dispatch_context = BuilderDispatchContext(fake_request, page)
 
     mock_get_builder_used_property_names.return_value = {
diff --git a/backend/tests/baserow/contrib/builder/data_sources/test_dispatch_context.py b/backend/tests/baserow/contrib/builder/data_sources/test_dispatch_context.py
index ce58dd5d4..ffdff3bb9 100644
--- a/backend/tests/baserow/contrib/builder/data_sources/test_dispatch_context.py
+++ b/backend/tests/baserow/contrib/builder/data_sources/test_dispatch_context.py
@@ -59,7 +59,7 @@ def test_dispatch_context_page_from_context(mock_get_field_names, data_fixture):
     )
 
     request = Request(HttpRequest())
-    request.user = user_source_user
+    request.user_source_user = user_source_user
 
     dispatch_context = BuilderDispatchContext(
         request, page, offset=0, count=5, only_expose_public_allowed_properties=True
@@ -211,7 +211,7 @@ def test_builder_dispatch_context_field_names_computed_on_param(
     mock_get_builder_used_property_names.return_value = mock_field_names
 
     mock_request = MagicMock()
-    mock_request.user.is_anonymous = True
+    mock_request.user_source_user.is_anonymous = True
     mock_page = MagicMock()
     mock_page.builder = MagicMock()
 
@@ -224,7 +224,7 @@ def test_builder_dispatch_context_field_names_computed_on_param(
     if only_expose_public_allowed_properties:
         assert dispatch_context.public_allowed_properties == mock_field_names
         mock_get_builder_used_property_names.assert_called_once_with(
-            mock_request.user, mock_page.builder
+            mock_request.user_source_user, mock_page.builder
         )
     else:
         assert dispatch_context.public_allowed_properties is None
@@ -378,7 +378,7 @@ def test_builder_dispatch_context_public_allowed_properties_is_cached(
     )
 
     request = Request(HttpRequest())
-    request.user = user_source_user
+    request.user_source_user = user_source_user
 
     dispatch_context = BuilderDispatchContext(
         request,
diff --git a/backend/tests/baserow/contrib/builder/test_builder_handler.py b/backend/tests/baserow/contrib/builder/test_builder_handler.py
index 72ff1b191..e782b06a3 100644
--- a/backend/tests/baserow/contrib/builder/test_builder_handler.py
+++ b/backend/tests/baserow/contrib/builder/test_builder_handler.py
@@ -44,43 +44,27 @@ def test_get_builder_select_related_theme_config(
 
 
 @pytest.mark.parametrize(
-    "is_anonymous,is_editor_user,user_role,expected_cache_key",
+    "is_anonymous,user_role,expected_cache_key",
     [
         (
             True,
-            False,
             "",
             f"{CACHE_KEY_PREFIX}_100",
         ),
         (
             True,
-            False,
             "foo_role",
             f"{CACHE_KEY_PREFIX}_100",
         ),
         (
-            False,
             False,
             "foo_role",
             f"{CACHE_KEY_PREFIX}_100_foo_role",
         ),
-        (
-            False,
-            False,
-            "bar_role",
-            f"{CACHE_KEY_PREFIX}_100_bar_role",
-        ),
-        # Test the "editor" role
-        (
-            False,
-            True,
-            "",
-            None,
-        ),
     ],
 )
 def test_get_builder_used_properties_cache_key_returned_expected_cache_key(
-    is_anonymous, is_editor_user, user_role, expected_cache_key
+    is_anonymous, user_role, expected_cache_key
 ):
     """
     Test the BuilderHandler::get_builder_used_properties_cache_key() method.
@@ -88,13 +72,9 @@ def test_get_builder_used_properties_cache_key_returned_expected_cache_key(
     Ensure the expected cache key is returned.
     """
 
-    mock_request = MagicMock()
-
-    if is_editor_user:
-        mock_request.user = MagicMock(spec=User)
-
-    mock_request.user.is_anonymous = is_anonymous
-    mock_request.user.role = user_role
+    user_source_user = MagicMock()
+    user_source_user.is_anonymous = is_anonymous
+    user_source_user.role = user_role
 
     mock_builder = MagicMock()
     mock_builder.id = 100
@@ -102,35 +82,12 @@ def test_get_builder_used_properties_cache_key_returned_expected_cache_key(
     handler = BuilderHandler()
 
     cache_key = handler.get_builder_used_properties_cache_key(
-        mock_request.user, mock_builder
+        user_source_user, mock_builder
     )
 
     assert cache_key == expected_cache_key
 
 
-def test_get_builder_used_properties_cache_key_returns_none():
-    """
-    Test the BuilderHandler::get_builder_used_properties_cache_key() method.
-
-    Ensure that None is returned when request or builder are not available,
-    or if the user is a builder user.
-    """
-
-    mock_request = MagicMock()
-    mock_request.user = MagicMock(spec=User)
-
-    mock_builder = MagicMock()
-    mock_builder.id = 100
-
-    handler = BuilderHandler()
-
-    cache_key = handler.get_builder_used_properties_cache_key(
-        mock_request.user, mock_builder
-    )
-
-    assert cache_key is None
-
-
 @pytest.mark.django_db
 def test_public_allowed_properties_is_cached(data_fixture, django_assert_num_queries):
     """
diff --git a/backend/tests/baserow/core/test_core_utils_cache.py b/backend/tests/baserow/core/test_core_utils_cache.py
new file mode 100755
index 000000000..f40e1201c
--- /dev/null
+++ b/backend/tests/baserow/core/test_core_utils_cache.py
@@ -0,0 +1,132 @@
+from django.core.cache import cache
+
+from baserow.core.utils import invalidate_versioned_cache, safe_get_or_set_cache
+
+
+def test_safe_get_or_set_cache_literally_stores_default():
+    """If the cache is empty, a literal default value is stored and returned."""
+
+    cache_key = "test_literal_default"
+    cache.delete(cache_key)
+
+    result = safe_get_or_set_cache(
+        cache_key=cache_key,
+        default="my_default_value",
+        timeout=60,
+    )
+    assert result == "my_default_value"
+    assert cache.get(cache_key) == "my_default_value"
+
+
+def test_safe_get_or_set_cache_callable_stores_return_value():
+    """
+    If the cache is empty, a callable default's return value is stored and returned.
+    """
+
+    cache_key = "test_callable_default"
+    cache.delete(cache_key)
+
+    def some_callable():
+        return "callable_value"
+
+    result = safe_get_or_set_cache(
+        cache_key=cache_key,
+        default=some_callable,
+        timeout=60,
+    )
+    assert result == "callable_value"
+    assert cache.get(cache_key) == "callable_value"
+
+
+def test_safe_get_or_set_cache_uses_existing_value():
+    """
+    If the cache key already has a value, it should be returned without overwriting.
+    """
+
+    cache_key = "test_existing"
+    cache.delete(cache_key)
+    cache.set(cache_key, "existing_value", 60)
+
+    result = safe_get_or_set_cache(
+        cache_key=cache_key,
+        default="unused_default",
+        timeout=60,
+    )
+    assert result == "existing_value"
+    # Confirm it didn't overwrite with 'unused_default'
+    assert cache.get(cache_key) == "existing_value"
+
+
+def test_versioned_cache_set_and_retrieve():
+    """
+    When a version_cache_key is given and the value does not exist,
+    it should store and retrieve the value under <cache_key>__version_X.
+    """
+
+    base_key = "test_versioned_base"
+    version_cache_key = "test_versioned_key"
+    cache.delete(base_key)
+    cache.delete(version_cache_key)
+
+    # No version exists, so this should initialize version=0
+    result = safe_get_or_set_cache(
+        cache_key=base_key,
+        version_cache_key=version_cache_key,
+        default="versioned_value",
+        timeout=60,
+    )
+    assert result == "versioned_value"
+    # Confirm the value is stored under the versioned key
+    assert cache.get(f"{base_key}__version_0") == "versioned_value"
+
+
+def test_versioned_cache_hit():
+    """
+    If a versioned key already exists, safe_get_or_set_cache should retrieve
+    that existing value rather than setting a new one.
+    """
+
+    base_key = "test_versioned_base2"
+    version_cache_key = "test_versioned_key2"
+    cache.delete(base_key)
+    cache.delete(version_cache_key)
+
+    # Manually set version=5
+    cache.set(version_cache_key, 5)
+    full_key = f"{base_key}__version_5"
+    cache.set(full_key, "already_versioned", 60)
+
+    result = safe_get_or_set_cache(
+        cache_key=base_key,
+        version_cache_key=version_cache_key,
+        default="unused_default",
+        timeout=60,
+    )
+    assert result == "already_versioned"
+    assert cache.get(full_key) == "already_versioned"
+
+
+def test_invalidate_versioned_cache_increments_existing():
+    """
+    If a version_cache_key already exists, calling invalidate_versioned_cache should
+    increment the version.
+    """
+
+    version_key = "test_invalidate_existing"
+    cache.set(version_key, 3)
+
+    invalidate_versioned_cache(version_key)
+    assert cache.get(version_key) == 4
+
+
+def test_invalidate_versioned_cache_sets_new_if_absent():
+    """
+    If a versioned cache key doesn't exist, calling invalidate_versioned_cache should
+    create it and set it to 1.
+    """
+
+    version_key = "test_invalidate_absent"
+    cache.delete(version_key)
+
+    invalidate_versioned_cache(version_key)
+    assert cache.get(version_key) == 1
diff --git a/changelog/entries/unreleased/refactor/builder_improved_performance_in_preview_mode.json b/changelog/entries/unreleased/refactor/builder_improved_performance_in_preview_mode.json
new file mode 100644
index 000000000..92ba73ef3
--- /dev/null
+++ b/changelog/entries/unreleased/refactor/builder_improved_performance_in_preview_mode.json
@@ -0,0 +1,7 @@
+{
+    "type": "refactor",
+    "message": "[Builder] Improved performance in preview mode",
+    "issue_number": null,
+    "bullet_points": [],
+    "created_at": "2025-02-13"
+}
\ No newline at end of file