diff --git a/src/api/tests/schedule.py b/src/api/tests/schedule.py
index b2854ac00296a39c7f34b21c4ba12aef421f4083..07041e3bea269b2924e8ed703dd080c80da5f84d 100644
--- a/src/api/tests/schedule.py
+++ b/src/api/tests/schedule.py
@@ -6,7 +6,7 @@ from django.urls import reverse
 from django.utils.timezone import now
 from rest_framework.authtoken.models import Token
 
-from core.models import Assembly, CachedSchedule, Conference, Event, PlatformUser, Room
+from core.models import Assembly, ConferenceExportCache, Conference, Event, PlatformUser, Room
 
 
 class ScheduleTest(TestCase):
@@ -191,7 +191,7 @@ class ScheduleTest(TestCase):
         event.save()
 
         # sanity check: we don't have a cached schedule
-        self.assertFalse(CachedSchedule.objects.all().exists())
+        self.assertFalse(ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).exists())
 
         # query schedule and check that an ETag got sent
         url = reverse('api:conference-schedule', kwargs={'conference': self.conf.slug})
@@ -200,9 +200,9 @@ class ScheduleTest(TestCase):
         self.assertIn('ETag', resp.headers)
         self.assertNotEqual(resp.headers['ETag'], '')
 
-        # check the CachedSchedule
-        self.assertEqual(1, CachedSchedule.objects.count())
-        c = CachedSchedule.objects.first()
+        # check the cached schedule
+        self.assertEqual(1, ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).count())
+        c = ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).first()
         self.assertEqual(self.conf, c.conference)
         self.assertEqual(resp.headers['ETag'], c.etag)
 
@@ -217,8 +217,8 @@ class ScheduleTest(TestCase):
         self.assertEqual(200, resp3.status_code)
         self.assertNotEqual(resp.headers['ETag'], resp3.headers['ETag'])
 
-        # check the CachedSchedule again, should still be only one
-        self.assertEqual(1, CachedSchedule.objects.count())
+        # check the cached schedules again, should still be only one
+        self.assertEqual(1, ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).count())
         c.refresh_from_db()
         self.assertEqual(self.conf, c.conference)
         self.assertEqual(resp3.headers['ETag'], c.etag)
diff --git a/src/api/views/schedule.py b/src/api/views/schedule.py
index 186165591f601fefa0011107adaea050840545b3..95f5da7cdfda701b82b36a7de26046b1a8052d5f 100644
--- a/src/api/views/schedule.py
+++ b/src/api/views/schedule.py
@@ -1,9 +1,7 @@
-from datetime import datetime
 import logging
 
 from django.db.models import F
-from django.http import JsonResponse, HttpResponse, HttpResponseNotModified
-from django.utils.timezone import now
+from django.http import JsonResponse, HttpResponse
 from django.utils.dateparse import parse_datetime
 from django.views.generic import View
 from rest_framework import authentication
@@ -12,12 +10,10 @@ from rest_framework.views import APIView
 import pytz
 
 from core.models.assemblies import Assembly
-from core.models.conference import ConferenceTrack
+from core.models.conference import ConferenceExportCache, ConferenceTrack
 from core.models.events import Event
 from core.models.rooms import Room
-from core.models.schedules import CachedSchedule
 from core.schedules.base import filter_additional_data, schedule_time_to_timedelta
-from core.utils import int_to_custom_string
 
 from ..permissions import IsApiUserOrReadOnly
 from ..schedule import Schedule, ScheduleEncoder
@@ -27,50 +23,28 @@ logger = logging.getLogger(__name__)
 
 
 class BaseScheduleView(ConferenceSlugMixin, View):
-    def prepare_response(self):
+    def get(self, *args, **kwargs):
         req_format = 'json' if self.request.resolver_match.kwargs.get('format') == 'json' else 'xml'
         cache_id = self.get_cache_id() + '.' + req_format
-        cache_entry, _ = CachedSchedule.objects.get_or_create(conference=self.conference, tag=cache_id, defaults={'needs_regeneration': True})
 
-        if cache_entry.is_dirty:
+        def gen_data():
             schedule = Schedule(self.conference)
             events = self.get_events()
             schedule.add_events(events)
 
             if req_format == 'json':
-                cache_entry.data = schedule.json()
+                return schedule.json()
             else:
