diff --git a/src/knot/dnssec/context.c b/src/knot/dnssec/context.c
index 1beaf3e5080db791cfcc2523828e62b69c019488..cb3cfdc1ed4f8a178643f2051cc98946f7892cef 100644
--- a/src/knot/dnssec/context.c
+++ b/src/knot/dnssec/context.c
@@ -22,6 +22,7 @@
 #include "libknot/libknot.h"
 #include "knot/dnssec/context.h"
 #include "knot/dnssec/kasp/keystore.h"
+#include "knot/dnssec/key_records.h"
 #include "knot/server/dthreads.h"
 
 knot_dynarray_define(parent, knot_kasp_parent_t, DYNARRAY_VISIBILITY_NORMAL)
@@ -228,6 +229,16 @@ int kdnssec_ctx_init(conf_t *conf, kdnssec_ctx_t *ctx, const knot_dname_t *zone_
 
 	ctx->now = knot_time();
 
+	key_records_init(ctx, &ctx->offline_records);
+	if (ctx->policy->offline_ksk) {
+		ret = kasp_db_load_offline_records(ctx->kasp_db, ctx->zone->dname,
+		                                   ctx->now, &ctx->offline_next_time,
+		                                   &ctx->offline_records);
+		if (ret != KNOT_EOK && ret != KNOT_ENOENT) {
+			goto init_error;
+		}
+	}
+
 	return KNOT_EOK;
 init_error:
 	kdnssec_ctx_deinit(ctx);
@@ -266,7 +277,7 @@ void kdnssec_ctx_deinit(kdnssec_ctx_t *ctx)
 		}
 		free(ctx->policy);
 	}
-	knot_rrset_free(ctx->offline_rrsig, NULL);
+	key_records_clear(&ctx->offline_records);
 	dnssec_keystore_deinit(ctx->keystore);
 	kasp_zone_free(&ctx->zone);
 	free(ctx->kasp_zone_path);
