diff --git a/src/Cache.cpp b/src/Cache.cpp
index 90c04e6199cb548ac0c4060dcb95ff47d0615a1c..2b83fbb5048383b44460214509ad856cdc0389ed 100644
--- a/src/Cache.cpp
+++ b/src/Cache.cpp
@@ -1570,6 +1570,47 @@ Cache::updateState(const std::string &room, const mtx::responses::StateEvents &s
     txn.commit();
 }
 
+namespace {
+template<typename T>
+auto
+isMessage(const mtx::events::RoomEvent<T> &e)
+  -> std::enable_if_t<std::is_same<decltype(e.content.msgtype), std::string>::value, bool>
+{
+    return true;
+}
+
+template<typename T>
+auto
+isMessage(const mtx::events::Event<T> &)
+{
+    return false;
+}
+
+template<typename T>
+auto
+isMessage(const mtx::events::EncryptedEvent<T> &)
+{
+    return true;
+}
+
+auto
+isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &)
+{
+    return true;
+}
+
+auto
+isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallAnswer> &)
+{
+    return true;
+}
+auto
+isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallHangUp> &)
+{
+    return true;
+}
+}
+
 void
 Cache::saveState(const mtx::responses::Sync &res)
 {
@@ -1623,6 +1664,25 @@ Cache::saveState(const mtx::responses::Sync &res)
         saveTimelineMessages(txn, eventsDb, room.first, room.second.timeline);
 
         RoomInfo updatedInfo;
+        {
+            // retrieve the old tags and modification ts
+            std::string_view data;
+            if (roomsDb_.get(txn, room.first, data)) {
+                try {
+                    RoomInfo tmp     = json::parse(std::string_view(data.data(), data.size()));
+                    updatedInfo.tags = std::move(tmp.tags);
+
+                    updatedInfo.approximate_last_modification_ts =
+                      tmp.approximate_last_modification_ts;
+                } catch (const json::exception &e) {
+                    nhlog::db()->warn("failed to parse room info: room_id ({}), {}: {}",
+                                      room.first,
+                                      std::string(data.data(), data.size()),
+                                      e.what());
+                }
+            }
+        }
+
         updatedInfo.name       = getRoomName(txn, statesdb, membersdb).toStdString();
         updatedInfo.topic      = getRoomTopic(txn, statesdb).toStdString();
         updatedInfo.avatar_url = getRoomAvatarUrl(txn, statesdb, membersdb).toStdString();
@@ -1666,7 +1726,6 @@ Cache::saveState(const mtx::responses::Sync &res)
                 rooms_with_space_updates.insert(room.first);
         }
 
-        bool has_new_tags = false;
         // Process the account_data associated with this room
         if (!room.second.account_data.events.empty()) {
             auto accountDataDb = getAccountDataDb(txn, room.first);
@@ -1691,7 +1750,8 @@ Cache::saveState(const mtx::responses::Sync &res)
                 // for tag events
                 if (std::holds_alternative<AccountDataEvent<account_data::Tags>>(evt)) {
                     auto tags_evt = std::get<AccountDataEvent<account_data::Tags>>(evt);
-                    has_new_tags  = true;
+
+                    updatedInfo.tags.clear();
                     for (const auto &tag : tags_evt.content.tags) {
                         updatedInfo.tags.push_back(tag.first);
                     }
@@ -1704,20 +1764,12 @@ Cache::saveState(const mtx::responses::Sync &res)
                 }
             }
         }
-        if (!has_new_tags) {
-            // retrieve the old tags, they haven't changed
-            std::string_view data;
-            if (roomsDb_.get(txn, room.first, data)) {
-                try {
-                    RoomInfo tmp     = json::parse(std::string_view(data.data(), data.size()));
-                    updatedInfo.tags = tmp.tags;
-                } catch (const json::exception &e) {
-                    nhlog::db()->warn("failed to parse room info: room_id ({}), {}: {}",
-                                      room.first,
-                                      std::string(data.data(), data.size()),
-                                      e.what());
-                }
-            }
+
+        for (const auto &e : room.second.timeline.events) {
+            if (!std::visit([](const auto &e) -> bool { return isMessage(e); }, e))
+                continue;
+            updatedInfo.approximate_last_modification_ts =
+              mtx::accessors::origin_server_ts(e).toMSecsSinceEpoch();
         }
 
         roomsDb_.put(txn, room.first, json(updatedInfo).dump());
@@ -4709,6 +4761,8 @@ to_json(json &j, const RoomInfo &info)
     j["join_rule"]    = info.join_rule;
     j["guest_access"] = info.guest_access;
 
+    j["app_l_ts"] = info.approximate_last_modification_ts;
+
     j["notification_count"] = info.notification_count;
     j["highlight_count"]    = info.highlight_count;
 
@@ -4732,6 +4786,8 @@ from_json(const json &j, RoomInfo &info)
     info.join_rule    = j.at("join_rule");
     info.guest_access = j.at("guest_access");
 
+    info.approximate_last_modification_ts = j.value("app_l_ts", 0);
+
     info.notification_count = j.value("notification_count", 0);
     info.highlight_count    = j.value("highlight_count", 0);
 
diff --git a/src/CacheStructs.h b/src/CacheStructs.h
index b391705828100bf3b97ff5bd7e5c92a14ad06342..43055145cd06543c2cab0f8dea7f1e6f1738d103 100644
--- a/src/CacheStructs.h
+++ b/src/CacheStructs.h
@@ -49,7 +49,7 @@ struct DescInfo
     QString userid;
     QString body;
     QString descriptiveTime;
-    uint64_t timestamp;
+    uint64_t timestamp = 0;
     QDateTime datetime;
 };
 