-                cache_entry.data = schedule.xml()
-            cache_entry.last_generated = now()
-            cache_entry.needs_regeneration = False
-            cache_entry.etag = int_to_custom_string(int(cache_entry.last_generated.timestamp() * 1000))
-            cache_entry.save()
-
-        headers = {
-            'Content-Type': 'application/json' if req_format == 'json' else 'text/xml',
-            'Content-Encoding': 'utf-8',
-            'ETag': cache_entry.etag,
-            'Last-Modified': cache_entry.last_generated.isoformat(),
-        }
-        return headers, cache_entry.data.encode('utf-8')
-
-    def get(self, *args, **kwargs):
-        headers, content = self.prepare_response()
-
-        if 'If-None-Match' in self.request.headers:
-            if self.request.headers['If-None-Match'] == headers['ETag']:
-                return HttpResponseNotModified()
-        if 'If-Modified-Since' in self.request.headers:
-            try:
-                ts_req = datetime.fromisoformat(self.request.headers['If-Modified-Since'])
-                ts_resp = datetime.fromisoformat(headers['Last-Modified'])
-                if ts_req >= ts_resp:
-                    return HttpResponseNotModified()
-            except ValueError:
-                # ignore timestamp parsing error, handle as non-match
-                pass
-
-        return HttpResponse(content, headers=headers)
+                return schedule.xml()
+
+        return ConferenceExportCache.handle_http_request(
+            request=self.request,
+            conference=self.conference,
+            type=ConferenceExportCache.Type.SCHEDULE,
+            ident=cache_id,
+            content_type=lambda: 'application/json' if req_format == 'json' else 'text/xml',
+            result_func=gen_data,
+        )
 
     def get_cache_id(self):
         raise NotImplementedError('Overwrite .get_cache_id() in a descendant class!')
diff --git a/src/backoffice/views/assemblies.py b/src/backoffice/views/assemblies.py
index 6ab153578b43bb25564870b5e4ee4b03c9e3fd09..01a90746561a5e72d9f13159eb92c8b273f2d290 100644
--- a/src/backoffice/views/assemblies.py
+++ b/src/backoffice/views/assemblies.py
@@ -18,9 +18,9 @@ from rest_framework.authtoken.models import Token
 
 from core.models.assemblies import Assembly, AssemblyMember, AssemblyLink
 from core.models.badges import Badge, BadgeToken
+from core.models.conference import ConferenceExportCache
 from core.models.events import Event
 from core.models.rooms import Room, RoomLink
-from core.models.schedules import CachedSchedule
 from core.models.sso import Application
 from core.models.tags import ConferenceTag
 from core.models.users import PlatformUser
@@ -65,7 +65,11 @@ class CreateAssemblyView(ConferenceMixin, CreateView):
 
         form.instance.save()
 
+        # log the action
+        messages.success(self.request, _('assembly__created'))
         logger.info('Assembly "{name}" ({id}) created by {user}.'.format(name=form.instance.name, id=form.instance.pk, user=self.request.user.username))
+
+        # add current user as first member of the new assembly (so that editing is possible)
         form.instance.members.create(
             member=self.request.user,
             is_representative=True,
@@ -73,8 +77,10 @@ class CreateAssemblyView(ConferenceMixin, CreateView):
             show_public=True,
         )
 
-        messages.success(self.request, _('assembly__created'))
+        # invalidate export caches where necessary
+        ConferenceExportCache.signal_assembly_modification(conference=self.conference, assembly=form.instance)
 
+        # we're done saving the initial version, redirect to edit view
         return redirect(reverse('backoffice:assembly-edit', kwargs={'pk': form.instance.id}))
 
 
@@ -285,11 +291,17 @@ class EditAssemblyView(AssemblyMixin, UpdateView):
             )
         assembly.save()
 
+        # log the action
         messages.success(self.request, _('assemblyedit__success'))
         logger.info(
             'Assembly "%s" (%s) edited by <%s>: %s',
             assembly.slug, assembly.pk, self.request.user.username, changes,
         )
+
+        # invalidate export caches where necessary
+        ConferenceExportCache.signal_assembly_modification(conference=self.conference, assembly=assembly)
+
+        # we're done saving, redirect to edit view again
         return redirect('backoffice:assembly-edit', pk=assembly.id)
 
     def form_invalid(self, form):
@@ -329,6 +341,7 @@ class AssemblyEditChildrenView(AssemblyMixin, View):
         request = self.request
         assembly = self.get_object()
 