diff --git a/src/knot/dnssec/context.h b/src/knot/dnssec/context.h
index df4ee33b19814ecce722df2c6ccbfc8c1e4b040e..55a2f3c364dffbbc309e5d080a57117113fa277d 100644
--- a/src/knot/dnssec/context.h
+++ b/src/knot/dnssec/context.h
@@ -44,7 +44,8 @@ typedef struct {
 
 	unsigned dbus_event;
 
-	knot_rrset_t *offline_rrsig;
+	key_records_t offline_records;
+	knot_time_t offline_next_time;
 } kdnssec_ctx_t;
 
 /*!
diff --git a/src/knot/dnssec/zone-events.c b/src/knot/dnssec/zone-events.c
index 8ecd03803588ca6578259ad7a716000f7628aa88..cf6efbf98234f948a1dfbb5a032770f3cdf7e4f8 100644
--- a/src/knot/dnssec/zone-events.c
+++ b/src/knot/dnssec/zone-events.c
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2021 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -123,6 +123,7 @@ int knot_dnssec_zone_sign(zone_update_t *update,
 	const knot_dname_t *zone_name = update->new_cont->apex->owner;
 	kdnssec_ctx_t ctx = { 0 };
 	zone_keyset_t keyset = { 0 };
+	knot_time_t zone_expire = 0;
 
 	int result = kdnssec_ctx_init(conf, &ctx, zone_name, zone_kaspdb(update->zone), NULL);
 	if (result != KNOT_EOK) {
@@ -191,8 +192,7 @@ int knot_dnssec_zone_sign(zone_update_t *update,
 
 	log_zone_info(zone_name, "DNSSEC, signing started");
 
-	knot_time_t next_resign = 0;
-	result = knot_zone_sign_update_dnskeys(update, &keyset, &ctx, &next_resign);
+	result = knot_zone_sign_update_dnskeys(update, &keyset, &ctx);
 	if (result != KNOT_EOK) {
 		log_zone_error(zone_name, "DNSSEC, failed to update DNSKEY records (%s)",
 			       knot_strerror(result));
@@ -213,7 +213,6 @@ int knot_dnssec_zone_sign(zone_update_t *update,
 		goto done;
 	}
 
-	knot_time_t zone_expire = 0;
 	result = knot_zone_sign(update, &keyset, &ctx, &zone_expire);
 	if (result != KNOT_EOK) {
 		log_zone_error(zone_name, "DNSSEC, failed to sign zone content (%s)",
@@ -259,7 +258,7 @@ int knot_dnssec_zone_sign(zone_update_t *update,
 
 done:
 	if (result == KNOT_EOK) {
-		reschedule->next_sign = schedule_next(&ctx, &keyset, next_resign, zone_expire);
+		reschedule->next_sign = schedule_next(&ctx, &keyset, ctx.offline_next_time, zone_expire);
 	} else {
 		reschedule->next_sign = knot_dnssec_failover_delay(&ctx);
 		reschedule->next_rollover = 0;
@@ -280,7 +279,7 @@ int knot_dnssec_sign_update(zone_update_t *update, conf_t *conf)
 	const knot_dname_t *zone_name = update->new_cont->apex->owner;
 	kdnssec_ctx_t ctx = { 0 };
 	zone_keyset_t keyset = { 0 };
-	knot_time_t expire_at = 0;
+	knot_time_t zone_expire = 0;
 
 	int result = kdnssec_ctx_init(conf, &ctx, zone_name, zone_kaspdb(update->zone), NULL);
 	if (result != KNOT_EOK) {
@@ -309,9 +308,8 @@ int knot_dnssec_sign_update(zone_update_t *update, conf_t *conf)
 		goto done;
 	}
 
-	if (zone_update_changes_dnskey(update) || ctx.policy->offline_ksk) {
-		// TODO: move offline rrsigs filling out of knot_zone_sign_update_dnskeys().
-		result = knot_zone_sign_update_dnskeys(update, &keyset, &ctx, &expire_at);
+	if (zone_update_changes_dnskey(update)) {
+		result = knot_zone_sign_update_dnskeys(update, &keyset, &ctx);
 		if (result != KNOT_EOK) {
 			log_zone_error(zone_name, "DNSSEC, failed to update DNSKEY records (%s)",
 				       knot_strerror(result));
@@ -325,7 +323,7 @@ int knot_dnssec_sign_update(zone_update_t *update, conf_t *conf)
 		goto done;
 	}
 
-	result = knot_zone_sign_update(update, &keyset, &ctx, &expire_at);
+	result = knot_zone_sign_update(update, &keyset, &ctx, &zone_expire);
 	if (result != KNOT_EOK) {
 		log_zone_error(zone_name, "DNSSEC, failed to sign changeset (%s)",
 		               knot_strerror(result));
@@ -379,8 +377,10 @@ int knot_dnssec_sign_update(zone_update_t *update, conf_t *conf)
 	log_zone_info(zone_name, "DNSSEC, incrementally signed");
 
 done:
-	if (result == KNOT_EOK && expire_at != 0) {
-		zone_events_schedule_at(update->zone, ZONE_EVENT_DNSSEC, (time_t)expire_at); // this is usually NOOP since signing planned earlier
+	if (result == KNOT_EOK) {
+		knot_time_t next = knot_time_min(ctx.offline_next_time, zone_expire);
+		// NOTE: this is usually NOOP since signing planned earlier
+		zone_events_schedule_at(update->zone, ZONE_EVENT_DNSSEC, next ? next : -1);
 	}
 
 	free_zone_keys(&keyset);
diff --git a/src/knot/dnssec/zone-sign.c b/src/knot/dnssec/zone-sign.c
index 5193c4f67828262df9e984431623e0f67ecc1553..ffa10c4817668eafb22921d86eb11d1eb00919bc 100644
--- a/src/knot/dnssec/zone-sign.c
+++ b/src/knot/dnssec/zone-sign.c
@@ -223,11 +223,11 @@ static int add_missing_rrsigs(const knot_rrset_t *covered,
 	int result = (!rrsig_covers_type(rrsigs, covered->type) ? KNOT_EOK :
 	             knot_synth_rrsig(covered->type, &rrsigs->rrs, &to_remove.rrs, NULL));
 
-	if (result == KNOT_EOK && sign_ctx->dnssec_ctx->offline_rrsig != NULL &&
-	    knot_dname_cmp(sign_ctx->dnssec_ctx->offline_rrsig->owner, covered->owner) == 0 &&
-	    rrsig_covers_type(sign_ctx->dnssec_ctx->offline_rrsig, covered->type)) {
+	if (result == KNOT_EOK && sign_ctx->dnssec_ctx->offline_records.rrsig.rrs.count > 0 &&
+	    knot_dname_cmp(sign_ctx->dnssec_ctx->offline_records.rrsig.owner, covered->owner) == 0 &&
+	    rrsig_covers_type(&sign_ctx->dnssec_ctx->offline_records.rrsig, covered->type)) {
 		result = knot_synth_rrsig(covered->type,
-		    &sign_ctx->dnssec_ctx->offline_rrsig->rrs, &to_add.rrs, NULL);
+		    &sign_ctx->dnssec_ctx->offline_records.rrsig.rrs, &to_add.rrs, NULL);
 		if (result == KNOT_EOK) {
 			// don't remove what shall be added
 			result = knot_rdataset_subtract(&to_remove.rrs, &to_add.rrs, NULL);
@@ -845,8 +845,7 @@ int knot_zone_sign_add_dnskeys(zone_keyset_t *zone_keys, const kdnssec_ctx_t *dn
 
 int knot_zone_sign_update_dnskeys(zone_update_t *update,
                                   zone_keyset_t *zone_keys,
-                                  kdnssec_ctx_t *dnssec_ctx,
-                                  knot_time_t *next_resign)
+                                  kdnssec_ctx_t *dnssec_ctx)
 {
 	if (update == NULL || zone_keys == NULL || dnssec_ctx == NULL) {
 		return KNOT_EINVAL;
@@ -857,62 +856,56 @@ int knot_zone_sign_update_dnskeys(zone_update_t *update,
 	}
 
 	const zone_node_t *apex = update->new_cont->apex;
-	key_records_t add_r, rem_r, orig_r;
-	memset(&add_r, 0, sizeof(add_r));
-	memset(&rem_r, 0, sizeof(rem_r));
-	key_records_from_apex(apex, &orig_r);
 	knot_rrset_t soa = node_rrset(apex, KNOT_RRTYPE_SOA);
 	if (knot_rrset_empty(&soa)) {
 		return KNOT_EINVAL;
 	}
 
+	key_records_t orig_r;
+	key_records_from_apex(apex, &orig_r);
+
 	changeset_t ch;
 	int ret = changeset_init(&ch, apex->owner);
 	if (ret != KNOT_EOK) {
 		return ret;
 	}
 
-#define CHECK_RET if (ret != KNOT_EOK) goto cleanup
-
 	if (!dnssec_ctx->policy->incremental) {
 		// remove all. This will cancel out with additions later
 		ret = key_records_to_changeset(&orig_r, &ch, true, 0);
-		CHECK_RET;
+		if (ret != KNOT_EOK) {
+			return ret;
+		}
 	}
 
+	key_records_t add_r, rem_r;
 	key_records_init(dnssec_ctx, &add_r);
 	key_records_init(dnssec_ctx, &rem_r);
 
+#define CHECK_RET if (ret != KNOT_EOK) goto cleanup
+
 	if (dnssec_ctx->policy->offline_ksk) {
-		ret = kasp_db_load_offline_records(dnssec_ctx->kasp_db, apex->owner, dnssec_ctx->now, next_resign, &add_r);
-		if (ret == KNOT_EOK) {
-			log_zone_info(dnssec_ctx->zone->dname,
-			              "DNSSEC, using offline records, DNSKEYs %hu, CDNSKEYs %hu, CDs %hu, RRSIGs %hu",
-			              add_r.dnskey.rrs.count, add_r.cdnskey.rrs.count, add_r.cds.rrs.count, add_r.rrsig.rrs.count);
-		} else {
-			log_zone_warning(dnssec_ctx->zone->dname, "DNSSEC, failed to load offline records (%s)",
-			                 knot_strerror(ret));
-		}
+		key_records_t *r = &dnssec_ctx->offline_records;
+		log_zone_info(dnssec_ctx->zone->dname,
+		              "DNSSEC, using offline records, DNSKEYs %hu, CDNSKEYs %hu, CDs %hu, RRSIGs %hu",
+		              r->dnskey.rrs.count, r->cdnskey.rrs.count, r->cds.rrs.count, r->rrsig.rrs.count);
+		ret = key_records_to_changeset(r, &ch, false, CHANGESET_CHECK);
+		CHECK_RET;
 	} else {
 		ret = knot_zone_sign_add_dnskeys(zone_keys, dnssec_ctx, &add_r, &rem_r, &orig_r);
+		CHECK_RET;
+		ret = key_records_to_changeset(&rem_r, &ch, true, CHANGESET_CHECK);
+		CHECK_RET;
+		ret = key_records_to_changeset(&add_r, &ch, false, CHANGESET_CHECK);
+		CHECK_RET;
 	}
-	CHECK_RET;
 
-	ret = key_records_to_changeset(&rem_r, &ch, true, CHANGESET_CHECK);
-	CHECK_RET;
-	ret = key_records_to_changeset(&add_r, &ch, false, CHANGESET_CHECK);
-	CHECK_RET;
 	if (dnssec_ctx->policy->ds_push && node_rrtype_exists(ch.add->apex, KNOT_RRTYPE_CDS)) {
 		// there is indeed a change to CDS
 		update->zone->timers.next_ds_push = time(NULL) + dnssec_ctx->policy->propagation_delay;
 		zone_events_schedule_at(update->zone, ZONE_EVENT_DS_PUSH, update->zone->timers.next_ds_push);
 	}
 
-	assert(knot_rrset_empty(&rem_r.rrsig));
-	if (!knot_rrset_empty(&add_r.rrsig)) {
-		dnssec_ctx->offline_rrsig = knot_rrset_copy(&add_r.rrsig, NULL);
-	}
-
 	ret = zone_update_apply_changeset(update, &ch);
 
 #undef CHECK_RET
diff --git a/src/knot/dnssec/zone-sign.h b/src/knot/dnssec/zone-sign.h
index c3990a52928a3051d389349d4cc1f190bf8a03b7..ba6e2b22f09b5b88b837889ceb96a7e8d40407cc 100644
--- a/src/knot/dnssec/zone-sign.h
+++ b/src/knot/dnssec/zone-sign.h
@@ -1,4 +1,4 @@
-/*  Copyright (C) 2020 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
+/*  Copyright (C) 2022 CZ.NIC, z.s.p.o. <knot-dns@labs.nic.cz>
 
     This program is free software: you can redistribute it and/or modify
     it under the terms of the GNU General Public License as published by
@@ -51,8 +51,7 @@ int knot_zone_sign_add_dnskeys(zone_keyset_t *zone_keys, const kdnssec_ctx_t *dn
  */
 int knot_zone_sign_update_dnskeys(zone_update_t *update,
                                   zone_keyset_t *zone_keys,
-                                  kdnssec_ctx_t *dnssec_ctx,
-                                  knot_time_t *next_resign);
+                                  kdnssec_ctx_t *dnssec_ctx);
 
 /*!
  * \brief Check if key can be used to sign given RR.