diff --git a/backend/src/data/GuildEvents.ts b/backend/src/data/GuildEvents.ts
index 72c6177c..24f14858 100644
--- a/backend/src/data/GuildEvents.ts
+++ b/backend/src/data/GuildEvents.ts
@@ -1,11 +1,15 @@
 import { Mute } from "./entities/Mute";
 import { ScheduledPost } from "./entities/ScheduledPost";
 import { Reminder } from "./entities/Reminder";
+import { Tempban } from "./entities/Tempban";
+import { VCAlert } from "./entities/VCAlert";
 
 interface GuildEventArgs extends Record<string, unknown[]> {
-  expiredMutes: [Mute[]];
-  scheduledPosts: [ScheduledPost[]];
-  reminders: [Reminder[]];
+  expiredMute: [Mute];
+  scheduledPost: [ScheduledPost];
+  reminder: [Reminder];
+  expiredTempban: [Tempban];
+  expiredVCAlert: [VCAlert];
 }
 
 type GuildEvent = keyof GuildEventArgs;
@@ -52,3 +56,14 @@ export function emitGuildEvent<K extends GuildEvent>(guildId: string, eventName:
     listener(...args);
   }
 }
+
+export function hasGuildEventListener<K extends GuildEvent>(guildId: string, eventName: K): boolean {
+  if (!guildListeners.has(guildId)) {
+    return false;
+  }
+  const listenerMap = guildListeners.get(guildId)!;
+  if (listenerMap[eventName] == null || listenerMap[eventName]!.length === 0) {
+    return false;
+  }
+  return true;
+}
diff --git a/backend/src/data/GuildMutes.ts b/backend/src/data/GuildMutes.ts
index f95571a0..ac857a57 100644
--- a/backend/src/data/GuildMutes.ts
+++ b/backend/src/data/GuildMutes.ts
@@ -47,11 +47,11 @@ export class GuildMutes extends BaseGuildRepository {
     return (await this.mutes.findOne({ where: result.identifiers[0] }))!;
   }
 