+        changed = False
         add_id = request.POST.get('add', None)
         remove_id = request.POST.get('delete', None)
 
@@ -338,6 +351,7 @@ class AssemblyEditChildrenView(AssemblyMixin, View):
                 if child.parent is None:
                     child.parent = assembly
                     child.save()
+                    changed = True
                     messages.success(request, gettext('assemblyedit_addedchild').format(child_name=child.name))
                     logger.info(f'Assembly "{assembly.name}" ({assembly.pk}): added child "{child}" ({child.pk}) upon request by {request.user.username}')
                 else:
@@ -350,12 +364,18 @@ class AssemblyEditChildrenView(AssemblyMixin, View):
                 if child.parent == assembly:
                     child.parent = None
                     child.save()
+                    changed = True
                     messages.success(request, gettext('assemblyedit_removedchild').format(child_name=child.name))
                     logger.info(f'Assembly "{assembly.name}" ({assembly.pk}): removed child "{child}" ({child.pk}) upon request by {request.user.username}')
                 else:
                     messages.warning(request, gettext('assemblyedit_not_removing_foreign_child').format(child_name=child.name))
                     logger.warning(f'Assembly "{assembly.name}" ({assembly.pk}): could not remove child "{child}" ({child.pk}) for {request.user.username} (not mine)')  # noqa: E501
 
+        # invalidate export caches where necessary
+        if changed:
+            ConferenceExportCache.signal_assembly_modification(conference=self.conference, assembly=None)
+
+        # we're done saving, redirect to children list view again
         return redirect('backoffice:assembly-editchildren', pk=assembly.pk)
 
     def get(self, *args, **kwargs):
@@ -403,9 +423,14 @@ class AssemblyEditHierarchyView(AssemblyMixin, View):
         assembly.hierarchy = value
         assembly.save(update_fields=['hierarchy'])
 
+        # log the action
         messages.success(request, gettext('assemblyedit_changedhierarchy'))
         logger.info(f'Assembly "{assembly.slug}" ({assembly.pk}): set hierarchy to "{value}" upon request by <{request.user.username}>')
 
+        # invalidate export caches where necessary
+        ConferenceExportCache.signal_assembly_modification(conference=self.conference, assembly=assembly)
+
+        # we're done saving, redirect to edit view again
         return redirect('backoffice:assembly-edit', pk=assembly.pk)
 
     def get(self, *args, **kwargs):
@@ -562,7 +587,7 @@ class AssemblyRoomView(AssemblyMixin, UpdateView):
 
         logger.info(f'changed room "{form.instance}" on assembly {self.assembly} by {self.request.user}')
         messages.success(self.request, _('updated'))
-        CachedSchedule.signal_schedule_modification(self.conference, obj=self)
+        ConferenceExportCache.signal_schedule_modification(self.conference, obj=self)
 
         return res
 
@@ -1103,7 +1128,7 @@ class RemoveRoomView(AssemblyMixin, View):
 
         room.delete()
         messages.success(self.request, format_lazy('{x}: {name}', x=_('removed'), name=room.name))
-        CachedSchedule.signal_schedule_modification(self.conference, obj=room)
+        ConferenceExportCache.signal_schedule_modification(self.conference, obj=room)
         return redirect('backoffice:assembly', pk=self.assembly.id)
 
 
diff --git a/src/backoffice/views/events.py b/src/backoffice/views/events.py
index 94313cb920f8d274d941b8b188c2868a1dcc3526..cfc88e55db76580ff65289fdb4d6263ce4c9aa76 100644
--- a/src/backoffice/views/events.py
+++ b/src/backoffice/views/events.py
@@ -8,9 +8,9 @@ from django.views.generic.edit import CreateView, DeleteView, UpdateView
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
+from core.models.conference import ConferenceExportCache
 from core.models.events import Event
 from core.models.rooms import Room
-from core.models.schedules import CachedSchedule
 
 from ..forms import CreateAssemblyEventForm, SelfOrganizedSessionForm
 from .mixins import AssemblyMixin, ConferenceMixin
@@ -50,7 +50,7 @@ class AssemblyEventView(AssemblyMixin, UpdateView):
 
     def form_valid(self, form):
         messages.success(self.request, _('updated'))
