diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 1aca5ae741838e1863a7249215003f205b9aa07a..35cc51d5b1a9ab8dcca4e6503d8c1818597d7081 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -19,6 +19,7 @@ Added
 * Allow to configure if additional field is required
 * Allow to configure description of additional fields
 * Allow configuring regex for allowed usernames
+* [Dev] Support scheduled notifications.
 * Implement StaticContentWidget
 
 Changed
diff --git a/aleksis/core/migrations/0038_notification_send_at.py b/aleksis/core/migrations/0038_notification_send_at.py
new file mode 100644
index 0000000000000000000000000000000000000000..edb96f1e87f143d60477a1578aea5d90226389a8
--- /dev/null
+++ b/aleksis/core/migrations/0038_notification_send_at.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.12 on 2022-02-23 19:33
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0037_add_static_content_widget'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='notification',
+            name='send_at',
+            field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Send notification at'),
+            preserve_default=False,
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 03ea0320f11220dc2c392188d1bf3b7807dfeffd..7c555b409c0aac4a8631df21e6aabadbadd26242 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -29,6 +29,7 @@ import customidenticon
 import jsonstore
 from cachalot.api import cachalot_disabled
 from cache_memoize import cache_memoize
+from celery.result import AsyncResult
 from ckeditor.fields import RichTextField
 from django_celery_results.models import TaskResult
 from django_cte import CTEQuerySet, With
@@ -740,6 +741,8 @@ class Notification(ExtensibleModel, TimeStampedModel):
     description = models.TextField(max_length=500, verbose_name=_("Description"))
     link = models.URLField(blank=True, verbose_name=_("Link"))
 
+    send_at = models.DateTimeField(default=timezone.now, verbose_name=_("Send notification at"))
+
     read = models.BooleanField(default=False, verbose_name=_("Read"))
     sent = models.BooleanField(default=False, verbose_name=_("Sent"))
 
@@ -748,10 +751,14 @@ class Notification(ExtensibleModel, TimeStampedModel):
 
     def save(self, **kwargs):
         super().save(**kwargs)
-        if not self.sent:
-            send_notification(self.pk, resend=True)
-        self.sent = True
-        super().save(**kwargs)
+        if not self.sent and self.send_at <= timezone.now():
+            self.send()
+            super().save(**kwargs)
+
+    def send(self, resend: bool = False) -> Optional[AsyncResult]:
+        """Send the notification to the recipient."""
+        if not self.sent or resend:
+            return send_notification.delay(self.pk, resend=True)
 
     class Meta:
         verbose_name = _("Notification")
diff --git a/aleksis/core/tasks.py b/aleksis/core/tasks.py
index 847fc41281a0266cff16bbe2117e326e13abc0dd..7b7e529b3a1fa21c5a6620170296d9ee1eb777ec 100644
--- a/aleksis/core/tasks.py
+++ b/aleksis/core/tasks.py
@@ -4,6 +4,7 @@ from django.conf import settings
 from django.core import management
 
 from .celery import app
+from .util.notifications import _send_due_notifications as _send_due_notifications
 from .util.notifications import send_notification as _send_notification
 
 
@@ -48,3 +49,9 @@ def clear_oauth_tokens():
     from oauth2_provider.models import clear_expired  # noqa
 
     return clear_expired()
+
+
+@app.task(run_every=timedelta(minutes=5))
+def send_notifications():
+    """Send due notifications to users."""
+    _send_due_notifications()
diff --git a/aleksis/core/tests/models/test_notification.py b/aleksis/core/tests/models/test_notification.py
index 1b1a6df054fe24f06bd363e825df3f1259af53e8..cdb01a04b99a398469ddfcd4466101c0b2d79f34 100644
--- a/aleksis/core/tests/models/test_notification.py
+++ b/aleksis/core/tests/models/test_notification.py
@@ -1,11 +1,81 @@
+from datetime import timedelta
+from time import sleep
+from unittest.mock import patch
+
+from django.test import override_settings
+from django.utils import timezone
+
 import pytest
+from freezegun import freeze_time
 
 from aleksis.core.models import Notification, Person
+from aleksis.core.util.notifications import _send_due_notifications
 
 pytestmark = pytest.mark.django_db
 
 