-  async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) {
+  async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]): Promise<void> {
     const expiresAt = newExpiryTime ? moment.utc().add(newExpiryTime, "ms").format("YYYY-MM-DD HH:mm:ss") : null;
 
     if (rolesToRestore && rolesToRestore.length) {
-      return this.mutes.update(
+      await this.mutes.update(
         {
           guild_id: this.guildId,
           user_id: userId,
@@ -62,7 +62,7 @@ export class GuildMutes extends BaseGuildRepository {
         },
       );
     } else {
-      return this.mutes.update(
+      await this.mutes.update(
         {
           guild_id: this.guildId,
           user_id: userId,
diff --git a/backend/src/data/GuildReminders.ts b/backend/src/data/GuildReminders.ts
index b665569f..fac42c0f 100644
--- a/backend/src/data/GuildReminders.ts
+++ b/backend/src/data/GuildReminders.ts
@@ -27,6 +27,10 @@ export class GuildReminders extends BaseGuildRepository {
     });
   }
 
+  find(id: number) {
+    return this.reminders.findOne({ id });
+  }
+
   async delete(id) {
     await this.reminders.delete({
       guild_id: this.guildId,
@@ -35,7 +39,7 @@ export class GuildReminders extends BaseGuildRepository {
   }
 
   async add(userId: string, channelId: string, remindAt: string, body: string, created_at: string) {
-    await this.reminders.insert({
+    const result = await this.reminders.insert({
       guild_id: this.guildId,
       user_id: userId,
       channel_id: channelId,
@@ -43,5 +47,7 @@ export class GuildReminders extends BaseGuildRepository {
       body,
       created_at,
     });
+
+    return (await this.find(result.identifiers[0].id))!;
   }
 }
diff --git a/backend/src/data/GuildScheduledPosts.ts b/backend/src/data/GuildScheduledPosts.ts
index b2f32d54..3f10b712 100644
--- a/backend/src/data/GuildScheduledPosts.ts
+++ b/backend/src/data/GuildScheduledPosts.ts
@@ -22,6 +22,10 @@ export class GuildScheduledPosts extends BaseGuildRepository {
       .getMany();
   }
 
+  find(id: number) {
+    return this.scheduledPosts.findOne({ id });
+  }
+
   async delete(id) {
     await this.scheduledPosts.delete({
       guild_id: this.guildId,
@@ -30,10 +34,12 @@ export class GuildScheduledPosts extends BaseGuildRepository {
   }
 
   async create(data: Partial<ScheduledPost>) {
-    await this.scheduledPosts.insert({
+    const result = await this.scheduledPosts.insert({
       ...data,
       guild_id: this.guildId,
     });
+
+    return (await this.find(result.identifiers[0].id))!;
   }
 
   async update(id: number, data: Partial<ScheduledPost>) {
diff --git a/backend/src/data/GuildVCAlerts.ts b/backend/src/data/GuildVCAlerts.ts
index 4736204a..210a91c9 100644
--- a/backend/src/data/GuildVCAlerts.ts
+++ b/backend/src/data/GuildVCAlerts.ts
@@ -40,6 +40,10 @@ export class GuildVCAlerts extends BaseGuildRepository {
     });
   }
 
+  find(id: number) {
+    return this.allAlerts.findOne({ id });
+  }
+
   async delete(id) {
     await this.allAlerts.delete({
       guild_id: this.guildId,
@@ -48,7 +52,7 @@ export class GuildVCAlerts extends BaseGuildRepository {
   }
 
   async add(requestorId: string, userId: string, channelId: string, expiresAt: string, body: string, active: boolean) {
-    await this.allAlerts.insert({
+    const result = await this.allAlerts.insert({
       guild_id: this.guildId,
       requestor_id: requestorId,
       user_id: userId,
@@ -57,5 +61,7 @@ export class GuildVCAlerts extends BaseGuildRepository {
       body,
       active,
     });
+
+    return (await this.find(result.identifiers[0].id))!;
   }
 }
diff --git a/backend/src/data/Mutes.ts b/backend/src/data/Mutes.ts
new file mode 100644
index 00000000..06674717
--- /dev/null
+++ b/backend/src/data/Mutes.ts
@@ -0,0 +1,23 @@
+import moment from "moment-timezone";
+import { Brackets, getRepository, Repository } from "typeorm";
+import { Mute } from "./entities/Mute";
+import { DBDateFormat } from "../utils";
+import { BaseRepository } from "./BaseRepository";
+
+export class Mutes extends BaseRepository {
+  private mutes: Repository<Mute>;
+
+  constructor() {
+    super();
+    this.mutes = getRepository(Mute);
+  }
+
+  async getSoonExpiringMutes(threshold: number): Promise<Mute[]> {
+    const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
+    return this.mutes
+      .createQueryBuilder("mutes")
+      .andWhere("expires_at IS NOT NULL")
+      .andWhere("expires_at <= :date", { date: thresholdDateStr })
+      .getMany();
+  }
+}
diff --git a/backend/src/data/Reminders.ts b/backend/src/data/Reminders.ts
new file mode 100644
index 00000000..7f8d2f1a
--- /dev/null
+++ b/backend/src/data/Reminders.ts
@@ -0,0 +1,19 @@
+import { getRepository, Repository } from "typeorm";
+import { Reminder } from "./entities/Reminder";
+import { BaseRepository } from "./BaseRepository";
+import moment from "moment-timezone";
+import { DBDateFormat } from "../utils";
+
+export class Reminders extends BaseRepository {
+  private reminders: Repository<Reminder>;
+
+  constructor() {
+    super();
+    this.reminders = getRepository(Reminder);
+  }
+
+  async getRemindersDueSoon(threshold: number): Promise<Reminder[]> {
+    const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
+    return this.reminders.createQueryBuilder().andWhere("remind_at <= :date", { date: thresholdDateStr }).getMany();
+  }
+}
diff --git a/backend/src/data/ScheduledPosts.ts b/backend/src/data/ScheduledPosts.ts
new file mode 100644
index 00000000..72f1dda4
--- /dev/null
+++ b/backend/src/data/ScheduledPosts.ts
@@ -0,0 +1,19 @@
+import { getRepository, Repository } from "typeorm";
+import { ScheduledPost } from "./entities/ScheduledPost";
+import { BaseRepository } from "./BaseRepository";
+import moment from "moment-timezone";
+import { DBDateFormat } from "../utils";
+
+export class ScheduledPosts extends BaseRepository {
+  private scheduledPosts: Repository<ScheduledPost>;
+
+  constructor() {
+    super();
+    this.scheduledPosts = getRepository(ScheduledPost);
+  }
+
+  getScheduledPostsDueSoon(threshold: number): Promise<ScheduledPost[]> {
+    const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
+    return this.scheduledPosts.createQueryBuilder().andWhere("post_at <= :date", { date: thresholdDateStr }).getMany();
+  }
+}
diff --git a/backend/src/data/Tempbans.ts b/backend/src/data/Tempbans.ts
new file mode 100644
index 00000000..5a78c30e
--- /dev/null
+++ b/backend/src/data/Tempbans.ts
@@ -0,0 +1,19 @@
+import moment from "moment-timezone";
+import { getRepository, Repository } from "typeorm";
+import { Tempban } from "./entities/Tempban";
+import { BaseRepository } from "./BaseRepository";
+import { DBDateFormat } from "../utils";
+
+export class Tempbans extends BaseRepository {
+  private tempbans: Repository<Tempban>;
+
+  constructor() {
+    super();
+    this.tempbans = getRepository(Tempban);
+  }
+
+  getSoonExpiringTempbans(threshold: number): Promise<Tempban[]> {
+    const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
+    return this.tempbans.createQueryBuilder().where("expires_at <= :date", { date: thresholdDateStr }).getMany();
+  }
+}
diff --git a/backend/src/data/VCAlerts.ts b/backend/src/data/VCAlerts.ts
new file mode 100644
index 00000000..27e85f90
--- /dev/null
+++ b/backend/src/data/VCAlerts.ts
@@ -0,0 +1,19 @@
+import { getRepository, Repository } from "typeorm";
+import { VCAlert } from "./entities/VCAlert";
+import { BaseRepository } from "./BaseRepository";
+import moment from "moment-timezone";
+import { DBDateFormat } from "../utils";
+
+export class VCAlerts extends BaseRepository {
+  private allAlerts: Repository<VCAlert>;
+
+  constructor() {
+    super();
+    this.allAlerts = getRepository(VCAlert);
+  }
+
+  async getSoonExpiringAlerts(threshold: number): Promise<VCAlert[]> {
+    const thresholdDateStr = moment.utc().add(threshold, "ms").format(DBDateFormat);
+    return this.allAlerts.createQueryBuilder().andWhere("expires_at <= :date", { date: thresholdDateStr }).getMany();
+  }
+}
diff --git a/backend/src/data/entities/Reminder.ts b/backend/src/data/entities/Reminder.ts
index 344b3090..e7ce506b 100644
--- a/backend/src/data/entities/Reminder.ts
+++ b/backend/src/data/entities/Reminder.ts
@@ -1,9 +1,8 @@
-import { Column, Entity, PrimaryColumn } from "typeorm";
+import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm";
 
 @Entity("reminders")
 export class Reminder {
-  @Column()
-  @PrimaryColumn()
+  @PrimaryGeneratedColumn()
   id: number;
 
   @Column() guild_id: string;
diff --git a/backend/src/data/entities/ScheduledPost.ts b/backend/src/data/entities/ScheduledPost.ts
index 8b7d4088..1729bca7 100644
--- a/backend/src/data/entities/ScheduledPost.ts
+++ b/backend/src/data/entities/ScheduledPost.ts
@@ -1,11 +1,10 @@
 import { MessageAttachment } from "discord.js";
-import { Column, Entity, PrimaryColumn } from "typeorm";
+import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm";
 import { StrictMessageContent } from "../../utils";
 
 @Entity("scheduled_posts")
 export class ScheduledPost {
-  @Column()
-  @PrimaryColumn()
+  @PrimaryGeneratedColumn()
   id: number;
 
   @Column() guild_id: string;
diff --git a/backend/src/data/entities/VCAlert.ts b/backend/src/data/entities/VCAlert.ts
index 81b6bc81..5f0011d7 100644
--- a/backend/src/data/entities/VCAlert.ts
+++ b/backend/src/data/entities/VCAlert.ts
@@ -1,9 +1,8 @@
-import { Column, Entity, PrimaryColumn } from "typeorm";
+import { Column, Entity, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm";
 
 @Entity("vc_alerts")
 export class VCAlert {
-  @Column()
-  @PrimaryColumn()
+  @PrimaryGeneratedColumn()
   id: number;
 
   @Column() guild_id: string;
diff --git a/backend/src/data/loops/expiringMutesLoop.ts b/backend/src/data/loops/expiringMutesLoop.ts
new file mode 100644
index 00000000..a3b5b7e8
--- /dev/null
+++ b/backend/src/data/loops/expiringMutesLoop.ts
@@ -0,0 +1,72 @@
+import { lazyMemoize, memoize, MINUTES } from "../../utils";
+import { Mutes } from "../Mutes";
+import Timeout = NodeJS.Timeout;
+import moment from "moment-timezone";
+import { Mute } from "../entities/Mute";
+import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents";
+
+const LOOP_INTERVAL = 15 * MINUTES;
+const MAX_TRIES_PER_SERVER = 3;
+const getMutesRepository = lazyMemoize(() => new Mutes());
+const timeouts = new Map<string, Timeout>();
+
+function muteToKey(mute: Mute) {
+  return `${mute.guild_id}/${mute.user_id}`;
+}
+
+function broadcastExpiredMute(mute: Mute, tries = 0) {
+  console.log(`[EXPIRING MUTES LOOP] Broadcasting expired mute: ${mute.guild_id}/${mute.user_id}`);
+  if (!hasGuildEventListener(mute.guild_id, "expiredMute")) {
+    // If there are no listeners registered for the server yet, try again in a bit
+    if (tries < MAX_TRIES_PER_SERVER) {
+      timeouts.set(
+        muteToKey(mute),
+        setTimeout(() => broadcastExpiredMute(mute, tries + 1), 1 * MINUTES),
+      );
+    }
+    return;
+  }
+  emitGuildEvent(mute.guild_id, "expiredMute", [mute]);
+}
+
+export async function runExpiringMutesLoop() {
+  console.log("[EXPIRING MUTES LOOP] Clearing old timeouts");
+  for (const timeout of timeouts.values()) {
+    clearTimeout(timeout);
+  }
+
+  console.log("[EXPIRING MUTES LOOP] Setting timeouts for expiring mutes");
+  const expiringMutes = await getMutesRepository().getSoonExpiringMutes(LOOP_INTERVAL);
+  for (const mute of expiringMutes) {
+    const remaining = Math.max(0, moment.utc(mute.expires_at!).diff(moment.utc()));
+    timeouts.set(
+      muteToKey(mute),
+      setTimeout(() => broadcastExpiredMute(mute), remaining),
+    );
+  }
+
+  console.log("[EXPIRING MUTES LOOP] Scheduling next loop");
+  setTimeout(() => runExpiringMutesLoop(), LOOP_INTERVAL);
+}
+
+export function registerExpiringMute(mute: Mute) {
+  clearExpiringMute(mute);
+
+  console.log("[EXPIRING MUTES LOOP] Registering new expiring mute");
+  const remaining = Math.max(0, moment.utc(mute.expires_at!).diff(moment.utc()));
+  if (remaining > LOOP_INTERVAL) {
+    return;
+  }
+
+  timeouts.set(
+    muteToKey(mute),
+    setTimeout(() => broadcastExpiredMute(mute), remaining),
+  );
+}
+
+export function clearExpiringMute(mute: Mute) {
+  console.log("[EXPIRING MUTES LOOP] Clearing expiring mute");
+  if (timeouts.has(muteToKey(mute))) {
+    clearTimeout(timeouts.get(muteToKey(mute))!);
+  }
+}
diff --git a/backend/src/data/loops/expiringTempbansLoop.ts b/backend/src/data/loops/expiringTempbansLoop.ts
new file mode 100644
index 00000000..a28f91e6
--- /dev/null
+++ b/backend/src/data/loops/expiringTempbansLoop.ts
@@ -0,0 +1,72 @@
+import { lazyMemoize, MINUTES } from "../../utils";
+import moment from "moment-timezone";
+import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents";
+import { Tempbans } from "../Tempbans";
+import { Tempban } from "../entities/Tempban";
+import Timeout = NodeJS.Timeout;
+
+const LOOP_INTERVAL = 15 * MINUTES;
+const MAX_TRIES_PER_SERVER = 3;
+const getBansRepository = lazyMemoize(() => new Tempbans());
+const timeouts = new Map<string, Timeout>();
+
+function tempbanToKey(tempban: Tempban) {
+  return `${tempban.guild_id}/${tempban.user_id}`;
+}
+
+function broadcastExpiredTempban(tempban: Tempban, tries = 0) {
+  console.log(`[EXPIRING TEMPBANS LOOP] Broadcasting expired tempban: ${tempban.guild_id}/${tempban.user_id}`);
+  if (!hasGuildEventListener(tempban.guild_id, "expiredTempban")) {
+    // If there are no listeners registered for the server yet, try again in a bit
+    if (tries < MAX_TRIES_PER_SERVER) {
+      timeouts.set(
+        tempbanToKey(tempban),
+        setTimeout(() => broadcastExpiredTempban(tempban, tries + 1), 1 * MINUTES),
+      );
+    }
+    return;
+  }
+  emitGuildEvent(tempban.guild_id, "expiredTempban", [tempban]);
+}
+
+export async function runExpiringTempbansLoop() {
+  console.log("[EXPIRING TEMPBANS LOOP] Clearing old timeouts");
+  for (const timeout of timeouts.values()) {
+    clearTimeout(timeout);
+  }
+
+  console.log("[EXPIRING TEMPBANS LOOP] Setting timeouts for expiring tempbans");
+  const expiringTempbans = await getBansRepository().getSoonExpiringTempbans(LOOP_INTERVAL);
+  for (const tempban of expiringTempbans) {
+    const remaining = Math.max(0, moment.utc(tempban.expires_at!).diff(moment.utc()));
+    timeouts.set(
+      tempbanToKey(tempban),
+      setTimeout(() => broadcastExpiredTempban(tempban), remaining),
+    );
+  }
+
+  console.log("[EXPIRING TEMPBANS LOOP] Scheduling next loop");
+  setTimeout(() => runExpiringTempbansLoop(), LOOP_INTERVAL);
+}
+
+export function registerExpiringTempban(tempban: Tempban) {
+  clearExpiringTempban(tempban);
+
+  console.log("[EXPIRING TEMPBANS LOOP] Registering new expiring tempban");
+  const remaining = Math.max(0, moment.utc(tempban.expires_at!).diff(moment.utc()));
+  if (remaining > LOOP_INTERVAL) {
+    return;
+  }
+
+  timeouts.set(
+    tempbanToKey(tempban),
+    setTimeout(() => broadcastExpiredTempban(tempban), remaining),
+  );
+}
+
+export function clearExpiringTempban(tempban: Tempban) {
+  console.log("[EXPIRING TEMPBANS LOOP] Clearing expiring tempban");
+  if (timeouts.has(tempbanToKey(tempban))) {
+    clearTimeout(timeouts.get(tempbanToKey(tempban))!);
+  }
+}
diff --git a/backend/src/data/loops/expiringVCAlertsLoop.ts b/backend/src/data/loops/expiringVCAlertsLoop.ts
new file mode 100644
index 00000000..b987d71a
--- /dev/null
+++ b/backend/src/data/loops/expiringVCAlertsLoop.ts
@@ -0,0 +1,68 @@
+import { lazyMemoize, MINUTES } from "../../utils";
+import moment from "moment-timezone";
+import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents";
+import Timeout = NodeJS.Timeout;
+import { VCAlerts } from "../VCAlerts";
+import { VCAlert } from "../entities/VCAlert";
+
+const LOOP_INTERVAL = 15 * MINUTES;
+const MAX_TRIES_PER_SERVER = 3;
+const getVCAlertsRepository = lazyMemoize(() => new VCAlerts());
+const timeouts = new Map<number, Timeout>();
+
+function broadcastExpiredVCAlert(alert: VCAlert, tries = 0) {
+  console.log(`[EXPIRING VCALERTS LOOP] Broadcasting expired vcalert: ${alert.guild_id}/${alert.user_id}`);
+  if (!hasGuildEventListener(alert.guild_id, "expiredVCAlert")) {
+    // If there are no listeners registered for the server yet, try again in a bit
+    if (tries < MAX_TRIES_PER_SERVER) {
+      timeouts.set(
+        alert.id,
+        setTimeout(() => broadcastExpiredVCAlert(alert, tries + 1), 1 * MINUTES),
+      );
+    }
+    return;
+  }
+  emitGuildEvent(alert.guild_id, "expiredVCAlert", [alert]);
+}
+
+export async function runExpiringVCAlertsLoop() {
+  console.log("[EXPIRING VCALERTS LOOP] Clearing old timeouts");
+  for (const timeout of timeouts.values()) {
+    clearTimeout(timeout);
+  }
+
+  console.log("[EXPIRING VCALERTS LOOP] Setting timeouts for expiring vcalerts");
+  const expiringVCAlerts = await getVCAlertsRepository().getSoonExpiringAlerts(LOOP_INTERVAL);
+  for (const alert of expiringVCAlerts) {
+    const remaining = Math.max(0, moment.utc(alert.expires_at!).diff(moment.utc()));
+    timeouts.set(
+      alert.id,
+      setTimeout(() => broadcastExpiredVCAlert(alert), remaining),
+    );
+  }
+
+  console.log("[EXPIRING VCALERTS LOOP] Scheduling next loop");
+  setTimeout(() => runExpiringVCAlertsLoop(), LOOP_INTERVAL);
+}
+
+export function registerExpiringVCAlert(alert: VCAlert) {
+  clearExpiringVCAlert(alert);
+
+  console.log("[EXPIRING VCALERTS LOOP] Registering new expiring vcalert");
+  const remaining = Math.max(0, moment.utc(alert.expires_at!).diff(moment.utc()));
+  if (remaining > LOOP_INTERVAL) {
+    return;
+  }
+
+  timeouts.set(
+    alert.id,
+    setTimeout(() => broadcastExpiredVCAlert(alert), remaining),
+  );
+}
+
+export function clearExpiringVCAlert(alert: VCAlert) {
+  console.log("[EXPIRING VCALERTS LOOP] Clearing expiring vcalert");
+  if (timeouts.has(alert.id)) {
+    clearTimeout(timeouts.get(alert.id)!);
+  }
+}
diff --git a/backend/src/data/loops/upcomingRemindersLoop.ts b/backend/src/data/loops/upcomingRemindersLoop.ts
new file mode 100644
index 00000000..1f34e99f
--- /dev/null
+++ b/backend/src/data/loops/upcomingRemindersLoop.ts
@@ -0,0 +1,67 @@
+import { lazyMemoize, MINUTES } from "../../utils";
+import moment from "moment-timezone";
+import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents";
+import { Reminder } from "../entities/Reminder";
+import { Reminders } from "../Reminders";
+import Timeout = NodeJS.Timeout;
+
+const LOOP_INTERVAL = 15 * MINUTES;
+const MAX_TRIES_PER_SERVER = 3;
+const getRemindersRepository = lazyMemoize(() => new Reminders());
+const timeouts = new Map<number, Timeout>();
+
+function broadcastReminder(reminder: Reminder, tries = 0) {
+  if (!hasGuildEventListener(reminder.guild_id, "reminder")) {
+    // If there are no listeners registered for the server yet, try again in a bit
+    if (tries < MAX_TRIES_PER_SERVER) {
+      timeouts.set(
+        reminder.id,
+        setTimeout(() => broadcastReminder(reminder, tries + 1), 1 * MINUTES),
+      );
+    }
+    return;
+  }
+  emitGuildEvent(reminder.guild_id, "reminder", [reminder]);
+}
+
+export async function runUpcomingRemindersLoop() {
+  console.log("[REMINDERS LOOP] Clearing old timeouts");
+  for (const timeout of timeouts.values()) {
+    clearTimeout(timeout);
+  }
+
+  console.log("[REMINDERS LOOP] Setting timeouts for upcoming reminders");
+  const remindersDueSoon = await getRemindersRepository().getRemindersDueSoon(LOOP_INTERVAL);
+  for (const reminder of remindersDueSoon) {
+    const remaining = Math.max(0, moment.utc(reminder.remind_at!).diff(moment.utc()));
+    timeouts.set(
+      reminder.id,
+      setTimeout(() => broadcastReminder(reminder), remaining),
+    );
+  }
+
+  console.log("[REMINDERS LOOP] Scheduling next loop");
+  setTimeout(() => runUpcomingRemindersLoop(), LOOP_INTERVAL);
+}
+
+export function registerUpcomingReminder(reminder: Reminder) {
+  clearUpcomingReminder(reminder);
+
+  console.log("[REMINDERS LOOP] Registering new upcoming reminder");
+  const remaining = Math.max(0, moment.utc(reminder.remind_at!).diff(moment.utc()));
+  if (remaining > LOOP_INTERVAL) {
+    return;
+  }
+
+  timeouts.set(
+    reminder.id,
+    setTimeout(() => broadcastReminder(reminder), remaining),
+  );
+}
+
+export function clearUpcomingReminder(reminder: Reminder) {
+  console.log("[REMINDERS LOOP] Clearing upcoming reminder");
+  if (timeouts.has(reminder.id)) {
+    clearTimeout(timeouts.get(reminder.id)!);
+  }
+}
diff --git a/backend/src/data/loops/upcomingScheduledPostsLoop.ts b/backend/src/data/loops/upcomingScheduledPostsLoop.ts
new file mode 100644
index 00000000..51da1bae
--- /dev/null
+++ b/backend/src/data/loops/upcomingScheduledPostsLoop.ts
@@ -0,0 +1,67 @@
+import { lazyMemoize, MINUTES } from "../../utils";
+import moment from "moment-timezone";
+import { emitGuildEvent, hasGuildEventListener } from "../GuildEvents";
+import { ScheduledPosts } from "../ScheduledPosts";
+import { ScheduledPost } from "../entities/ScheduledPost";
+import Timeout = NodeJS.Timeout;
+
+const LOOP_INTERVAL = 15 * MINUTES;
+const MAX_TRIES_PER_SERVER = 3;
+const getScheduledPostsRepository = lazyMemoize(() => new ScheduledPosts());
+const timeouts = new Map<number, Timeout>();
+
+function broadcastScheduledPost(post: ScheduledPost, tries = 0) {
+  if (!hasGuildEventListener(post.guild_id, "scheduledPost")) {
+    // If there are no listeners registered for the server yet, try again in a bit
+    if (tries < MAX_TRIES_PER_SERVER) {
+      timeouts.set(
+        post.id,
+        setTimeout(() => broadcastScheduledPost(post, tries + 1), 1 * MINUTES),
+      );
+    }
+    return;
+  }
+  emitGuildEvent(post.guild_id, "scheduledPost", [post]);
+}
+
+export async function runUpcomingScheduledPostsLoop() {
+  console.log("[SCHEDULED POSTS LOOP] Clearing old timeouts");
+  for (const timeout of timeouts.values()) {
+    clearTimeout(timeout);
+  }
+
+  console.log("[SCHEDULED POSTS LOOP] Setting timeouts for upcoming scheduled posts");
+  const postsDueSoon = await getScheduledPostsRepository().getScheduledPostsDueSoon(LOOP_INTERVAL);
+  for (const post of postsDueSoon) {
+    const remaining = Math.max(0, moment.utc(post.post_at!).diff(moment.utc()));
+    timeouts.set(
+      post.id,
+      setTimeout(() => broadcastScheduledPost(post), remaining),
+    );
+  }
+
+  console.log("[SCHEDULED POSTS LOOP] Scheduling next loop");
+  setTimeout(() => runUpcomingScheduledPostsLoop(), LOOP_INTERVAL);
+}
+
+export function registerUpcomingScheduledPost(post: ScheduledPost) {
+  clearUpcomingScheduledPost(post);
+
+  console.log("[SCHEDULED POSTS LOOP] Registering new upcoming scheduled post");
+  const remaining = Math.max(0, moment.utc(post.post_at!).diff(moment.utc()));
+  if (remaining > LOOP_INTERVAL) {
+    return;
+  }
+
+  timeouts.set(
+    post.id,
+    setTimeout(() => broadcastScheduledPost(post), remaining),
+  );
+}
+
+export function clearUpcomingScheduledPost(post: ScheduledPost) {
+  console.log("[SCHEDULED POSTS LOOP] Clearing upcoming scheduled post");
+  if (timeouts.has(post.id)) {
+    clearTimeout(timeouts.get(post.id)!);
+  }
+}
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 6b552733..76ec4c1c 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -23,6 +23,11 @@ import { DecayingCounter } from "./utils/DecayingCounter";
 import { PluginNotLoadedError } from "knub/dist/plugins/PluginNotLoadedError";
 import { logRestCall } from "./restCallStats";
 import { logRateLimit } from "./rateLimitStats";
+import { runExpiringMutesLoop } from "./data/loops/expiringMutesLoop";
+import { runUpcomingRemindersLoop } from "./data/loops/upcomingRemindersLoop";
+import { runUpcomingScheduledPostsLoop } from "./data/loops/upcomingScheduledPostsLoop";
+import { runExpiringTempbansLoop } from "./data/loops/expiringTempbansLoop";
+import { runExpiringVCAlertsLoop } from "./data/loops/expiringVCAlertsLoop";
 
 if (!process.env.KEY) {
   // tslint:disable-next-line:no-console
@@ -322,6 +327,14 @@ connect().then(async () => {
     logRateLimit(data);
   });
 
+  bot.on("loadingFinished", () => {
+    runExpiringMutesLoop();
+    runExpiringTempbansLoop();
+    runExpiringVCAlertsLoop();
+    runUpcomingRemindersLoop();
+    runUpcomingScheduledPostsLoop();
+  });
+
   bot.initialize();
   logger.info("Bot Initialized");
   logger.info("Logging in...");
diff --git a/backend/src/migrations/1632582078622-SplitScheduledPostsPostAtIndex.ts b/backend/src/migrations/1632582078622-SplitScheduledPostsPostAtIndex.ts
new file mode 100644
index 00000000..16bbabb2
--- /dev/null
+++ b/backend/src/migrations/1632582078622-SplitScheduledPostsPostAtIndex.ts
@@ -0,0 +1,30 @@
+import { MigrationInterface, QueryRunner, TableIndex } from "typeorm";
+
+export class SplitScheduledPostsPostAtIndex1632582078622 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.dropIndex("scheduled_posts", "IDX_c383ecfbddd8b625a0912ded3e");
+    await queryRunner.createIndex(
+      "scheduled_posts",
+      new TableIndex({
+        columnNames: ["guild_id"],
+      }),
+    );
+    await queryRunner.createIndex(
+      "scheduled_posts",
+      new TableIndex({
+        columnNames: ["post_at"],
+      }),
+    );
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.dropIndex("scheduled_posts", "IDX_e3ce9a618354f29256712abc5c");
+    await queryRunner.dropIndex("scheduled_posts", "IDX_b30f532b58ec5caf116389486f");
+    await queryRunner.createIndex(
+      "scheduled_posts",
+      new TableIndex({
+        columnNames: ["guild_id", "post_at"],
+      }),
+    );
+  }
+}
diff --git a/backend/src/migrations/1632582299400-AddIndexToRemindersRemindAt.ts b/backend/src/migrations/1632582299400-AddIndexToRemindersRemindAt.ts
new file mode 100644
index 00000000..31f52869
--- /dev/null
+++ b/backend/src/migrations/1632582299400-AddIndexToRemindersRemindAt.ts
@@ -0,0 +1,16 @@
+import { MigrationInterface, QueryRunner, TableIndex } from "typeorm";
+
+export class AddIndexToRemindersRemindAt1632582299400 implements MigrationInterface {
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.createIndex(
+      "reminders",
+      new TableIndex({
+        columnNames: ["remind_at"],
+      }),
+    );
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.dropIndex("reminders", "IDX_6f4e1a9db3410c43c7545ff060");
+  }
+}
diff --git a/backend/src/plugins/LocateUser/LocateUserPlugin.ts b/backend/src/plugins/LocateUser/LocateUserPlugin.ts
index fc77d99a..506380ff 100644
--- a/backend/src/plugins/LocateUser/LocateUserPlugin.ts
+++ b/backend/src/plugins/LocateUser/LocateUserPlugin.ts
@@ -9,8 +9,8 @@ import { GuildBanRemoveAlertsEvt } from "./events/BanRemoveAlertsEvt";
 import { VoiceStateUpdateAlertEvt } from "./events/SendAlertsEvts";
 import { ConfigSchema, LocateUserPluginType } from "./types";
 import { fillActiveAlertsList } from "./utils/fillAlertsList";
-import { outdatedAlertsLoop } from "./utils/outdatedLoop";
-import Timeout = NodeJS.Timeout;
+import { onGuildEvent } from "../../data/GuildEvents";
+import { clearExpiredAlert } from "./utils/clearExpiredAlert";
 
 const defaultOptions: PluginOptions<LocateUserPluginType> = {
   config: {
@@ -61,18 +61,17 @@ export const LocateUserPlugin = zeppelinGuildPlugin<LocateUserPluginType>()({
     const { state, guild } = pluginData;
 
     state.alerts = GuildVCAlerts.getGuildInstance(guild.id);
-    state.outdatedAlertsTimeout = null;
     state.usersWithAlerts = [];
-    state.unloaded = false;
   },
 
   afterLoad(pluginData) {
-    outdatedAlertsLoop(pluginData);
+    pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "expiredVCAlert", (alert) =>
+      clearExpiredAlert(pluginData, alert),
+    );
     fillActiveAlertsList(pluginData);
   },
 
   beforeUnload(pluginData) {
-    clearTimeout(pluginData.state.outdatedAlertsTimeout as Timeout);
-    pluginData.state.unloaded = true;
+    pluginData.state.unregisterGuildEventListener();
   },
 });
diff --git a/backend/src/plugins/LocateUser/commands/FollowCmd.ts b/backend/src/plugins/LocateUser/commands/FollowCmd.ts
index 5a8d7e5d..82f84e50 100644
--- a/backend/src/plugins/LocateUser/commands/FollowCmd.ts
+++ b/backend/src/plugins/LocateUser/commands/FollowCmd.ts
@@ -4,6 +4,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
 import { MINUTES, SECONDS } from "../../../utils";
 import { locateUserCmd } from "../types";
+import { registerExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop";
 
 export const FollowCmd = locateUserCmd({
   trigger: ["follow", "f"],
@@ -30,7 +31,7 @@ export const FollowCmd = locateUserCmd({
       return;
     }
 
-    await pluginData.state.alerts.add(
+    const alert = await pluginData.state.alerts.add(
       msg.author.id,
       args.member.id,
       msg.channel.id,
@@ -38,6 +39,8 @@ export const FollowCmd = locateUserCmd({
       body,
       active,
     );
+    registerExpiringVCAlert(alert);
+
     if (!pluginData.state.usersWithAlerts.includes(args.member.id)) {
       pluginData.state.usersWithAlerts.push(args.member.id);
     }
diff --git a/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts b/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts
index f8e669bc..5db75917 100644
--- a/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts
+++ b/backend/src/plugins/LocateUser/commands/ListFollowCmd.ts
@@ -2,6 +2,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
 import { createChunkedMessage, sorter } from "../../../utils";
 import { locateUserCmd } from "../types";
+import { clearExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop";
 
 export const ListFollowCmd = locateUserCmd({
   trigger: ["follows", "fs"],
@@ -50,6 +51,7 @@ export const DeleteFollowCmd = locateUserCmd({
     }
 
     const toDelete = alerts[args.num - 1];
+    clearExpiringVCAlert(toDelete);
     await pluginData.state.alerts.delete(toDelete.id);
 
     sendSuccessMessage(pluginData, msg.channel, "Alert deleted");
diff --git a/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts b/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts
index c93ad872..42b71312 100644
--- a/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts
+++ b/backend/src/plugins/LocateUser/events/BanRemoveAlertsEvt.ts
@@ -1,4 +1,5 @@
 import { locateUserEvt } from "../types";
+import { clearExpiringVCAlert } from "../../../data/loops/expiringVCAlertsLoop";
 
 export const GuildBanRemoveAlertsEvt = locateUserEvt({
   event: "guildBanAdd",
@@ -6,6 +7,7 @@ export const GuildBanRemoveAlertsEvt = locateUserEvt({
   async listener(meta) {
     const alerts = await meta.pluginData.state.alerts.getAlertsByUserId(meta.args.ban.user.id);
     alerts.forEach((alert) => {
+      clearExpiringVCAlert(alert);
       meta.pluginData.state.alerts.delete(alert.id);
     });
   },
diff --git a/backend/src/plugins/LocateUser/types.ts b/backend/src/plugins/LocateUser/types.ts
index 5b4f4752..51f3a34d 100644
--- a/backend/src/plugins/LocateUser/types.ts
+++ b/backend/src/plugins/LocateUser/types.ts
@@ -13,9 +13,8 @@ export interface LocateUserPluginType extends BasePluginType {
   config: TConfigSchema;
   state: {
     alerts: GuildVCAlerts;
-    outdatedAlertsTimeout: Timeout | null;
     usersWithAlerts: string[];
-    unloaded: boolean;
+    unregisterGuildEventListener: () => void;
   };
 }
 
diff --git a/backend/src/plugins/LocateUser/utils/clearExpiredAlert.ts b/backend/src/plugins/LocateUser/utils/clearExpiredAlert.ts
new file mode 100644
index 00000000..5ee86836
--- /dev/null
+++ b/backend/src/plugins/LocateUser/utils/clearExpiredAlert.ts
@@ -0,0 +1,9 @@
+import { GuildPluginData } from "knub";
+import { LocateUserPluginType } from "../types";
+import { removeUserIdFromActiveAlerts } from "./removeUserIdFromActiveAlerts";
+import { VCAlert } from "../../../data/entities/VCAlert";
+
+export async function clearExpiredAlert(pluginData: GuildPluginData<LocateUserPluginType>, alert: VCAlert) {
+  await pluginData.state.alerts.delete(alert.id);
+  await removeUserIdFromActiveAlerts(pluginData, alert.user_id);
+}
diff --git a/backend/src/plugins/LocateUser/utils/outdatedLoop.ts b/backend/src/plugins/LocateUser/utils/outdatedLoop.ts
deleted file mode 100644
index 4236342d..00000000
--- a/backend/src/plugins/LocateUser/utils/outdatedLoop.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { GuildPluginData } from "knub";
-import { SECONDS } from "../../../utils";
-import { LocateUserPluginType } from "../types";
-import { removeUserIdFromActiveAlerts } from "./removeUserIdFromActiveAlerts";
-
-const ALERT_LOOP_TIME = 30 * SECONDS;
-
-export async function outdatedAlertsLoop(pluginData: GuildPluginData<LocateUserPluginType>) {
-  const outdatedAlerts = await pluginData.state.alerts.getOutdatedAlerts();
-
-  for (const alert of outdatedAlerts) {
-    await pluginData.state.alerts.delete(alert.id);
-    await removeUserIdFromActiveAlerts(pluginData, alert.user_id);
-  }
-
-  if (!pluginData.state.unloaded) {
-    pluginData.state.outdatedAlertsTimeout = setTimeout(() => outdatedAlertsLoop(pluginData), ALERT_LOOP_TIME);
-  }
-}
diff --git a/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts b/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts
index ee4ffd1e..52aa83ca 100644
--- a/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts
+++ b/backend/src/plugins/Logs/logFunctions/logPostedScheduledMessage.ts
@@ -3,12 +3,12 @@ import { LogsPluginType } from "../types";
 import { LogType } from "../../../data/LogType";
 import { log } from "../util/log";
 import { createTypedTemplateSafeValueContainer } from "../../../templateFormatter";
-import { BaseGuildTextChannel, User } from "discord.js";
+import { BaseGuildTextChannel, ThreadChannel, User } from "discord.js";
 import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 
 interface LogPostedScheduledMessageData {
   author: User;
-  channel: BaseGuildTextChannel;
+  channel: BaseGuildTextChannel | ThreadChannel;
   messageId: string;
 }
 
diff --git a/backend/src/plugins/ModActions/ModActionsPlugin.ts b/backend/src/plugins/ModActions/ModActionsPlugin.ts
index 452cabf6..60dc3bf7 100644
--- a/backend/src/plugins/ModActions/ModActionsPlugin.ts
+++ b/backend/src/plugins/ModActions/ModActionsPlugin.ts
@@ -34,7 +34,6 @@ import { UnmuteCmd } from "./commands/UnmuteCmd";
 import { UpdateCmd } from "./commands/UpdateCmd";
 import { WarnCmd } from "./commands/WarnCmd";
 import { CreateBanCaseOnManualBanEvt } from "./events/CreateBanCaseOnManualBanEvt";
-import { CreateKickCaseOnManualKickEvt } from "./events/CreateKickCaseOnManualKickEvt";
 import { CreateUnbanCaseOnManualUnbanEvt } from "./events/CreateUnbanCaseOnManualUnbanEvt";
 import { PostAlertOnMemberJoinEvt } from "./events/PostAlertOnMemberJoinEvt";
 import { banUserId } from "./functions/banUserId";
@@ -42,11 +41,12 @@ import { hasMutePermission } from "./functions/hasMutePerm";
 import { kickMember } from "./functions/kickMember";
 import { offModActionsEvent } from "./functions/offModActionsEvent";
 import { onModActionsEvent } from "./functions/onModActionsEvent";
-import { outdatedTempbansLoop } from "./functions/outdatedTempbansLoop";
 import { updateCase } from "./functions/updateCase";
 import { warnMember } from "./functions/warnMember";
 import { BanOptions, ConfigSchema, KickOptions, ModActionsPluginType, WarnOptions } from "./types";
 import { LogsPlugin } from "../Logs/LogsPlugin";
+import { onGuildEvent } from "../../data/GuildEvents";
+import { clearTempban } from "./functions/clearTempban";
 
 const defaultOptions = {
   config: {
@@ -200,7 +200,6 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
     state.serverLogs = new GuildLogs(guild.id);
 
     state.unloaded = false;
-    state.outdatedTempbansTimeout = null;
     state.ignoredEvents = [];
     // Massbans can take a while depending on rate limits,
     // so we're giving each massban 15 minutes to complete before launching the next massban
@@ -210,11 +209,14 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()({
   },
 
   afterLoad(pluginData) {
-    outdatedTempbansLoop(pluginData);
+    pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "expiredTempban", (tempban) =>
+      clearTempban(pluginData, tempban),
+    );
   },
 
   beforeUnload(pluginData) {
     pluginData.state.unloaded = true;
+    pluginData.state.unregisterGuildEventListener();
     pluginData.state.events.removeAllListeners();
   },
 });
diff --git a/backend/src/plugins/ModActions/commands/UnbanCmd.ts b/backend/src/plugins/ModActions/commands/UnbanCmd.ts
index eb235917..2c9bbfd3 100644
--- a/backend/src/plugins/ModActions/commands/UnbanCmd.ts
+++ b/backend/src/plugins/ModActions/commands/UnbanCmd.ts
@@ -10,6 +10,7 @@ import { formatReasonWithAttachments } from "../functions/formatReasonWithAttach
 import { ignoreEvent } from "../functions/ignoreEvent";
 import { IgnoredEventType, modActionsCmd } from "../types";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { clearExpiringTempban } from "../../../data/loops/expiringTempbansLoop";
 
 const opts = {
   mod: ct.member({ option: true }),
@@ -68,7 +69,11 @@ export const UnbanCmd = modActionsCmd({
       ppId: mod.id !== msg.author.id ? msg.author.id : undefined,
     });
     // Delete the tempban, if one exists
-    pluginData.state.tempbans.clear(user.id);
+    const tempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);
+    if (tempban) {
+      clearExpiringTempban(tempban);
+      await pluginData.state.tempbans.clear(user.id);
+    }
 
     // Confirm the action
     sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`);
diff --git a/backend/src/plugins/ModActions/functions/banUserId.ts b/backend/src/plugins/ModActions/functions/banUserId.ts
index 0fc69371..bbaab584 100644
--- a/backend/src/plugins/ModActions/functions/banUserId.ts
+++ b/backend/src/plugins/ModActions/functions/banUserId.ts
@@ -19,6 +19,7 @@ import { BanOptions, BanResult, IgnoredEventType, ModActionsPluginType } from ".
 import { getDefaultContactMethods } from "./getDefaultContactMethods";
 import { ignoreEvent } from "./ignoreEvent";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { registerExpiringTempban } from "../../../data/loops/expiringTempbansLoop";
 
 /**
  * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case.
@@ -112,6 +113,8 @@ export async function banUserId(
     } else {
       pluginData.state.tempbans.addTempban(user.id, banTime, banOptions.modId ?? selfId);
     }
+    const tempban = (await pluginData.state.tempbans.findExistingTempbanForUserId(user.id))!;
+    registerExpiringTempban(tempban);
   }
 
   // Create a case for this action
diff --git a/backend/src/plugins/ModActions/functions/clearTempban.ts b/backend/src/plugins/ModActions/functions/clearTempban.ts
new file mode 100644
index 00000000..00ef5849
--- /dev/null
+++ b/backend/src/plugins/ModActions/functions/clearTempban.ts
@@ -0,0 +1,60 @@
+import { Snowflake } from "discord.js";
+import humanizeDuration from "humanize-duration";
+import { GuildPluginData } from "knub";
+import moment from "moment-timezone";
+import { LogType } from "src/data/LogType";
+import { logger } from "src/logger";
+import { CaseTypes } from "../../../data/CaseTypes";
+import { resolveUser, SECONDS } from "../../../utils";
+import { CasesPlugin } from "../../Cases/CasesPlugin";
+import { IgnoredEventType, ModActionsPluginType } from "../types";
+import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
+import { ignoreEvent } from "./ignoreEvent";
+import { isBanned } from "./isBanned";
+import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { Tempban } from "../../../data/entities/Tempban";
+
+export async function clearTempban(pluginData: GuildPluginData<ModActionsPluginType>, tempban: Tempban) {
+  if (!(await isBanned(pluginData, tempban.user_id))) {
+    pluginData.state.tempbans.clear(tempban.user_id);
+    return;
+  }
+
+  pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id);
+  const reason = formatReasonWithAttachments(
+    `Tempban timed out.
+    Tempbanned at: \`${tempban.created_at} UTC\``,
+    [],
+  );
+  try {
+    ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id);
+    await pluginData.guild.bans.remove(tempban.user_id as Snowflake, reason ?? undefined);
+  } catch (e) {
+    pluginData.getPlugin(LogsPlugin).logBotAlert({
+      body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`,
+    });
+    logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`);
+    return;
+  }
+
+  // Create case and delete tempban
+  const casesPlugin = pluginData.getPlugin(CasesPlugin);
+  const createdCase = await casesPlugin.createCase({
+    userId: tempban.user_id,
+    modId: tempban.mod_id,
+    type: CaseTypes.Unban,
+    reason,
+    ppId: undefined,
+  });
+  pluginData.state.tempbans.clear(tempban.user_id);
+
+  // Log the unban
+  const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at));
+  pluginData.getPlugin(LogsPlugin).logMemberTimedUnban({
+    mod: await resolveUser(pluginData.client, tempban.mod_id),
+    userId: tempban.user_id,
+    caseNumber: createdCase.case_number,
+    reason,
+    banTime: humanizeDuration(banTime),
+  });
+}
diff --git a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts b/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts
deleted file mode 100644
index ca3c79ef..00000000
--- a/backend/src/plugins/ModActions/functions/outdatedTempbansLoop.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Snowflake } from "discord.js";
-import humanizeDuration from "humanize-duration";
-import { GuildPluginData } from "knub";
-import moment from "moment-timezone";
-import { LogType } from "src/data/LogType";
-import { logger } from "src/logger";
-import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
-import { CaseTypes } from "../../../data/CaseTypes";
-import { resolveUser, SECONDS } from "../../../utils";
-import { CasesPlugin } from "../../Cases/CasesPlugin";
-import { IgnoredEventType, ModActionsPluginType } from "../types";
-import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
-import { ignoreEvent } from "./ignoreEvent";
-import { isBanned } from "./isBanned";
-import { LogsPlugin } from "../../Logs/LogsPlugin";
-
-const TEMPBAN_LOOP_TIME = 60 * SECONDS;
-
-export async function outdatedTempbansLoop(pluginData: GuildPluginData<ModActionsPluginType>) {
-  const outdatedTempbans = await pluginData.state.tempbans.getExpiredTempbans();
-
-  for (const tempban of outdatedTempbans) {
-    if (!(await isBanned(pluginData, tempban.user_id))) {
-      pluginData.state.tempbans.clear(tempban.user_id);
-      continue;
-    }
-
-    pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id);
-    const reason = formatReasonWithAttachments(
-      `Tempban timed out.
-      Tempbanned at: \`${tempban.created_at} UTC\``,
-      [],
-    );
-    try {
-      ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id);
-      await pluginData.guild.bans.remove(tempban.user_id as Snowflake, reason ?? undefined);
-    } catch (e) {
-      pluginData.getPlugin(LogsPlugin).logBotAlert({
-        body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`,
-      });
-      logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`);
-      return;
-    }
-
-    // Create case and delete tempban
-    const casesPlugin = pluginData.getPlugin(CasesPlugin);
-    const createdCase = await casesPlugin.createCase({
-      userId: tempban.user_id,
-      modId: tempban.mod_id,
-      type: CaseTypes.Unban,
-      reason,
-      ppId: undefined,
-    });
-    pluginData.state.tempbans.clear(tempban.user_id);
-
-    // Log the unban
-    const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at));
-    pluginData.getPlugin(LogsPlugin).logMemberTimedUnban({
-      mod: await resolveUser(pluginData.client, tempban.mod_id),
-      userId: tempban.user_id,
-      caseNumber: createdCase.case_number,
-      reason,
-      banTime: humanizeDuration(banTime),
-    });
-  }
-
-  if (!pluginData.state.unloaded) {
-    pluginData.state.outdatedTempbansTimeout = setTimeout(() => outdatedTempbansLoop(pluginData), TEMPBAN_LOOP_TIME);
-  }
-}
diff --git a/backend/src/plugins/ModActions/types.ts b/backend/src/plugins/ModActions/types.ts
index b8c07270..16e8e15e 100644
--- a/backend/src/plugins/ModActions/types.ts
+++ b/backend/src/plugins/ModActions/types.ts
@@ -72,7 +72,7 @@ export interface ModActionsPluginType extends BasePluginType {
     serverLogs: GuildLogs;
 
     unloaded: boolean;
-    outdatedTempbansTimeout: Timeout | null;
+    unregisterGuildEventListener: () => void;
     ignoredEvents: IIgnoredEvent[];
     massbanQueue: Queue;
 
diff --git a/backend/src/plugins/Mutes/MutesPlugin.ts b/backend/src/plugins/Mutes/MutesPlugin.ts
index ad1c0c66..7d18335e 100644
--- a/backend/src/plugins/Mutes/MutesPlugin.ts
+++ b/backend/src/plugins/Mutes/MutesPlugin.ts
@@ -15,12 +15,13 @@ import { MutesCmd } from "./commands/MutesCmd";
 import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt";
 import { ClearActiveMuteOnRoleRemovalEvt } from "./events/ClearActiveMuteOnRoleRemovalEvt";
 import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt";
-import { clearExpiredMutes } from "./functions/clearExpiredMutes";
 import { muteUser } from "./functions/muteUser";
 import { offMutesEvent } from "./functions/offMutesEvent";
 import { onMutesEvent } from "./functions/onMutesEvent";
 import { unmuteUser } from "./functions/unmuteUser";
 import { ConfigSchema, MutesPluginType } from "./types";
+import { onGuildEvent } from "../../data/GuildEvents";
+import { clearMute } from "./functions/clearMute";
 
 const defaultOptions = {
   config: {
@@ -113,15 +114,13 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()({
   },
 
   afterLoad(pluginData) {
-    clearExpiredMutes(pluginData);
-    pluginData.state.muteClearIntervalId = setInterval(
-      () => clearExpiredMutes(pluginData),
-      EXPIRED_MUTE_CHECK_INTERVAL,
+    pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "expiredMute", (mute) =>
+      clearMute(pluginData, mute),
     );
   },
 
   beforeUnload(pluginData) {
-    clearInterval(pluginData.state.muteClearIntervalId);
+    pluginData.state.unregisterGuildEventListener();
     pluginData.state.events.removeAllListeners();
   },
 });
diff --git a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts b/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts
deleted file mode 100644
index dc83fee6..00000000
--- a/backend/src/plugins/Mutes/functions/clearExpiredMutes.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { Snowflake } from "discord.js";
-import { GuildPluginData } from "knub";
-import { memberToTemplateSafeMember } from "../../../utils/templateSafeObjects";
-import { LogType } from "../../../data/LogType";
-import { resolveMember, UnknownUser, verboseUserMention } from "../../../utils";
-import { memberRolesLock } from "../../../utils/lockNameHelpers";
-import { MutesPluginType } from "../types";
-import { LogsPlugin } from "../../Logs/LogsPlugin";
-
-export async function clearExpiredMutes(pluginData: GuildPluginData<MutesPluginType>) {
-  const expiredMutes = await pluginData.state.mutes.getExpiredMutes();
-  for (const mute of expiredMutes) {
-    const member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true);
-
-    if (member) {
-      try {
-        const lock = await pluginData.locks.acquire(memberRolesLock(member));
-
-        const muteRole = pluginData.config.get().mute_role;
-        if (muteRole) {
-          await member.roles.remove(muteRole);
-        }
-        if (mute.roles_to_restore) {
-          const guildRoles = pluginData.guild.roles.cache;
-          const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole);
-          for (const toRestore of mute.roles_to_restore) {
-            if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) {
-              newRoles.push(toRestore);
-            }
-          }
-          await member.roles.set(newRoles);
-        }
-
-        lock.unlock();
-      } catch {
-        pluginData.getPlugin(LogsPlugin).logBotAlert({
-          body: `Failed to remove mute role from ${verboseUserMention(member.user)}`,
-        });
-      }
-    }
-
-    await pluginData.state.mutes.clear(mute.user_id);
-
-    pluginData.getPlugin(LogsPlugin).logMemberMuteExpired({
-      member: member || new UnknownUser({ id: mute.user_id }),
-    });
-
-    pluginData.state.events.emit("unmute", mute.user_id);
-  }
-}
diff --git a/backend/src/plugins/Mutes/functions/clearMute.ts b/backend/src/plugins/Mutes/functions/clearMute.ts
new file mode 100644
index 00000000..e7167474
--- /dev/null
+++ b/backend/src/plugins/Mutes/functions/clearMute.ts
@@ -0,0 +1,55 @@
+import { Mute } from "../../../data/entities/Mute";
+import { resolveMember, verboseUserMention } from "../../../utils";
+import { memberRolesLock } from "../../../utils/lockNameHelpers";
+import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { GuildPluginData } from "knub";
+import { MutesPluginType } from "../types";
+import { clearExpiringMute } from "../../../data/loops/expiringMutesLoop";
+import { GuildMember } from "discord.js";
+
+export async function clearMute(
+  pluginData: GuildPluginData<MutesPluginType>,
+  mute: Mute | null = null,
+  member: GuildMember | null = null,
+) {
+  if (mute) {
+    clearExpiringMute(mute);
+  }
+
+  if (!member && mute) {
+    member = await resolveMember(pluginData.client, pluginData.guild, mute.user_id, true);
+  }
+
+  if (member) {
+    const lock = await pluginData.locks.acquire(memberRolesLock(member));
+
+    try {
+      const muteRole = pluginData.config.get().mute_role;
+      if (muteRole) {
+        await member.roles.remove(muteRole);
+      }
+      if (mute?.roles_to_restore) {
+        const guildRoles = pluginData.guild.roles.cache;
+        const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole);
+        for (const toRestore of mute?.roles_to_restore) {
+          if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) {
+            newRoles.push(toRestore);
+          }
+        }
+        await member.roles.set(newRoles);
+      }
+
+      lock.unlock();
+    } catch {
+      pluginData.getPlugin(LogsPlugin).logBotAlert({
+        body: `Failed to remove mute role from ${verboseUserMention(member.user)}`,
+      });
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  if (mute) {
+    await pluginData.state.mutes.clear(mute.user_id);
+  }
+}
diff --git a/backend/src/plugins/Mutes/functions/muteUser.ts b/backend/src/plugins/Mutes/functions/muteUser.ts
index cf0aeca0..bf60002e 100644
--- a/backend/src/plugins/Mutes/functions/muteUser.ts
+++ b/backend/src/plugins/Mutes/functions/muteUser.ts
@@ -4,7 +4,6 @@ import { GuildPluginData } from "knub";
 import { userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
 import { CaseTypes } from "../../../data/CaseTypes";
 import { Case } from "../../../data/entities/Case";
-import { LogType } from "../../../data/LogType";
 import { LogsPlugin } from "../../../plugins/Logs/LogsPlugin";
 import { ERRORS, RecoverablePluginError } from "../../../RecoverablePluginError";
 import { renderTemplate, TemplateSafeValueContainer } from "../../../templateFormatter";
@@ -19,7 +18,12 @@ import {
 import { muteLock } from "../../../utils/lockNameHelpers";
 import { CasesPlugin } from "../../Cases/CasesPlugin";
 import { MuteOptions, MutesPluginType } from "../types";
+import { Mute } from "../../../data/entities/Mute";
+import { registerExpiringMute } from "../../../data/loops/expiringMutesLoop";
 
+/**
+ * TODO: Clean up this function
+ */
 export async function muteUser(
   pluginData: GuildPluginData<MutesPluginType>,
   userId: string,
@@ -132,6 +136,7 @@ export async function muteUser(
 
   // If the user is already muted, update the duration of their existing mute
   const existingMute = await pluginData.state.mutes.findExistingMuteForUserId(user.id);
+  let finalMute: Mute;
   let notifyResult: UserNotificationResult = { method: null, success: true };
 
   if (existingMute) {
@@ -139,10 +144,13 @@ export async function muteUser(
       rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore]));
     }
     await pluginData.state.mutes.updateExpiryTime(user.id, muteTime, rolesToRestore);
+    finalMute = (await pluginData.state.mutes.findExistingMuteForUserId(user.id))!;
   } else {
-    await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore);
+    finalMute = await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore);
   }
 
+  registerExpiringMute(finalMute);
+
   const template = existingMute
     ? config.update_mute_message
     : muteTime
diff --git a/backend/src/plugins/Mutes/functions/unmuteUser.ts b/backend/src/plugins/Mutes/functions/unmuteUser.ts
index 0f2d88d5..a3c55b08 100644
--- a/backend/src/plugins/Mutes/functions/unmuteUser.ts
+++ b/backend/src/plugins/Mutes/functions/unmuteUser.ts
@@ -11,6 +11,7 @@ import { CaseArgs } from "../../Cases/types";
 import { MutesPluginType, UnmuteResult } from "../types";
 import { memberHasMutedRole } from "./memberHasMutedRole";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { clearMute } from "./clearMute";
 
 export async function unmuteUser(
   pluginData: GuildPluginData<MutesPluginType>,
@@ -34,34 +35,7 @@ export async function unmuteUser(
     }
   } else {
     // Unmute immediately
-    if (member) {
-      const lock = await pluginData.locks.acquire(memberRolesLock(member));
-
-      const muteRole = pluginData.config.get().mute_role;
-      if (muteRole && member.roles.cache.has(muteRole as Snowflake)) {
-        await member.roles.remove(muteRole as Snowflake);
-      }
-      if (existingMute?.roles_to_restore) {
-        const guildRoles = pluginData.guild.roles.cache;
-        const newRoles = [...member.roles.cache.keys()].filter((roleId) => roleId !== muteRole);
-        for (const toRestore of existingMute.roles_to_restore) {
-          if (guildRoles.has(toRestore) && toRestore !== muteRole && !newRoles.includes(toRestore)) {
-            newRoles.push(toRestore);
-          }
-        }
-        await member.roles.set(newRoles);
-      }
-
-      lock.unlock();
-    } else {
-      // tslint:disable-next-line:no-console
-      console.warn(
-        `Member ${userId} not found in guild ${pluginData.guild.name} (${pluginData.guild.id}) when attempting to unmute`,
-      );
-    }
-    if (existingMute) {
-      await pluginData.state.mutes.clear(userId);
-    }
+    clearMute(pluginData, existingMute);
   }
 
   const timeUntilUnmute = unmuteTime && humanizeDuration(unmuteTime);
diff --git a/backend/src/plugins/Mutes/types.ts b/backend/src/plugins/Mutes/types.ts
index c0afcc18..b612b029 100644
--- a/backend/src/plugins/Mutes/types.ts
+++ b/backend/src/plugins/Mutes/types.ts
@@ -52,7 +52,7 @@ export interface MutesPluginType extends BasePluginType {
     serverLogs: GuildLogs;
     archives: GuildArchives;
 
-    muteClearIntervalId: Timeout;
+    unregisterGuildEventListener: () => void;
 
     events: MutesEventEmitter;
   };
diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts
index 1b176252..5c44ce27 100644
--- a/backend/src/plugins/Post/PostPlugin.ts
+++ b/backend/src/plugins/Post/PostPlugin.ts
@@ -8,12 +8,13 @@ import { EditCmd } from "./commands/EditCmd";
 import { EditEmbedCmd } from "./commands/EditEmbedCmd";
 import { PostCmd } from "./commands/PostCmd";
 import { PostEmbedCmd } from "./commands/PostEmbedCmd";
-import { ScheduledPostsDeleteCmd } from "./commands/SchedluedPostsDeleteCmd";
+import { ScheduledPostsDeleteCmd } from "./commands/ScheduledPostsDeleteCmd";
 import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd";
 import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd";
 import { ConfigSchema, PostPluginType } from "./types";
-import { scheduledPostLoop } from "./util/scheduledPostLoop";
 import { LogsPlugin } from "../Logs/LogsPlugin";
+import { onGuildEvent } from "../../data/GuildEvents";
+import { postScheduledPost } from "./util/postScheduledPost";
 
 const defaultOptions: PluginOptions<PostPluginType> = {
   config: {
@@ -60,10 +61,12 @@ export const PostPlugin = zeppelinGuildPlugin<PostPluginType>()({
   },
 
   afterLoad(pluginData) {
-    scheduledPostLoop(pluginData);
+    pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "scheduledPost", (post) =>
+      postScheduledPost(pluginData, post),
+    );
   },
 
   beforeUnload(pluginData) {
-    clearTimeout(pluginData.state.scheduledPostLoopTimeout);
+    pluginData.state.unregisterGuildEventListener();
   },
 });
diff --git a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts
similarity index 86%
rename from backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts
rename to backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts
index 0cda8a72..2a08344f 100644
--- a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts
+++ b/backend/src/plugins/Post/commands/ScheduledPostsDeleteCmd.ts
@@ -2,6 +2,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
 import { sorter } from "../../../utils";
 import { postCmd } from "../types";
+import { clearUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop";
 
 export const ScheduledPostsDeleteCmd = postCmd({
   trigger: ["scheduled_posts delete", "scheduled_posts d"],
@@ -20,6 +21,7 @@ export const ScheduledPostsDeleteCmd = postCmd({
       return;
     }
 
+    clearUpcomingScheduledPost(post);
     await pluginData.state.scheduledPosts.delete(post.id);
     sendSuccessMessage(pluginData, msg.channel, "Scheduled post deleted!");
   },
diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts
index b54a23d2..493e8444 100644
--- a/backend/src/plugins/Post/types.ts
+++ b/backend/src/plugins/Post/types.ts
@@ -16,7 +16,7 @@ export interface PostPluginType extends BasePluginType {
     scheduledPosts: GuildScheduledPosts;
     logs: GuildLogs;
 
-    scheduledPostLoopTimeout: NodeJS.Timeout;
+    unregisterGuildEventListener: () => void;
   };
 }
 
diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts
index 32320368..054f655c 100644
--- a/backend/src/plugins/Post/util/actualPostCmd.ts
+++ b/backend/src/plugins/Post/util/actualPostCmd.ts
@@ -11,6 +11,7 @@ import { PostPluginType } from "../types";
 import { parseScheduleTime } from "./parseScheduleTime";
 import { postMessage } from "./postMessage";
 import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop";
 
 const MIN_REPEAT_TIME = 5 * MINUTES;
 const MAX_REPEAT_TIME = Math.pow(2, 32);
@@ -141,7 +142,7 @@ export async function actualPostCmd(
       return;
     }
 
-    await pluginData.state.scheduledPosts.create({
+    const post = await pluginData.state.scheduledPosts.create({
       author_id: msg.author.id,
       author_name: msg.author.tag,
       channel_id: targetChannel.id,
@@ -153,6 +154,7 @@ export async function actualPostCmd(
       repeat_until: repeatUntil ? repeatUntil.clone().tz("Etc/UTC").format(DBDateFormat) : null,
       repeat_times: repeatTimes ?? null,
     });
+    registerUpcomingScheduledPost(post);
 
     if (opts.repeat) {
       pluginData.getPlugin(LogsPlugin).logScheduledRepeatedMessage({
diff --git a/backend/src/plugins/Post/util/postScheduledPost.ts b/backend/src/plugins/Post/util/postScheduledPost.ts
new file mode 100644
index 00000000..824f5021
--- /dev/null
+++ b/backend/src/plugins/Post/util/postScheduledPost.ts
@@ -0,0 +1,78 @@
+import { Snowflake, TextChannel, User } from "discord.js";
+import { GuildPluginData } from "knub";
+import moment from "moment-timezone";
+import { logger } from "../../../logger";
+import { DBDateFormat, verboseChannelMention, verboseUserMention } from "../../../utils";
+import { PostPluginType } from "../types";
+import { postMessage } from "./postMessage";
+import { LogsPlugin } from "../../Logs/LogsPlugin";
+import { ScheduledPost } from "../../../data/entities/ScheduledPost";
+import { registerUpcomingScheduledPost } from "../../../data/loops/upcomingScheduledPostsLoop";
+
+export async function postScheduledPost(pluginData: GuildPluginData<PostPluginType>, post: ScheduledPost) {
+  // First, update the scheduled post or delete it from the database *before* we try posting it.
+  // This ensures strange errors don't cause reposts.
+  let shouldClear = true;
+
+  if (post.repeat_interval) {
+    const nextPostAt = moment.utc().add(post.repeat_interval, "ms");
+
+    if (post.repeat_until) {
+      const repeatUntil = moment.utc(post.repeat_until, DBDateFormat);
+      if (nextPostAt.isSameOrBefore(repeatUntil)) {
+        await pluginData.state.scheduledPosts.update(post.id, {
+          post_at: nextPostAt.format(DBDateFormat),
+        });
+        shouldClear = false;
+      }
+    } else if (post.repeat_times) {
+      if (post.repeat_times > 1) {
+        await pluginData.state.scheduledPosts.update(post.id, {
+          post_at: nextPostAt.format(DBDateFormat),
+          repeat_times: post.repeat_times - 1,
+        });
+        shouldClear = false;
+      }
+    }
+  }
+
+  if (shouldClear) {
+    await pluginData.state.scheduledPosts.delete(post.id);
+  } else {
+    const upToDatePost = (await pluginData.state.scheduledPosts.find(post.id))!;
+    registerUpcomingScheduledPost(upToDatePost);
+  }
+
+  // Post the message
+  const channel = pluginData.guild.channels.cache.get(post.channel_id as Snowflake);
+  if (channel?.isText() || channel?.isThread()) {
+    const [username, discriminator] = post.author_name.split("#");
+    const author: User = (await pluginData.client.users.fetch(post.author_id as Snowflake)) || {
+      id: post.author_id,
+      username,
+      discriminator,
+    };
+
+    try {
+      const postedMessage = await postMessage(
+        pluginData,
+        channel,
+        post.content,
+        post.attachments,
+        post.enable_mentions,
+      );
+      pluginData.getPlugin(LogsPlugin).logPostedScheduledMessage({
+        author,
+        channel,
+        messageId: postedMessage.id,
+      });
+    } catch {
+      pluginData.getPlugin(LogsPlugin).logBotAlert({
+        body: `Failed to post scheduled message by ${verboseUserMention(author)} to ${verboseChannelMention(channel)}`,
+      });
+      logger.warn(
+        `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`,
+      );
+    }
+  }
+}
diff --git a/backend/src/plugins/Post/util/scheduledPostLoop.ts b/backend/src/plugins/Post/util/scheduledPostLoop.ts
deleted file mode 100644
index 2a946c1e..00000000
--- a/backend/src/plugins/Post/util/scheduledPostLoop.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Snowflake, TextChannel, User } from "discord.js";
-import { GuildPluginData } from "knub";
-import moment from "moment-timezone";
-import { channelToTemplateSafeChannel, userToTemplateSafeUser } from "../../../utils/templateSafeObjects";
-import { LogType } from "../../../data/LogType";
-import { logger } from "../../../logger";
-import { DBDateFormat, SECONDS, verboseChannelMention, verboseUserMention } from "../../../utils";
-import { PostPluginType } from "../types";
-import { postMessage } from "./postMessage";
-import { LogsPlugin } from "../../Logs/LogsPlugin";
-
-const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS;
-
-export async function scheduledPostLoop(pluginData: GuildPluginData<PostPluginType>) {
-  const duePosts = await pluginData.state.scheduledPosts.getDueScheduledPosts();
-  for (const post of duePosts) {
-    const channel = pluginData.guild.channels.cache.get(post.channel_id as Snowflake);
-    if (channel instanceof TextChannel) {
-      const [username, discriminator] = post.author_name.split("#");
-      const author: User = (await pluginData.client.users.fetch(post.author_id as Snowflake)) || {
-        id: post.author_id,
-        username,
-        discriminator,
-      };
-
-      try {
-        const postedMessage = await postMessage(
-          pluginData,
-          channel,
-          post.content,
-          post.attachments,
-          post.enable_mentions,
-        );
-        pluginData.getPlugin(LogsPlugin).logPostedScheduledMessage({
-          author,
-          channel,
-          messageId: postedMessage.id,
-        });
-      } catch {
-        pluginData.getPlugin(LogsPlugin).logBotAlert({
-          body: `Failed to post scheduled message by ${verboseUserMention(author)} to ${verboseChannelMention(
-            channel,
-          )}`,
-        });
-        logger.warn(
-          `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`,
-        );
-      }
-    }
-
-    let shouldClear = true;
-
-    if (post.repeat_interval) {
-      const nextPostAt = moment.utc().add(post.repeat_interval, "ms");
-
-      if (post.repeat_until) {
-        const repeatUntil = moment.utc(post.repeat_until, DBDateFormat);
-        if (nextPostAt.isSameOrBefore(repeatUntil)) {
-          await pluginData.state.scheduledPosts.update(post.id, {
-            post_at: nextPostAt.format(DBDateFormat),
-          });
-          shouldClear = false;
-        }
-      } else if (post.repeat_times) {
-        if (post.repeat_times > 1) {
-          await pluginData.state.scheduledPosts.update(post.id, {
-            post_at: nextPostAt.format(DBDateFormat),
-            repeat_times: post.repeat_times - 1,
-          });
-          shouldClear = false;
-        }
-      }
-    }
-
-    if (shouldClear) {
-      await pluginData.state.scheduledPosts.delete(post.id);
-    }
-  }
-
-  pluginData.state.scheduledPostLoopTimeout = setTimeout(
-    () => scheduledPostLoop(pluginData),
-    SCHEDULED_POST_CHECK_INTERVAL,
-  );
-}
diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts
index 058c5a46..67a97035 100644
--- a/backend/src/plugins/Reminders/RemindersPlugin.ts
+++ b/backend/src/plugins/Reminders/RemindersPlugin.ts
@@ -6,7 +6,8 @@ import { RemindCmd } from "./commands/RemindCmd";
 import { RemindersCmd } from "./commands/RemindersCmd";
 import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd";
 import { ConfigSchema, RemindersPluginType } from "./types";
-import { postDueRemindersLoop } from "./utils/postDueRemindersLoop";
+import { onGuildEvent } from "../../data/GuildEvents";
+import { postReminder } from "./functions/postReminder";
 
 const defaultOptions: PluginOptions<RemindersPluginType> = {
   config: {
@@ -46,16 +47,16 @@ export const RemindersPlugin = zeppelinGuildPlugin<RemindersPluginType>()({
     state.reminders = GuildReminders.getGuildInstance(guild.id);
     state.tries = new Map();
     state.unloaded = false;
-
-    state.postRemindersTimeout = null;
   },
 
   afterLoad(pluginData) {
-    postDueRemindersLoop(pluginData);
+    pluginData.state.unregisterGuildEventListener = onGuildEvent(pluginData.guild.id, "reminder", (reminder) =>
+      postReminder(pluginData, reminder),
+    );
   },
 
   beforeUnload(pluginData) {
-    clearTimeout(pluginData.state.postRemindersTimeout);
+    pluginData.state.unregisterGuildEventListener();
     pluginData.state.unloaded = true;
   },
 });
diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts
index 24c765a7..122c9e3d 100644
--- a/backend/src/plugins/Reminders/commands/RemindCmd.ts
+++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts
@@ -5,6 +5,7 @@ import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
 import { convertDelayStringToMS, messageLink } from "../../../utils";
 import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
 import { remindersCmd } from "../types";
+import { registerUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop";
 
 export const RemindCmd = remindersCmd({
   trigger: ["remind", "remindme", "reminder"],
@@ -50,7 +51,7 @@ export const RemindCmd = remindersCmd({
     }
 
     const reminderBody = args.reminder || messageLink(pluginData.guild.id, msg.channel.id, msg.id);
-    await pluginData.state.reminders.add(
+    const reminder = await pluginData.state.reminders.add(
       msg.author.id,
       msg.channel.id,
       reminderTime.clone().tz("Etc/UTC").format("YYYY-MM-DD HH:mm:ss"),
@@ -58,6 +59,8 @@ export const RemindCmd = remindersCmd({
       moment.utc().format("YYYY-MM-DD HH:mm:ss"),
     );
 
+    registerUpcomingReminder(reminder);
+
     const msUntilReminder = reminderTime.diff(now);
     const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true });
     const prettyReminderTime = (await timeAndDate.inMemberTz(msg.author.id, reminderTime)).format(
diff --git a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts
index e9f97a66..0690b434 100644
--- a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts
+++ b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts
@@ -2,6 +2,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
 import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
 import { sorter } from "../../../utils";
 import { remindersCmd } from "../types";
+import { clearUpcomingReminder } from "../../../data/loops/upcomingRemindersLoop";
 
 export const RemindersDeleteCmd = remindersCmd({
   trigger: ["reminders delete", "reminders d"],
@@ -21,6 +22,7 @@ export const RemindersDeleteCmd = remindersCmd({
     }
 
     const toDelete = reminders[args.num - 1];
+    clearUpcomingReminder(toDelete);
     await pluginData.state.reminders.delete(toDelete.id);
 
     sendSuccessMessage(pluginData, msg.channel, "Reminder deleted");
diff --git a/backend/src/plugins/Reminders/functions/postReminder.ts b/backend/src/plugins/Reminders/functions/postReminder.ts
new file mode 100644
index 00000000..85b95cb8
--- /dev/null
+++ b/backend/src/plugins/Reminders/functions/postReminder.ts
@@ -0,0 +1,41 @@
+import { GuildPluginData } from "knub";
+import { RemindersPluginType } from "../types";
+import { Reminder } from "../../../data/entities/Reminder";
+import { Snowflake, TextChannel } from "discord.js";
+import moment from "moment-timezone";
+import { disableLinkPreviews } from "knub/dist/helpers";
+import { DBDateFormat, SECONDS } from "../../../utils";
+import humanizeDuration from "humanize-duration";
+
+export async function postReminder(pluginData: GuildPluginData<RemindersPluginType>, reminder: Reminder) {
+  const channel = pluginData.guild.channels.cache.get(reminder.channel_id as Snowflake);
+  if (channel && (channel.isText() || channel.isThread())) {
+    try {
+      // Only show created at date if one exists
+      if (moment.utc(reminder.created_at).isValid()) {
+        const createdAtTS = Math.floor(moment.utc(reminder.created_at, DBDateFormat).valueOf() / 1000);
+        await channel.send({
+          content: disableLinkPreviews(
+            `Reminder for <@!${reminder.user_id}>: ${reminder.body} \nSet <t:${createdAtTS}:R>`,
+          ),
+          allowedMentions: {
+            users: [reminder.user_id as Snowflake],
+          },
+        });
+      } else {
+        await channel.send({
+          content: disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`),
+          allowedMentions: {
+            users: [reminder.user_id as Snowflake],
+          },
+        });
+      }
+    } catch (err) {
+      // If we were unable to post the reminder, we'll try again later
+      console.warn(`Error when posting reminder for ${reminder.user_id} in guild ${reminder.guild_id}: ${String(err)}`);
+      return;
+    }
+  }
+
+  await pluginData.state.reminders.delete(reminder.id);
+}
diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts
index 0a2c37e9..be9f3fdb 100644
--- a/backend/src/plugins/Reminders/types.ts
+++ b/backend/src/plugins/Reminders/types.ts
@@ -14,7 +14,8 @@ export interface RemindersPluginType extends BasePluginType {
     reminders: GuildReminders;
     tries: Map<number, number>;
 
-    postRemindersTimeout;
+    unregisterGuildEventListener: () => void;
+
     unloaded: boolean;
   };
 }
diff --git a/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts b/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts
deleted file mode 100644
index b5fc6257..00000000
--- a/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Snowflake, TextChannel } from "discord.js";
-import humanizeDuration from "humanize-duration";
-import { GuildPluginData } from "knub";
-import { disableLinkPreviews } from "knub/dist/helpers";
-import moment from "moment-timezone";
-import { SECONDS } from "../../../utils";
-import { RemindersPluginType } from "../types";
-
-const REMINDER_LOOP_TIME = 10 * SECONDS;
-const MAX_TRIES = 3;
-
-export async function postDueRemindersLoop(pluginData: GuildPluginData<RemindersPluginType>) {
-  const pendingReminders = await pluginData.state.reminders.getDueReminders();
-  for (const reminder of pendingReminders) {
-    const channel = pluginData.guild.channels.cache.get(reminder.channel_id as Snowflake);
-    if (channel && channel instanceof TextChannel) {
-      try {
-        // Only show created at date if one exists
-        if (moment.utc(reminder.created_at).isValid()) {
-          const target = moment.utc();
-          const diff = target.diff(moment.utc(reminder.created_at, "YYYY-MM-DD HH:mm:ss"));
-          const result = humanizeDuration(diff, { largest: 2, round: true });
-          await channel.send({
-            content: disableLinkPreviews(
-              `Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``,
-            ),
-            allowedMentions: {
-              users: [reminder.user_id as Snowflake],
-            },
-          });
-        } else {
-          await channel.send({
-            content: disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${reminder.body}`),
-            allowedMentions: {
-              users: [reminder.user_id as Snowflake],
-            },
-          });
-        }
-      } catch {
-        // Probably random Discord internal server error or missing permissions or somesuch
-        // Try again next round unless we've already tried to post this a bunch of times
-        const tries = pluginData.state.tries.get(reminder.id) || 0;
-        if (tries < MAX_TRIES) {
-          pluginData.state.tries.set(reminder.id, tries + 1);
-          continue;
-        }
-      }
-    }
-
-    await pluginData.state.reminders.delete(reminder.id);
-  }
-
-  if (!pluginData.state.unloaded) {
-    pluginData.state.postRemindersTimeout = setTimeout(() => postDueRemindersLoop(pluginData), REMINDER_LOOP_TIME);
-  }
-}
diff --git a/backend/src/utils.ts b/backend/src/utils.ts
index e5bde6fe..fd14070d 100644
--- a/backend/src/utils.ts
+++ b/backend/src/utils.ts
@@ -1534,7 +1534,7 @@ interface IMemoizedItem {
 }
 
 const memoizeCache: Map<any, IMemoizedItem> = new Map();
-export function memoize<T>(fn: (...args: any[]) => T, key?, time?): T {
+export function memoize<T>(fn: () => T, key?, time?): T {
   const realKey = key ?? fn;
 
   if (memoizeCache.has(realKey)) {
@@ -1555,6 +1555,12 @@ export function memoize<T>(fn: (...args: any[]) => T, key?, time?): T {
   return value;
 }
 
+export function lazyMemoize<T extends () => unknown>(fn: T, key?: string, time?: number): T {
+  return (() => {
+    return memoize(fn, key, time);
+  }) as T;
+}
+
 type RecursiveRenderFn = (str: string) => string | Promise<string>;
 
 export async function renderRecursively(value, fn: RecursiveRenderFn) {
diff --git a/backend/src/utils/typeUtils.ts b/backend/src/utils/typeUtils.ts
index a32aa0e1..26bc121e 100644
--- a/backend/src/utils/typeUtils.ts
+++ b/backend/src/utils/typeUtils.ts
@@ -5,3 +5,6 @@ export declare type WithRequiredProps<T, K extends keyof T> = T & {
   // https://mariusschulz.com/blog/mapped-type-modifiers-in-typescript#removing-the-mapped-type-modifier
   [PK in K]-?: Exclude<T[K], null>;
 };
+
+// https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/
+export type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;