-        CachedSchedule.signal_schedule_modification(self.conference)
+        ConferenceExportCache.signal_schedule_modification(self.conference)
         return super().form_valid(form)
 
     def get_success_url(self):
@@ -72,7 +72,7 @@ class AssemblyRemoveEventView(AssemblyMixin, DeleteView):
         result = super().delete(*args, **kwargs)
         messages.success(self.request, _('removed'))
         logger.info(f'Event {self.object} removed by {self.request.user}')
-        CachedSchedule.signal_schedule_modification(self.conference)
+        ConferenceExportCache.signal_schedule_modification(self.conference)
         return result
 
     def get_success_url(self, *args, **kwargs):
@@ -182,7 +182,7 @@ class SosEditView(ConferenceMixin, UpdateView):
         try:
             return super().form_valid(form)
         finally:
-            CachedSchedule.signal_schedule_modification(self.conference)
+            ConferenceExportCache.signal_schedule_modification(self.conference)
 
     def get_success_url(self):
         messages.success(self.request, _("Event--sos-updated"))
@@ -198,7 +198,7 @@ class SosDeleteView(ConferenceMixin, DeleteView):
         result = super().delete(*args, **kwargs)
         messages.success(self.request, _('Event--sos-removed%s' % (self.object.name,)))
         logger.info(f"Event (sos) '{self.object.name}' ({self.object.pk!s}) removed by '{self.request.user.username}' ({self.request.user.pk!s})")
-        CachedSchedule.signal_schedule_modification(self.conference)
+        ConferenceExportCache.signal_schedule_modification(self.conference)
         return result
 
     def get_success_url(self, *args, **kwargs):
diff --git a/src/core/management/commands/housekeeping.py b/src/core/management/commands/housekeeping.py
index 7143ecdeef87bd98ca147bd23fa9724a49f6dc9d..b9b5c9bf2a48845c52d84283e5080af7111efe35 100644
--- a/src/core/management/commands/housekeeping.py
+++ b/src/core/management/commands/housekeeping.py
@@ -4,9 +4,9 @@ from django.core.management.base import BaseCommand, CommandError
 from django.db.models import Max
 from django.utils import timezone
 
-from ...models.conference import Conference
+from ...models.conference import Conference, ConferenceExportCache
 from ...models.messages import DirectMessage
-from ...models.schedules import CachedSchedule, ScheduleSource, ScheduleSourceImport
+from ...models.schedules import ScheduleSource, ScheduleSourceImport
 from ...models.voucher import Voucher
 
 
@@ -19,7 +19,7 @@ class Command(BaseCommand):
         self._housekeeping_directmessages()
         self._housekeeping_vouchers()
         self._housekeeping_scheduleimports()
-        self._housekeeping_schedulecaching()
+        self._housekeeping_exportcache()
 
     def _housekeeping_directmessages(self):
         # clear all direct messages which are after their expiry date
@@ -70,7 +70,7 @@ class Command(BaseCommand):
         for k, v in schedule_results.items():
             print('  ', k, ' => ', v, sep='')
 
-    def _housekeeping_schedulecaching(self):
+    def _housekeeping_exportcache(self):
         ref = {}
         for c in Conference.objects.all():
             ts = []
@@ -80,7 +80,7 @@ class Command(BaseCommand):
             ref[c.id] = max([x for x in ts if x is not None], default=None)
 
         changed, total = 0, 0
-        for entry in CachedSchedule.objects.all():
+        for entry in ConferenceExportCache.objects.all():
             total += 1
 
             # entries already marked for regeneration don't need further action
@@ -97,7 +97,7 @@ class Command(BaseCommand):
             entry.save(update_fields=['needs_regeneration'])
             changed += 1
 
-        print('Flagged', changed, 'out of', total, 'cached schedules for regeneration.')
+        print('Flagged', changed, 'out of', total, 'cached exports for regeneration.')
 
     def handle(self, *args, **options):
         # call _do_housekeeping repeatedly (unless --forever is not set)