-def test_email_notification(mailoutbox):
+def test_send_notification():
+    email = "doe@example.com"
+    recipient = Person.objects.create(first_name="Jane", last_name="Doe", email=email)
+
+    sender = "Foo"
+    title = "There is happened something."
+    description = "Here you get some more information."
+    link = "https://aleksis.org/"
+
+    notification = Notification(
+        sender=sender,
+        recipient=recipient,
+        title=title,
+        description=description,
+        link=link,
+    )
+
+    with patch("aleksis.core.models.Notification.send") as patched_send:
+        patched_send.assert_not_called()
+
+        notification.save()
+
+        patched_send.assert_called()
+
+
+def test_send_scheduled_notification():
+    email = "doe@example.com"
+    recipient = Person.objects.create(first_name="Jane", last_name="Doe", email=email)
+
+    sender = "Foo"
+    title = "There is happened something."
+    description = "Here you get some more information."
+    link = "https://aleksis.org/"
+
+    notification = Notification(
+        sender=sender,
+        recipient=recipient,
+        title=title,
+        description=description,
+        link=link,
+        send_at=timezone.now() + timedelta(days=1),
+    )
+    notification.save()
+
+    with patch("aleksis.core.models.Notification.send") as patched_send:
+        patched_send.assert_not_called()
+
+        _send_due_notifications()
+
+        patched_send.assert_not_called()
+
+        with freeze_time(timezone.now() + timedelta(days=1)):
+            _send_due_notifications()
+
+        patched_send.assert_called()
+
+
+@override_settings(CELERY_BROKER_URL="memory://localhost//")
+@pytest.mark.django_db(
+    databases=["default", "default_oot"], serialized_rollback=True, transaction=True
+)
+def test_email_notification(mailoutbox, celery_worker):
     email = "doe@example.com"
     recipient = Person.objects.create(first_name="Jane", last_name="Doe", email=email)
 
@@ -19,6 +89,9 @@ def test_email_notification(mailoutbox):
     )
     notification.save()
 
+    sleep(3)
+
+    notification.refresh_from_db()
     assert notification.sent
 
     assert len(mailoutbox) == 1
diff --git a/aleksis/core/util/notifications.py b/aleksis/core/util/notifications.py
index 13887a197defb9e24f5a9b1538561287b80c942f..061b8d3d8fae61f68b1c3f2fa9e9f3f421491292 100644
--- a/aleksis/core/util/notifications.py
+++ b/aleksis/core/util/notifications.py
@@ -5,6 +5,7 @@ from typing import Sequence, Union
 from django.apps import apps
 from django.conf import settings
 from django.template.loader import get_template
+from django.utils import timezone
 from django.utils.functional import lazy
 from django.utils.translation import gettext_lazy as _
 
@@ -82,6 +83,8 @@ def send_notification(notification: Union[int, "Notification"], resend: bool = F
             name, check, send = _CHANNELS_MAP[channel]
             if check():
                 send(notification)
+                notification.sent = True
+                notification.save()
 
 
 def get_notification_choices() -> list:
@@ -99,3 +102,12 @@ def get_notification_choices() -> list:
 
 
 get_notification_choices_lazy = lazy(get_notification_choices, tuple)
+
+
+def _send_due_notifications():
+    """Send all notifications that are due to be sent."""
+    Notification = apps.get_model("core", "Notification")
+
+    due_notifications = Notification.objects.filter(sent=False, send_at__lte=timezone.now())
+    for notification in due_notifications:
+        notification.send()
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index 7be99d6a0e67aa552f53822f3b9fcbc4abf9e87b..00c95c47d139f28a6be7ca761580b96d98b9da40 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -216,7 +216,9 @@ def index(request: HttpRequest) -> HttpResponse:
         widgets = []
 
     activities = person.activities.all().order_by("-created")[:5]
-    notifications = person.notifications.all().order_by("-created")[:5]
+    notifications = person.notifications.filter(send_at__lte=timezone.now()).order_by("-created")[
+        :5
+    ]
     unread_notifications = person.notifications.all().filter(read=False).order_by("-created")
 
     context["activities"] = activities
@@ -246,7 +248,9 @@ class NotificationsListView(PermissionRequiredMixin, ListView):
     template_name = "core/notifications.html"
 
     def get_queryset(self) -> QuerySet:
-        return self.request.user.person.notifications.order_by("-created")
+        return self.request.user.person.notifications.filter(send_at__lte=timezone.now()).order_by(
+            "-created"
+        )
 
     def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
         self.get_queryset().filter(read=False).update(read=True)