diff --git a/web/src/lib/components/onboarding-page/onboarding-theme.svelte b/web/src/lib/components/onboarding-page/onboarding-theme.svelte
index 4229cf9f67..9d3345dc2d 100644
--- a/web/src/lib/components/onboarding-page/onboarding-theme.svelte
+++ b/web/src/lib/components/onboarding-page/onboarding-theme.svelte
@@ -1,12 +1,12 @@
 <script lang="ts">
-  import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js';
+  import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
   import Button from '$lib/components/elements/buttons/button.svelte';
   import Icon from '$lib/components/elements/icon.svelte';
-  import OnboardingCard from './onboarding-card.svelte';
-  import { colorTheme } from '$lib/stores/preferences.store';
-  import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
   import { Theme } from '$lib/constants';
+  import { themeManager } from '$lib/managers/theme-manager.svelte';
+  import { mdiArrowRight, mdiThemeLightDark } from '@mdi/js';
   import { t } from 'svelte-i18n';
+  import OnboardingCard from './onboarding-card.svelte';
 
   interface Props {
     onDone: () => void;
@@ -24,7 +24,7 @@
     <button
       type="button"
       class="w-1/2 aspect-square bg-immich-bg rounded-3xl transition-all shadow-sm hover:shadow-xl border-[3px] border-immich-dark-primary/80 border-immich-primary dark:border dark:border-transparent"
-      onclick={() => ($colorTheme.value = Theme.LIGHT)}
+      onclick={() => themeManager.setTheme(Theme.LIGHT)}
     >
       <div
         class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-primary"
@@ -36,7 +36,7 @@
     <button
       type="button"
       class="w-1/2 aspect-square bg-immich-dark-bg rounded-3xl dark:border-[3px] dark:border-immich-dark-primary/80 dark:border-immich-dark-primary border border-transparent"
-      onclick={() => ($colorTheme.value = Theme.DARK)}
+      onclick={() => themeManager.setTheme(Theme.DARK)}
     >
       <div
         class="flex flex-col place-items-center place-content-center justify-around h-full w-full text-immich-dark-primary"
diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte
index e21a73ab43..80e7f56148 100644
--- a/web/src/lib/components/shared-components/map/map.svelte
+++ b/web/src/lib/components/shared-components/map/map.svelte
@@ -9,15 +9,15 @@
 <script lang="ts">
   import Icon from '$lib/components/elements/icon.svelte';
   import { Theme } from '$lib/constants';
-  import { colorTheme, mapSettings } from '$lib/stores/preferences.store';
+  import { themeManager } from '$lib/managers/theme-manager.svelte';
+  import { mapSettings } from '$lib/stores/preferences.store';
   import { serverConfig } from '$lib/stores/server-config.store';
   import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
   import { type MapMarkerResponseDto } from '@immich/sdk';
   import mapboxRtlUrl from '@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.min.js?url';
   import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
   import type { Feature, GeoJsonProperties, Geometry, Point } from 'geojson';
-  import { type GeoJSONSource, GlobeControl, type LngLatLike } from 'maplibre-gl';
-  import maplibregl from 'maplibre-gl';
+  import maplibregl, { GlobeControl, type GeoJSONSource, type LngLatLike } from 'maplibre-gl';
   import { t } from 'svelte-i18n';
   import {
     AttributionControl,
@@ -68,7 +68,7 @@
   let map: maplibregl.Map | undefined = $state();
   let marker: maplibregl.Marker | null = null;
 
-  const theme = $derived($mapSettings.allowDarkMode ? $colorTheme.value : Theme.LIGHT);
+  const theme = $derived($mapSettings.allowDarkMode ? themeManager.value : Theme.LIGHT);
   const styleUrl = $derived(theme === Theme.DARK ? $serverConfig.mapDarkStyleUrl : $serverConfig.mapLightStyleUrl);
 
   export function addClipMapMarker(lng: number, lat: number) {
diff --git a/web/src/lib/components/shared-components/qrcode.svelte b/web/src/lib/components/shared-components/qrcode.svelte
index fa0a237577..3940975fba 100644
--- a/web/src/lib/components/shared-components/qrcode.svelte
+++ b/web/src/lib/components/shared-components/qrcode.svelte
@@ -1,7 +1,7 @@
 <script lang="ts">
-  import QRCode from 'qrcode';
-  import { colorTheme } from '$lib/stores/preferences.store';
   import { Theme } from '$lib/constants';
+  import { themeManager } from '$lib/managers/theme-manager.svelte';
+  import QRCode from 'qrcode';
   import { t } from 'svelte-i18n';
 
   type Props = {
@@ -14,7 +14,7 @@
 
   let promise = $derived(
     QRCode.toDataURL(value, {
-      color: { dark: $colorTheme.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' },
+      color: { dark: themeManager.value === Theme.DARK ? '#ffffffff' : '#000000ff', light: '#00000000' },
       margin: 0,
       width,
     }),
diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte
index 446668256f..2ed59e6cf7 100644
--- a/web/src/lib/components/shared-components/theme-button.svelte
+++ b/web/src/lib/components/shared-components/theme-button.svelte
@@ -1,13 +1,11 @@
 <script lang="ts">
   import { moonPath, moonViewBox, sunPath, sunViewBox } from '$lib/assets/svg-paths';
   import CircleIconButton, { type Padding } from '$lib/components/elements/buttons/circle-icon-button.svelte';
-  import { Theme } from '$lib/constants';
-  import { colorTheme, handleToggleTheme } from '$lib/stores/preferences.store';
+  import { themeManager } from '$lib/managers/theme-manager.svelte';
   import { t } from 'svelte-i18n';
 
-  let icon = $derived($colorTheme.value === Theme.LIGHT ? moonPath : sunPath);
-  let viewBox = $derived($colorTheme.value === Theme.LIGHT ? moonViewBox : sunViewBox);
-  let isDark = $derived($colorTheme.value === Theme.DARK);
+  let icon = $derived(themeManager.isDark ? sunPath : moonPath);
+  let viewBox = $derived(themeManager.isDark ? sunViewBox : moonViewBox);
 
   interface Props {
     padding?: Padding;
@@ -16,14 +14,14 @@
   let { padding = '3' }: Props = $props();
 </script>
 
-{#if !$colorTheme.system}
+{#if !themeManager.theme.system}
   <CircleIconButton
     title={$t('toggle_theme')}
     {icon}
     {viewBox}
     role="switch"
-    aria-checked={isDark ? 'true' : 'false'}
-    onclick={handleToggleTheme}
+    aria-checked={themeManager.isDark ? 'true' : 'false'}
+    onclick={() => themeManager.toggleTheme()}
     {padding}
   />
 {/if}
diff --git a/web/src/lib/components/user-settings-page/app-settings.svelte b/web/src/lib/components/user-settings-page/app-settings.svelte
index 5b4a19c34f..f1d8e14787 100644
--- a/web/src/lib/components/user-settings-page/app-settings.svelte
+++ b/web/src/lib/components/user-settings-page/app-settings.svelte
@@ -1,11 +1,12 @@
 <script lang="ts">
+  import { invalidateAll } from '$app/navigation';
   import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
   import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
   import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
   import { defaultLang, fallbackLocale, langs, locales } from '$lib/constants';
+  import { themeManager } from '$lib/managers/theme-manager.svelte';
   import {
     alwaysLoadOriginalFile,
-    colorTheme,
     lang,
     locale,
     loopVideo,
@@ -17,7 +18,6 @@
   import { onMount } from 'svelte';
   import { locale as i18nLocale, t } from 'svelte-i18n';
   import { fade } from 'svelte/transition';
-  import { invalidateAll } from '$app/navigation';
 
   let time = $state(new Date());
 
@@ -40,10 +40,6 @@
       }));
   };
 
-  const handleToggleColorTheme = () => {
-    $colorTheme.system = !$colorTheme.system;
-  };
-
   const handleToggleLocaleBrowser = () => {
     $locale = $locale ? undefined : fallbackLocale.code;
   };
@@ -101,8 +97,8 @@
         <SettingSwitch
           title={$t('theme_selection')}
           subtitle={$t('theme_selection_description')}
-          bind:checked={$colorTheme.system}
-          onToggle={handleToggleColorTheme}
+          checked={themeManager.theme.system}
+          onToggle={(isChecked) => themeManager.setSystem(isChecked)}
         />
       </div>
 
diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts
index 1102e482ec..f8e39411cf 100644
--- a/web/src/lib/managers/event-manager.svelte.ts
+++ b/web/src/lib/managers/event-manager.svelte.ts
@@ -1,3 +1,4 @@
+import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
 import type { LoginResponseDto } from '@immich/sdk';
 
 type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;
@@ -56,4 +57,5 @@ export const eventManager = new EventManager<{
   'auth.login': [LoginResponseDto];
   'auth.logout': [];
   'language.change': [{ name: string; code: string; rtl?: boolean }];
+  'theme.change': [ThemeSetting];
 }>();
diff --git a/web/src/lib/managers/theme-manager.svelte.ts b/web/src/lib/managers/theme-manager.svelte.ts
new file mode 100644
index 0000000000..a20e5f9a98
--- /dev/null
+++ b/web/src/lib/managers/theme-manager.svelte.ts
@@ -0,0 +1,78 @@
+import { browser } from '$app/environment';
+import { Theme } from '$lib/constants';
+import { eventManager } from '$lib/managers/event-manager.svelte';
+import { PersistedLocalStorage } from '$lib/utils/persisted';
+
+export interface ThemeSetting {
+  value: Theme;
+  system: boolean;
+}
+
+const getDefaultTheme = () => {
+  if (!browser) {
+    return Theme.DARK;
+  }
+
+  return globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT;
+};
+
+class ThemeManager {
+  #theme = new PersistedLocalStorage<ThemeSetting>(
+    'color-theme',
+    { value: getDefaultTheme(), system: false },
+    {
+      valid: (value): value is ThemeSetting => {
+        return Object.values(Theme).includes((value as ThemeSetting)?.value);
+      },
+    },
+  );
+
+  get theme() {
+    return this.#theme.current;
+  }
+
+  value = $derived(this.theme.value);
+
+  isDark = $derived(this.value === Theme.DARK);
+
+  constructor() {
+    eventManager.on('app.init', () => this.#onAppInit());
+  }
+
+  setSystem(system: boolean) {
+    this.#update(system ? 'system' : getDefaultTheme());
+  }
+
+  setTheme(theme: Theme) {
+    this.#update(theme);
+  }
+
+  toggleTheme() {
+    this.#update(this.value === Theme.DARK ? Theme.LIGHT : Theme.DARK);
+  }
+
+  #onAppInit() {
+    globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+      if (this.theme.system) {
+        this.#update('system');
+      }
+    });
+  }
+
+  #update(value: Theme | 'system') {
+    const theme: ThemeSetting =
+      value === 'system' ? { system: true, value: getDefaultTheme() } : { system: false, value };
+
+    if (theme.value === Theme.LIGHT) {
+      document.documentElement.classList.remove('dark');
+    } else {
+      document.documentElement.classList.add('dark');
+    }
+
+    this.#theme.current = theme;
+
+    eventManager.emit('theme.change', theme);
+  }
+}
+
+export const themeManager = new ThemeManager();
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts
index e268e8817d..e7f38eb6d0 100644
--- a/web/src/lib/stores/preferences.store.ts
+++ b/web/src/lib/stores/preferences.store.ts
@@ -2,39 +2,12 @@ import { browser } from '$app/environment';
 import { Theme, defaultLang } from '$lib/constants';
 import { getPreferredLocale } from '$lib/utils/i18n';
 import { persisted } from 'svelte-persisted-store';
-import { get } from 'svelte/store';
 
 export interface ThemeSetting {
   value: Theme;
   system: boolean;
 }
 
-export const handleToggleTheme = () => {
-  const theme = get(colorTheme);
-  theme.value = theme.value === Theme.DARK ? Theme.LIGHT : Theme.DARK;
-  colorTheme.set(theme);
-};
-
-const initTheme = (): ThemeSetting => {
-  if (browser && globalThis.matchMedia && !globalThis.matchMedia('(prefers-color-scheme: dark)').matches) {
-    return { value: Theme.LIGHT, system: false };
-  }
-  return { value: Theme.DARK, system: false };
-};
-
-const initialTheme = initTheme();
-
-// The 'color-theme' key is also used by app.html to prevent FOUC on page load.
-export const colorTheme = persisted<ThemeSetting>('color-theme', initialTheme, {
-  serializer: {
-    parse: (text: string): ThemeSetting => {
-      const parsedText: ThemeSetting = JSON.parse(text);
-      return Object.values(Theme).includes(parsedText.value) ? parsedText : initTheme();
-    },
-    stringify: (object) => JSON.stringify(object),
-  },
-});
-
 // Locale to use for formatting dates, numbers, etc.
 export const locale = persisted<string | undefined>('locale', undefined, {
   serializer: {
diff --git a/web/src/lib/utils/persisted.ts b/web/src/lib/utils/persisted.ts
new file mode 100644
index 0000000000..73eb4de5db
--- /dev/null
+++ b/web/src/lib/utils/persisted.ts
@@ -0,0 +1,81 @@
+import { browser } from '$app/environment';
+import { createSubscriber } from 'svelte/reactivity';
+
+type PersistedBaseOptions<T> = {
+  read: (key: string) => T | undefined;
+  write: (key: string, value: T) => void;
+};
+
+class PersistedBase<T> {
+  #value: T;
+  #subscribe: () => void;
+  #update = () => {};
+
+  #write: (value: T) => void;
+
+  get current() {
+    this.#subscribe();
+    return this.#value as T;
+  }
+
+  set current(value: T) {
+    this.#write(value);
+    this.#update();
+    this.#value = value;
+  }
+
+  constructor(key: string, defaultValue: T, options: PersistedBaseOptions<T>) {
+    const value = options.read(key);
+
+    this.#value = value === undefined ? defaultValue : value;
+    this.#write = (value: T) => options.write(key, value);
+
+    this.#subscribe = createSubscriber((update) => {
+      this.#update = update;
+
+      return () => {
+        this.#update = () => {};
+      };
+    });
+  }
+}
+
+type PersistedLocalStorageOptions<T> = {
+  serializer?: {
+    stringify(value: T): string;
+    parse(text: string): T;
+  };
+  valid?: (value: T | unknown) => value is T;
+};
+
+export class PersistedLocalStorage<T> extends PersistedBase<T> {
+  constructor(key: string, defaultValue: T, options: PersistedLocalStorageOptions<T> = {}) {
+    const valid = options.valid || (() => true);
+    const serializer = options.serializer || JSON;
+
+    super(key, defaultValue, {
+      read: (key: string) => {
+        if (!browser) {
+          return;
+        }
+
+        const item = localStorage.getItem(key) ?? undefined;
+        if (item === undefined) {
+          return;
+        }
+
+        const parsed = serializer.parse(item);
+        if (!valid(parsed)) {
+          return;
+        }
+
+        return parsed;
+      },
+      write: (key: string, value: T) => {
+        if (browser) {
+          localStorage.setItem(key, serializer.stringify(value));
+        }
+      },
+    });
+  }
+}
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 39740e16fe..844037a13f 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -10,16 +10,14 @@
   import NotificationList from '$lib/components/shared-components/notification/notification-list.svelte';
   import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
   import VersionAnnouncementBox from '$lib/components/shared-components/version-announcement-box.svelte';
-  import { Theme } from '$lib/constants';
   import { eventManager } from '$lib/managers/event-manager.svelte';
-  import { colorTheme, handleToggleTheme, type ThemeSetting } from '$lib/stores/preferences.store';
   import { serverConfig } from '$lib/stores/server-config.store';
   import { user } from '$lib/stores/user.store';
   import { closeWebsocketConnection, openWebsocketConnection } from '$lib/stores/websocket';
   import { copyToClipboard } from '$lib/utils';
   import { isAssetViewerRoute } from '$lib/utils/navigation';
   import { setTranslations } from '@immich/ui';
-  import { onDestroy, onMount, type Snippet } from 'svelte';
+  import { onMount, type Snippet } from 'svelte';
   import { t } from 'svelte-i18n';
   import { run } from 'svelte/legacy';
   import '../app.css';
@@ -40,24 +38,6 @@
 
   let showNavigationLoadingBar = $state(false);
 
-  const changeTheme = (theme: ThemeSetting) => {
-    if (theme.system) {
-      theme.value = globalThis.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.DARK : Theme.LIGHT;
-    }
-
-    if (theme.value === Theme.LIGHT) {
-      document.documentElement.classList.remove('dark');
-    } else {
-      document.documentElement.classList.add('dark');
-    }
-  };
-
-  const handleChangeTheme = () => {
-    if ($colorTheme.system) {
-      handleToggleTheme();
-    }
-  };
-
   const getMyImmichLink = () => {
     return new URL(page.url.pathname + page.url.search, 'https://my.immich.app');
   };
@@ -66,11 +46,6 @@
     const element = document.querySelector('#stencil');
     element?.remove();
     // if the browser theme changes, changes the Immich theme too
-    globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', handleChangeTheme);
-  });
-
-  onDestroy(() => {
-    document.removeEventListener('change', handleChangeTheme);
   });
 
   eventManager.emit('app.init');
@@ -85,9 +60,6 @@
   afterNavigate(() => {
     showNavigationLoadingBar = false;
   });
-  run(() => {
-    changeTheme($colorTheme);
-  });
   run(() => {
     if ($user) {
       openWebsocketConnection();
diff --git a/web/src/test-data/setup.ts b/web/src/test-data/setup.ts
index 6709bd5b80..f2cfac3c36 100644
--- a/web/src/test-data/setup.ts
+++ b/web/src/test-data/setup.ts
@@ -4,3 +4,15 @@ import { init } from 'svelte-i18n';
 beforeAll(async () => {
   await init({ fallbackLocale: 'dev' });
 });
+
+Object.defineProperty(globalThis, 'matchMedia', {
+  writable: true,
+  value: vi.fn().mockImplementation((query) => ({
+    matches: false,
+    media: query,
+    onchange: null,
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    dispatchEvent: vi.fn(),
+  })),
+});