diff --git a/src/core/migrations/0098_conferenceexportcache.py b/src/core/migrations/0098_conferenceexportcache.py
new file mode 100644
index 0000000000000000000000000000000000000000..4aaea53b9c98ab8223dd6a0985776a1e721b4ac6
--- /dev/null
+++ b/src/core/migrations/0098_conferenceexportcache.py
@@ -0,0 +1,34 @@
+# Generated by Django 4.2.2 on 2023-07-27 08:59
+
+import core.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0097_staticpage_language_null'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ConferenceExportCache',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('type', models.CharField(choices=[('schedule', 'Schedule'), ('map', 'Map'), ('assemblies', 'Assemblies')], max_length=20)),
+                ('ident', models.CharField(max_length=50)),
+                ('etag', models.CharField(editable=False, max_length=100)),
+                ('needs_regeneration', models.BooleanField(default=True)),
+                ('last_generated', models.DateTimeField(blank=True, null=True)),
+                ('data', models.TextField(blank=True, null=True)),
+                ('conference', core.fields.ConferenceReference(help_text='Conference__reference_help', on_delete=django.db.models.deletion.CASCADE, related_name='+', to='core.conference', verbose_name='Conference__reference')),
+            ],
+            options={
+                'unique_together': {('conference', 'ident')},
+            },
+        ),
+        migrations.DeleteModel(
+            name='CachedSchedule',
+        ),
+    ]
diff --git a/src/core/models/__init__.py b/src/core/models/__init__.py
index fd19ca451d82193583359389e79716c20c4a2590..54c5ac03feea3589c82e7d17e3102b90ee81e4cb 100644
--- a/src/core/models/__init__.py
+++ b/src/core/models/__init__.py
@@ -1,13 +1,13 @@
 from .assemblies import Assembly, AssemblyLikeCount, AssemblyLink, AssemblyMember
 from .badges import Badge, UserBadge, BadgeToken
-from .conference import Conference, ConferenceMember, ConferenceTrack
+from .conference import Conference, ConferenceExportCache, ConferenceMember, ConferenceTrack
 from .dereferrer import UserDereferrerAllowlist, DereferrerStats
 from .board import BulletinBoardEntry
 from .events import Event, EventAttachment, EventLikeCount, EventParticipant
 from .pages import StaticPage, StaticPageRevision
 from .messages import DirectMessage
 from .rooms import Room, RoomLink
-from .schedules import CachedSchedule, ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping
+from .schedules import ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping
 from .shared import BackendMixin
 from .sso import Application
 from .tags import ConferenceTag, TagItem
