diff --git a/src/client/actions/ephemeral.cpp b/src/client/actions/ephemeral.cpp
--- a/src/client/actions/ephemeral.cpp
+++ b/src/client/actions/ephemeral.cpp
@@ -50,7 +50,13 @@
 
         m.addJob(std::move(job));
 
-        return { std::move(m), lager::noop };
+        auto userId = m.userId;
+        auto [next, _] = ClientModel::update(std::move(m), UpdateRoomAction{
+            a.roomId,
+            UpdateLocalReadMarkerAction{a.eventId, userId},
+        });
+
+        return { std::move(next), lager::noop };
     }
 
     ClientResult processResponse(ClientModel m, PostReceiptResponse r)
diff --git a/src/client/room/room-model.hpp b/src/client/room/room-model.hpp
--- a/src/client/room/room-model.hpp
+++ b/src/client/room/room-model.hpp
@@ -166,6 +166,13 @@
         std::string myUserId;
     };
 
+    /// Update the local read marker, removing any read notifications before it.
+    struct UpdateLocalReadMarkerAction
+    {
+        std::string localReadMarker;
+        std::string myUserId;
+    };
+
     inline bool operator==(const PendingRoomKeyEvent &a, const PendingRoomKeyEvent &b)
     {
         return a.txnId == b.txnId && a.messages == b.messages;
@@ -340,7 +347,8 @@
             UpdateJoinedMemberCountAction,
             UpdateInvitedMemberCountAction,
             AddLocalNotificationsAction,
-            RemoveReadLocalNotificationsAction
+            RemoveReadLocalNotificationsAction,
+            UpdateLocalReadMarkerAction
             >;
 
         static RoomModel update(RoomModel r, Action a);
diff --git a/src/client/room/room-model.cpp b/src/client/room/room-model.cpp
--- a/src/client/room/room-model.cpp
+++ b/src/client/room/room-model.cpp
@@ -20,6 +20,21 @@
 
 inline const auto receiptTypes = immer::flex_vector<std::string>{"m.read", "m.read.private"};
 
+template<class Func>
+static std::string getMaxInTimeline(std::string a, std::string b, Func sortKey)
+{
+    if (a.empty() && b.empty()) {
+        return std::string();
+    } else {
+        // for an unexisting event, event id is empty and timestamp is 0
+        // for an existing event, event id is not empty and timestamp >= 0
+        // so this handles all cases even when we do not have the corresponding event
+        return std::max(a, b, [=](const std::string &x, const std::string &y) {
+            return sortKey(x) < sortKey(y);
+        });
+    }
+}
+
 namespace Kazv
 {
     PendingRoomKeyEvent makePendingRoomKeyEventV0(std::string txnId, Event event, immer::map<std::string, immer::flex_vector<std::string>> devices)
@@ -328,7 +343,11 @@
                 return r;
             },
             [&](AddLocalNotificationsAction a) {
-                auto readReceiptForCurrentUser = r.readReceipts.count(a.myUserId) ? r.readReceipts[a.myUserId].eventId : ""s;
+                auto k = [r](const auto &id) {
+                    return sortKeyForTimelineEvent(r.messages[id]);
+                };
+
+                auto readReceiptForCurrentUser = getMaxInTimeline(r.readReceipts[a.myUserId].eventId, r.localReadMarker, k);
 
                 auto newEventIds = intoImmer(
                     immer::flex_vector<std::string>{},
@@ -336,10 +355,6 @@
                     a.newEvents
                 );
 
-                auto k = [r](const auto &id) {
-                    return sortKeyForTimelineEvent(r.messages[id]);
-                };
-
                 auto needToAddPredicate = [a, r, k, readReceiptForCurrentUser](const auto &eid) {
                     if (!readReceiptForCurrentUser.empty()
                         && k(readReceiptForCurrentUser) >= k(eid)) {
@@ -355,12 +370,6 @@
                 return r;
             },
             [&](RemoveReadLocalNotificationsAction a) {
-                if (!r.readReceipts.count(a.myUserId)) {
-                    return r;
-                }
-
-                auto rr = r.readReceipts[a.myUserId].eventId;
-
                 auto k = [r](const auto &id) {
                     return sortKeyForTimelineEvent(r.messages[id]);
                 };
@@ -368,6 +377,15 @@
                     return k(a) < k(b);
                 };
 
+                auto rr = getMaxInTimeline(
+                    r.readReceipts[a.myUserId].eventId,
+                    r.localReadMarker,
+                    k
+                );
+                if (rr.empty()) {
+                    return r;
+                }
+
                 auto it = std::upper_bound(
                     r.unreadNotificationEventIds.begin(),
                     r.unreadNotificationEventIds.end(),
@@ -385,6 +403,11 @@
                     r.unreadNotificationEventIds = std::move(r.unreadNotificationEventIds).erase(0, it.index());
                 }
                 return r;
+            },
+            [&](UpdateLocalReadMarkerAction a) {
+                r.localReadMarker = a.localReadMarker;
+                auto next = RoomModel::update(std::move(r), RemoveReadLocalNotificationsAction{a.myUserId});
+                return next;
             }
             );
     }
diff --git a/src/client/room/room.hpp b/src/client/room/room.hpp
--- a/src/client/room/room.hpp
+++ b/src/client/room/room.hpp
@@ -340,6 +340,13 @@
         /* lager::reader<bool> */
         KAZV_WRAP_ATTR(RoomModel, roomCursor(), membersFullyLoaded);
 