@@ -89,6 +89,10 @@ struct RoomInfo
     //! The list of tags associated with this room
     std::vector<std::string> tags;
 
+    //! An approximate timestamp of when the last message was sent in the room.
+    //! Use the TimelineModel::lastMessage for an accurate timestamp.
+    uint64_t approximate_last_modification_ts = 0;
+
     uint16_t highlight_count    = 0;
     uint16_t notification_count = 0;
 };
diff --git a/src/timeline/RoomlistModel.cpp b/src/timeline/RoomlistModel.cpp
index 1e0b4c23a81ed7c61f6caeb70c72f82246f16c03..972f061d216fb80f7194de5ba026e7a447cd3893 100644
--- a/src/timeline/RoomlistModel.cpp
+++ b/src/timeline/RoomlistModel.cpp
@@ -121,7 +121,7 @@ RoomlistModel::data(const QModelIndex &index, int role) const
             case Roles::Time:
                 return room->lastMessage().descriptiveTime;
             case Roles::Timestamp:
-                return QVariant{static_cast<quint64>(room->lastMessage().timestamp)};
+                return QVariant{static_cast<quint64>(room->lastMessageTimestamp())};
             case Roles::HasUnreadMessages:
                 return this->roomReadStatus.count(roomid) && this->roomReadStatus.at(roomid);
             case Roles::HasLoudNotification:
@@ -333,7 +333,7 @@ RoomlistModel::addRoom(const QString &room_id, bool suppressInsertNotification)
             emit totalUnreadMessageCountUpdated(total_unread_msgs);
         });
 
-        newRoom->updateLastMessage();
+        // newRoom->updateLastMessage();
 
         std::vector<QString> previewsToAdd;
         if (newRoom->isSpace()) {
diff --git a/src/timeline/TimelineModel.cpp b/src/timeline/TimelineModel.cpp
index 142ca79326b4e83ff8839f02f9f71a553ba1a43f..578d63b7dc38299a0e6f44bf5610167f70bfd2e3 100644
--- a/src/timeline/TimelineModel.cpp
+++ b/src/timeline/TimelineModel.cpp
@@ -353,14 +353,13 @@ TimelineModel::TimelineModel(TimelineViewManager *manager, QString room_id, QObj
   , manager_(manager)
   , permissions_{room_id_}
 {
-    lastMessage_.timestamp = 0;
-
     this->isEncrypted_ = cache::isRoomEncrypted(room_id_.toStdString());
 
     auto roomInfo            = cache::singleRoomInfo(room_id_.toStdString());
     this->isSpace_           = roomInfo.is_space;
     this->notification_count = roomInfo.notification_count;
     this->highlight_count    = roomInfo.highlight_count;
+    lastMessage_.timestamp   = roomInfo.approximate_last_modification_ts;
 
     // this connection will simplify adding the plainRoomNameChanged() signal everywhere that it
     // needs to be
@@ -1025,10 +1024,21 @@ isYourJoin(const mtx::events::Event<T> &)
     return false;
 }
 
+DescInfo
+TimelineModel::lastMessage() const
+{
+    if (lastMessage_.event_id.isEmpty())
+        QTimer::singleShot(0, this, &TimelineModel::updateLastMessage);
+
+    return lastMessage_;
+}
+
 void
 TimelineModel::updateLastMessage()
 {
-    for (auto it = events.size() - 1; it >= 0; --it) {
+    // only try to generate a preview for the last 1000 messages
+    auto end = std::max(events.size() - 1001, 0);
+    for (auto it = events.size() - 1; it >= end; --it) {
         auto event = events.get(it, decryptDescription);
         if (!event)
             continue;
diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h
index dae64094d2ca4f849a0067a29934c2cbb6101447..ec9a34f1f7b0d496c01d25748e4a22cc13c2397f 100644
--- a/src/timeline/TimelineModel.h
+++ b/src/timeline/TimelineModel.h
@@ -311,7 +311,9 @@ public:
     void sendMessageEvent(const T &content, mtx::events::EventType eventType);
     RelatedInfo relatedInfo(const QString &id);
 
-    DescInfo lastMessage() const { return lastMessage_; }
+    DescInfo lastMessage() const;
+    uint64_t lastMessageTimestamp() const { return lastMessage_.timestamp; }
+
     bool isSpace() const { return isSpace_; }
     bool isEncrypted() const { return isEncrypted_; }
     crypto::Trust trustlevel() const;