@@ -20,8 +20,7 @@ __all__ = [
     'Application',
     'Assembly', 'AssemblyLikeCount', 'AssemblyLink', 'AssemblyMember',
     'BackendMixin', 'Badge', 'BadgeToken', 'BulletinBoardEntry',
-    'CachedSchedule',
-    'Conference', 'ConferenceMember', 'ConferenceMemberTicket', 'ConferenceTag', 'ConferenceTrack',
+    'Conference', 'ConferenceExportCache', 'ConferenceMember', 'ConferenceMemberTicket', 'ConferenceTag', 'ConferenceTrack',
     'DereferrerStats', 'DirectMessage',
     'Event', 'EventAttachment', 'EventLikeCount', 'EventParticipant',
     'PlatformUser',
diff --git a/src/core/models/conference.py b/src/core/models/conference.py
index a59d4a67bb08e5cf945a06d48fd8a802c02f5b3b..d6e5cf5c75704729228a572434f55713b217bbf0 100644
--- a/src/core/models/conference.py
+++ b/src/core/models/conference.py
@@ -3,15 +3,20 @@ import pytz
 from uuid import uuid4
 from datetime import datetime, time, timedelta
 
+from django.conf import settings
 from django.contrib.auth.models import Group
 from django.contrib.postgres import fields as pg_fields
 from django.core.exceptions import ValidationError
 from django.db import models
+from django.db.models import Max
+from django.http import HttpRequest, HttpResponseNotModified, HttpResponse
 from django.utils import timezone
 from django.utils.functional import cached_property
+from django.utils.timezone import now
 from django.utils.translation import get_language, gettext_lazy as _
 
 from ..fields import ConferenceReference
+from ..utils import int_to_custom_string
 from .users import PlatformUser
 
 
@@ -435,3 +440,129 @@ class ConferenceDay:
         self.index = i + 1
         self.start = datetime.combine(date, time(6, 0)).astimezone(tz)
         self.end = datetime.combine(date + timedelta(days=1), time(4, 0)).astimezone(tz)
+
+
+class ConferenceExportCache(models.Model):
+    class Type(models.TextChoices):
+        SCHEDULE = 'schedule'
+        MAP = 'map'
+        ASSEMBLIES = 'assemblies'
+
+    conference = ConferenceReference(related_name='+')
+    type = models.CharField(max_length=20, choices=Type.choices)
+    ident = models.CharField(max_length=50)
+    etag = models.CharField(max_length=100, editable=False)
+    needs_regeneration = models.BooleanField(default=True)
+    last_generated = models.DateTimeField(blank=True, null=True)
+    data = models.TextField(blank=True, null=True)
+
+    class Meta(object):
+        unique_together = [
+            ('conference', 'ident'),
+        ]
+
+    @property
+    def is_dirty(self):
+        # we're flagged for regeneration already or have no data -> yes, we're dirty
+        if self.needs_regeneration is True or self.data is None or self.last_generated is None:
+            return True
+
+        # check per-type if updates are available
+        if self.type == self.Type.SCHEDULE:
+            from .events import Event
+            from .rooms import Room
+
+            # get latest update timestamp for ConferenceTracks, Events and Rooms
+            last_track = ConferenceTrack.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
+            last_event = Event.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
+            last_room = Room.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
+
+            # if one of those is greater than our last generation, we need to update
+            if (last_track is not None and last_track > self.last_generated) or \
+               (last_event is not None and last_event > self.last_generated) or \
+               (last_room is not None and last_room > self.last_generated):
+                return True
+
+        elif self.type == self.Type.MAP:
+            from .assemblies import Assembly
+            last_assemblies = Assembly.objects.filter(conference=self.conference).aggregate(Max('last_update_staff'))['last_update_staff__max']
+            if last_assemblies is not None and last_assemblies > self.last_generated:
+                return True
+
+        elif self.type == self.Type.ASSEMBLIES:
+            from .assemblies import Assembly
+            last_updates = Assembly.objects.filter(conference=self.conference).aggregate(Max('last_update_assembly'), Max('last_update_staff'))
+            last_assemblies = last_updates['last_update_assembly__max']
+            last_staff = last_updates['last_update_staff__max']
+            if (last_assemblies is not None and last_assemblies > self.last_generated) or \
+               (last_staff is not None and last_staff > self.last_generated):
+                return True
+
+        else:
+            raise NotImplementedError('Unexpected value for ConferenceExportCache.type!')
+
+        # no need to update found yet -> there is none
+        return False
+
+    def save(self, *args, **kwargs):
+        if self.last_generated is not None:
+            self.etag = int_to_custom_string(int(self.last_generated.timestamp() * 1000))
+        return super().save(*args, **kwargs)
+
+    @classmethod
+    def handle_http_request(cls, request: HttpRequest, conference, type: Type, ident: str, content_type, result_func):
+        cache_entry, _ = cls.objects.get_or_create(
+            conference=conference, type=type, ident=ident,
+            defaults={'needs_regeneration': True},
+        )
+
+        if cache_entry.is_dirty or settings.DEBUG:
+            cache_entry.data = result_func()
+            cache_entry.last_generated = now()
+            cache_entry.needs_regeneration = False
+            cache_entry.save()
+
+        headers = {
+            'Content-Type': content_type if isinstance(content_type, str) else content_type(),
+            'Content-Encoding': 'utf-8',
+            'ETag': cache_entry.etag,
+            'Last-Modified': cache_entry.last_generated.isoformat(),
+        }
+
+        content = cache_entry.data.encode('utf-8')
+
+        if 'If-None-Match' in request.headers:
+            if request.headers['If-None-Match'] == headers['ETag']:
+                return HttpResponseNotModified()
+
+        if 'If-Modified-Since' in request.headers:
+            try:
+                ts_req = datetime.fromisoformat(request.headers['If-Modified-Since'])
+                ts_resp = datetime.fromisoformat(headers['Last-Modified'])
+                if ts_req >= ts_resp:
+                    return HttpResponseNotModified()
+            except ValueError:
+                # ignore timestamp parsing error, handle as non-match
+                pass
+
+        return HttpResponse(content, headers=headers)
+
+    @classmethod
+    def signal_schedule_modification(cls, conference, obj=None):
+        """
+        Signals that the conference's schedule got an update and will need to be regenerated.
+        :type conference: Conference
+        :type obj: Assembly|Event|Room
+        """
+        # TODO: check if modified obj is relevant to the cached schedule(s)
+        cls.objects.filter(conference=conference, type=cls.Type.SCHEDULE).update(needs_regeneration=True)
+
+    @classmethod
+    def signal_assembly_modification(cls, conference, assembly=None):
+        """
+        Signals that the conference's assemblies got an update and exports may need to be regenerated.
+        :type conference: Conference
+        :type assembly: Assembly
+        """
+        # TODO: check if modified assembly is relevant to the cached schedule(s)
+        cls.objects.filter(conference=conference, type__in=[cls.Type.ASSEMBLIES, cls.Type.MAP]).update(needs_regeneration=True)
diff --git a/src/core/models/schedules.py b/src/core/models/schedules.py
index 6b4cff71ae302662745e93269ce3c102990b6ea3..24fa4c68a5dfa34616b0d5b0fcec605b8cd6bae6 100644
--- a/src/core/models/schedules.py
+++ b/src/core/models/schedules.py
@@ -4,12 +4,10 @@ from uuid import uuid4, UUID
 
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import models
-from django.db.models import Max
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
 from .assemblies import Assembly
-from .conference import ConferenceTrack
 from .events import Event, EventAttachment, EventParticipant
 from .rooms import Room
 from ..fields import ConferenceReference
@@ -29,50 +27,6 @@ class LocalObjectAccessViolation(Exception):
     pass
 
 
-class CachedSchedule(models.Model):
-    conference = ConferenceReference(related_name='+')
-    tag = models.CharField(max_length=20)
-    etag = models.CharField(max_length=100)
-    needs_regeneration = models.BooleanField(default=True)
-    last_generated = models.DateTimeField(blank=True, null=True)
-    data = models.TextField(blank=True, null=True)
-
-    class Meta(object):
-        unique_together = [
-            ('conference', 'tag'),
-        ]
-
-    @property
-    def is_dirty(self):
-        # we're flagged for regeneration already or have no data -> yes, we're dirty
-        if self.needs_regeneration is True or self.data is None or self.last_generated is None:
-            return True
-
-        # get latest update timestamp for ConferenceTracks, Events and Rooms
-        last_track = ConferenceTrack.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
-        last_event = Event.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
-        last_room = Room.objects.filter(conference=self.conference).aggregate(Max('last_update'))['last_update__max']
-
-        # if one of those is greater than our last generation, we need to update
-        if (last_track is not None and last_track > self.last_generated) or \
-           (last_event is not None and last_event > self.last_generated) or \
-           (last_room is not None and last_room > self.last_generated):
-            return True
-
-        # no need to update found yet -> there is none
-        return False
-
-    @classmethod
-    def signal_schedule_modification(cls, conference, obj=None):
-        """
-        Signals that the conference's schedule got an update and will need to be regenerated.
-        :type conference: Conference
-        :type obj: Assembly|Event|Room
-        """
-        # TODO: check if modified obj is relevant to the cached schedule(s)
-        cls.objects.filter(conference=conference).update(needs_regeneration=True)
-
-
 class ScheduleSource(models.Model):
     id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
 
diff --git a/src/core/tests/__init__.py b/src/core/tests/__init__.py
index f53369815322269853ca7288cf91c0656d6678cd..3568c620d487d0d7697cc723ae3a0791d2a67546 100644
--- a/src/core/tests/__init__.py
+++ b/src/core/tests/__init__.py
@@ -1,6 +1,7 @@
 from .badges import *  # noqa: F401, F403
 from .bigbluebutton import *  # noqa: F401, F403
 from .events import *  # noqa: F401, F403
+from .exportcache import *  # noqa: F401, F403
 from .search import *  # noqa: F401, F403
 from .users import *  # noqa: F401, F403
 from .tags import *  # noqa: F401, F403
diff --git a/src/core/tests/exportcache.py b/src/core/tests/exportcache.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ea5bdb0693d51b752d94454340a5abbcb02955c
--- /dev/null
+++ b/src/core/tests/exportcache.py
@@ -0,0 +1,59 @@
+from datetime import timedelta
+
+from django.utils import timezone
+from django.utils.datetime_safe import datetime
+from django.utils.timezone import now
+from django.test import TestCase, override_settings
+
+from ..models.assemblies import Assembly
+from ..models.conference import Conference, ConferenceExportCache
+
+
+class ScheduleTests(TestCase):
+    def setUp(self):
+        self.conference = Conference(
+            slug='foo',
+            name='Foo Conference',
+            start=datetime(2021, 12, 1, 9, 0, 0, tzinfo=timezone.utc),
+            end=datetime(2021, 12, 31, 15, 00, 00, tzinfo=timezone.utc),
+        )
+        self.conference.save()
+
+        self.assembly = Assembly(conference=self.conference, slug='fnord', name='Fnord Assembly', is_official=True)
+        self.assembly.save()
+
+    @override_settings(SCHEDULES_SUPPORT_FILE_PROTOCOL=True)
+    def test_schedule_cache(self):
+        # there should be no cached items at all
+        self.assertFalse(ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).exists())
+
+        # create an event
+        e = self.conference.events.create(assembly=self.assembly, name='Fnord', is_public=True)
+        e.save()
+
+        # creating the event should not have created a cache entry
+        self.assertFalse(ConferenceExportCache.objects.filter(type=ConferenceExportCache.Type.SCHEDULE).exists())
+
+        # create one manually
+        c = ConferenceExportCache(conference=self.conference, type=ConferenceExportCache.Type.SCHEDULE, tag='')
+        c.save()
+
+        # check default data
+        self.assertTrue(c.needs_regeneration)
+        self.assertTrue(c.is_dirty)
+
+        # simulate successful generation
+        c.needs_regeneration = False
+        c.data = 'FNORD'
+        c.last_generated = now()
+        c.save()
+
+        # this should have reset the 'is_dirty' flag
+        self.assertFalse(c.is_dirty)
+
+        # update the event
+        e.last_update = c.last_generated + timedelta(seconds=1)  # ensure event's last_update is newer
+        e.save()
+
+        # now the flag should react to the event updated after 'last_generation'
+        self.assertTrue(c.is_dirty)
diff --git a/src/core/tests/schedules.py b/src/core/tests/schedules.py
index dd67f2df3e58b5ae728d957554503ddae6820ae9..df00ec67a71e6bdd7e99ff6f0599922981d65f5e 100644
--- a/src/core/tests/schedules.py
+++ b/src/core/tests/schedules.py
@@ -2,14 +2,13 @@ from datetime import datetime, timedelta, timezone
 import json
 import os
 