+        /**
+         * Get the local read marker in this room.
+         *
+         * @return a lager::reader of a std::string of the local read marker.
+         */
+        auto localReadMarker() const -> lager::reader<std::string>;
+
         /**
          * Get the ids of the heroes of the room.
          *
diff --git a/src/client/room/room.cpp b/src/client/room/room.cpp
--- a/src/client/room/room.cpp
+++ b/src/client/room/room.cpp
@@ -278,6 +278,12 @@
         return roomCursor()[&RoomModel::encrypted];
     }
 
+    auto Room::localReadMarker() const
+        -> lager::reader<std::string>
+    {
+        return roomCursor()[&RoomModel::localReadMarker];
+    }
+
     auto Room::heroIds() const
         -> lager::reader<immer::flex_vector<std::string>>
     {
diff --git a/src/tests/client/room/read-receipt-test.cpp b/src/tests/client/room/read-receipt-test.cpp
--- a/src/tests/client/room/read-receipt-test.cpp
+++ b/src/tests/client/room/read-receipt-test.cpp
@@ -163,6 +163,25 @@
     REQUIRE(readers2 == expected2);
 }
 
+TEST_CASE("localReadMarker", "[client][room][receipt]")
+{
+    boost::asio::io_context io;
+    AsioPromiseHandler ph{io.get_executor()};
+
+    auto tl = EventList{
+        makeEvent(),
+    };
+    auto room = makeRoom(withRoomTimeline(tl));
+    room.localReadMarker = tl[0].id();
+    auto model = makeClient(withRoom(room));
+    auto store = createTestClientStoreFrom(model, ph);
+    auto client = Client(store.reader().map([](auto c) { return SdkModel{c}; }),
+        store,
+        std::nullopt);
+    auto r = client.room(room.roomId);
+    REQUIRE(r.localReadMarker().get() == tl[0].id());
+}
+
 TEST_CASE("Posting receipts", "[client][room][receipt]")
 {
     auto r = makeRoom();
@@ -217,6 +236,25 @@
     });
 }
 
+static auto pushRules = PushRulesDesc(Event(R"({
+  "type": "m.push_rules",
+  "content": {
+    "global": {
+      "override": [{
+        "rule_id": "moe.kazv.mxc.some-rule",
+        "default": true,
+        "enabled": true,
+        "conditions": [{
+          "kind": "event_match",
+          "key": "type",
+          "pattern": "moe.kazv.mxc.test-event"
+        }],
+        "actions": ["notify"]
+      }]
+    }
+  }
+})"_json));
+
 TEST_CASE("AddLocalNotificationsAction", "[client][room][receipt]")
 {
     auto myUserId = "@mew:example.com"s;
@@ -243,24 +281,7 @@
     WHEN("push rules notifies some") {
         auto next = RoomModel::update(r, AddLocalNotificationsAction{
             newEvents,
-            PushRulesDesc(Event(R"({
-  "type": "m.push_rules",
-  "content": {
-    "global": {
-      "override": [{
-        "rule_id": "moe.kazv.mxc.some-rule",
-        "default": true,
-        "enabled": true,
-        "conditions": [{
-          "kind": "event_match",
-          "key": "type",
-          "pattern": "moe.kazv.mxc.test-event"
-        }],
-        "actions": ["notify"]
-      }]
-    }
-  }
-})"_json)),
+            pushRules,
             myUserId,
         });
         REQUIRE(
@@ -271,6 +292,48 @@
             }
         );
     }
+
+    WHEN("push rules notifies some but is before local read marker, only things after local read marker is added") {
+        r.localReadMarker = newEvents[3].id();
+        auto next = RoomModel::update(r, AddLocalNotificationsAction{
+            newEvents,
+            pushRules,
+            myUserId,
+        });
+
+        REQUIRE(
+            next.unreadNotificationEventIds
+            == immer::flex_vector<std::string>{
+                newEvents[4].id(),
+            }
+        );
+    }
+}
+
+TEST_CASE("PostReceiptAction should update local read marker and remove read notifications")
+{
+    auto myUserId = "@mew:example.com"s;
+
+    auto newEvents = EventList{
+        makeEvent(withEventType("moe.kazv.mxc.test-event")),
+        makeEvent(withEventType("moe.kazv.mxc.test-event")),
+        makeEvent(withEventType("moe.kazv.mxc.test-event")),
+    };
+    auto r = makeRoom(withRoomTimeline(newEvents));
+    r.readReceipts = {{myUserId, ReadReceipt{newEvents[0].id(), 0}}};
+    r = RoomModel::update(r, AddLocalNotificationsAction{
+        newEvents,
+        pushRules,
+        myUserId,
+    });
+
+    auto client = makeClient(withRoom(r));
+    client.userId = myUserId;
+    auto receiptPos = newEvents[1].id();
+    auto [next, _] = ClientModel::update(client, PostReceiptAction{r.roomId, receiptPos});
+    auto nextRoom = next.roomList.rooms.at(r.roomId);
+    REQUIRE(nextRoom.localReadMarker == receiptPos);
+    REQUIRE(nextRoom.unreadNotificationEventIds == immer::flex_vector<std::string>{newEvents[2].id()});
 }
 
 TEST_CASE("RemoveReadLocalNotificationsAction", "[client][room][receipt]")