-from django.utils.timezone import now
 from django.test import TestCase, override_settings
 
 from ..models import Event
 from ..models.assemblies import Assembly
 from ..models.conference import Conference, ConferenceMember
 from ..models.users import PlatformUser
-from ..models.schedules import ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping, CachedSchedule
+from ..models.schedules import ScheduleSource, ScheduleSourceImport, ScheduleSourceMapping
 from ..schedules.base import BaseScheduleSupport, ScheduleTypeManager, \
     filter_additional_data, schedule_time_to_timedelta
 from ..schedules.schedulejson import ScheduleJSONSupport
@@ -254,42 +253,6 @@ class ScheduleTests(TestCase):
         self.assertEqual(r1.id, str(Event.objects.get(slug='minkorrekt').room_id))
         self.assertEqual(r2.id, str(Event.objects.get(slug='blubb').room_id))
 
-    @override_settings(SCHEDULES_SUPPORT_FILE_PROTOCOL=True)
-    def test_cache(self):
-        # there should be no cached schedules
-        self.assertFalse(CachedSchedule.objects.all().exists())
-
-        # create an event
-        e = self.conference.events.create(assembly=self.assembly, name='Fnord', is_public=True)
-        e.save()
-
-        # creating the event should not have created a cache entry
-        self.assertFalse(CachedSchedule.objects.all().exists())
-
-        # create one manually
-        c = CachedSchedule(conference=self.conference, tag='')
-        c.save()
-
-        # check default data
-        self.assertTrue(c.needs_regeneration)
-        self.assertTrue(c.is_dirty)
-
-        # simulate successful generation
-        c.needs_regeneration = False
-        c.data = 'FNORD'
-        c.last_generated = now()
-        c.save()
-
-        # this should have reset the 'is_dirty' flag
-        self.assertFalse(c.is_dirty)
-
-        # update the event
-        e.last_update = c.last_generated + timedelta(seconds=1)  # ensure event's last_update is newer
-        e.save()
-
-        # now the flag should react to the event updated after 'last_generation'
-        self.assertTrue(c.is_dirty)
-
     @override_settings(SCHEDULES_SUPPORT_FILE_PROTOCOL=True)
     def test_xml(self):
         src = ScheduleSource.objects.create(