diff --git a/native/cpp/CommonCpp/DatabaseManagers/DatabaseQueryExecutor.h b/native/cpp/CommonCpp/DatabaseManagers/DatabaseQueryExecutor.h
--- a/native/cpp/CommonCpp/DatabaseManagers/DatabaseQueryExecutor.h
+++ b/native/cpp/CommonCpp/DatabaseManagers/DatabaseQueryExecutor.h
@@ -42,6 +42,8 @@
   virtual void removeDrafts(const std::vector<std::string> &ids) const = 0;
   virtual void removeAllMessages() const = 0;
   virtual std::vector<MessageEntity> getInitialMessages() const = 0;
+  virtual std::vector<MessageEntity>
+  fetchMessages(std::string threadID, int limit, int offset) const = 0;
   virtual void removeMessages(const std::vector<std::string> &ids) const = 0;
   virtual void
   removeMessagesForThreads(const std::vector<std::string> &threadIDs) const = 0;
@@ -190,6 +192,8 @@
   virtual std::vector<WebThread> getAllThreadsWeb() const = 0;
   virtual void replaceThreadWeb(const WebThread &thread) const = 0;
   virtual std::vector<MessageWithMedias> getInitialMessagesWeb() const = 0;
+  virtual std::vector<MessageWithMedias>
+  fetchMessagesWeb(std::string threadID, int limit, int offset) const = 0;
   virtual void replaceMessageWeb(const WebMessage &message) const = 0;
   virtual NullableString getOlmPersistAccountDataWeb(int accountID) const = 0;
   virtual std::vector<MessageWithMedias>
diff --git a/native/cpp/CommonCpp/DatabaseManagers/SQLiteQueryExecutor.h b/native/cpp/CommonCpp/DatabaseManagers/SQLiteQueryExecutor.h
--- a/native/cpp/CommonCpp/DatabaseManagers/SQLiteQueryExecutor.h
+++ b/native/cpp/CommonCpp/DatabaseManagers/SQLiteQueryExecutor.h
@@ -67,6 +67,8 @@
   void removeDrafts(const std::vector<std::string> &ids) const override;
   void removeAllMessages() const override;
   std::vector<MessageEntity> getInitialMessages() const override;
+  std::vector<MessageEntity>
+  fetchMessages(std::string threadID, int limit, int offset) const override;
   void removeMessages(const std::vector<std::string> &ids) const override;
   void removeMessagesForThreads(
       const std::vector<std::string> &threadIDs) const override;
@@ -207,6 +209,8 @@
   std::vector<WebThread> getAllThreadsWeb() const override;
   void replaceThreadWeb(const WebThread &thread) const override;
   std::vector<MessageWithMedias> getInitialMessagesWeb() const override;
+  std::vector<MessageWithMedias>
+  fetchMessagesWeb(std::string threadID, int limit, int offset) const override;
   void replaceMessageWeb(const WebMessage &message) const override;
   NullableString getOlmPersistAccountDataWeb(int accountID) const override;
   std::vector<MessageWithMedias>
diff --git a/native/cpp/CommonCpp/DatabaseManagers/SQLiteQueryExecutor.cpp b/native/cpp/CommonCpp/DatabaseManagers/SQLiteQueryExecutor.cpp
--- a/native/cpp/CommonCpp/DatabaseManagers/SQLiteQueryExecutor.cpp
+++ b/native/cpp/CommonCpp/DatabaseManagers/SQLiteQueryExecutor.cpp
@@ -1535,6 +1535,31 @@
   return this->processMessagesResults(preparedSQL);
 }
 
+std::vector<MessageEntity> SQLiteQueryExecutor::fetchMessages(
+    std::string threadID,
+    int limit,
+    int offset) const {
+  static std::string query =
+      "SELECT "
+      "  m.id, m.local_id, m.thread, m.user, m.type, m.future_type, "
+      "  m.content, m.time, media.id, media.container, media.thread, "
+      "  media.uri, media.type, media.extras "
+      "FROM messages AS m "
+      "LEFT JOIN media "
+      "  ON m.id = media.container "
+      "WHERE m.thread = ? "
+      "ORDER BY m.time DESC, m.id DESC "
+      "LIMIT ? OFFSET ?;";
+  SQLiteStatementWrapper preparedSQL(
+      SQLiteQueryExecutor::getConnection(), query, "Failed to fetch messages.");
+
+  bindStringToSQL(threadID.c_str(), preparedSQL, 1);
+  bindIntToSQL(limit, preparedSQL, 2);
+  bindIntToSQL(offset, preparedSQL, 3);
+
+  return this->processMessagesResults(preparedSQL);
+}
+
 std::vector<MessageEntity> SQLiteQueryExecutor::processMessagesResults(
     SQLiteStatementWrapper &preparedSQL) const {
   std::string prevMsgIdx{};
@@ -2852,6 +2877,14 @@
   return this->transformToWebMessages(messages);
 }
 
+std::vector<MessageWithMedias> SQLiteQueryExecutor::fetchMessagesWeb(
+    std::string threadID,
+    int limit,
+    int offset) const {
+  auto messages = this->fetchMessages(threadID, limit, offset);
+  return this->transformToWebMessages(messages);
+}
+
 void SQLiteQueryExecutor::replaceMessageWeb(const WebMessage &message) const {
   this->replaceMessage(message.toMessage());
 };
diff --git a/web/cpp/SQLiteQueryExecutorBindings.cpp b/web/cpp/SQLiteQueryExecutorBindings.cpp
--- a/web/cpp/SQLiteQueryExecutorBindings.cpp
+++ b/web/cpp/SQLiteQueryExecutorBindings.cpp
@@ -325,7 +325,8 @@
       .function(
           "updateMessageSearchIndex",
           &SQLiteQueryExecutor::updateMessageSearchIndex)
-      .function("searchMessages", &SQLiteQueryExecutor::searchMessagesWeb);
+      .function("searchMessages", &SQLiteQueryExecutor::searchMessagesWeb)
+      .function("fetchMessagesWeb", &SQLiteQueryExecutor::fetchMessagesWeb);
 }
 
 } // namespace comm
diff --git a/web/shared-worker/_generated/comm_query_executor.wasm b/web/shared-worker/_generated/comm_query_executor.wasm
index 0000000000000000000000000000000000000000..0000000000000000000000000000000000000000
GIT binary patch
literal 0
Hc$@<O00001

literal 0
Hc$@<O00001

diff --git a/web/shared-worker/queries/fetch-messages-queries.test.js b/web/shared-worker/queries/fetch-messages-queries.test.js
new file mode 100644
--- /dev/null
+++ b/web/shared-worker/queries/fetch-messages-queries.test.js
@@ -0,0 +1,109 @@
+// @flow
+
+import { messageTypes } from 'lib/types/message-types-enum.js';
+
+import { getDatabaseModule } from '../db-module.js';
+import { createNullableInt, createNullableString } from '../types/entities.js';
+import type {
+  MessageEntity,
+  WebMessage,
+} from '../types/sqlite-query-executor.js';
+import { clearSensitiveData } from '../utils/db-utils.js';
+
+const FILE_PATH = 'test.sqlite';
+
+describe('Fetch messages queries', () => {
+  let queryExecutor;
+  let dbModule;
+  const threadID = '123';
+  const userID = '124';
+
+  beforeAll(async () => {
+    dbModule = getDatabaseModule();
+
+    if (!dbModule) {
+      throw new Error('Database module is missing');
+    }
+    queryExecutor = new dbModule.SQLiteQueryExecutor(FILE_PATH);
+    if (!queryExecutor) {
+      throw new Error('SQLiteQueryExecutor is missing');
+    }
+
+    for (let i = 0; i < 50; i++) {
+      const message: WebMessage = {
+        id: i.toString(),
+        localID: createNullableString(),
+        thread: threadID,
+        user: userID,
+        type: messageTypes.TEXT,
+        futureType: createNullableInt(),
+        content: createNullableString(`text-${i}`),
+        time: i.toString(),
+      };
+      queryExecutor.replaceMessageWeb(message);
+    }
+  });
+
+  afterAll(() => {
+    clearSensitiveData(dbModule, FILE_PATH, queryExecutor);
+  });
+
+  function assertMessageEqual(message: MessageEntity, id: number) {
+    const expected: WebMessage = {
+      id: id.toString(),
+      localID: createNullableString(),
+      thread: threadID,
+      user: userID,
+      type: messageTypes.TEXT,
+      futureType: createNullableInt(),
+      content: createNullableString(`text-${id}`),
+      time: id.toString(),
+    };
+    expect(message.message).toEqual(expected);
+  }
+
+  it('should fetch the first messages', () => {
+    const result = queryExecutor.fetchMessagesWeb(threadID, 5, 0);
+    expect(result.length).toBe(5);
+    for (let i = 0; i < 5; i++) {
+      assertMessageEqual(result[i], 49 - i);
+    }
+  });
+
+  it('should fetch the following messages', () => {
+    const result = queryExecutor.fetchMessagesWeb(threadID, 5, 5);
+    expect(result.length).toBe(5);
+    for (let i = 0; i < 5; i++) {
+      assertMessageEqual(result[i], 44 - i);
+    }
+  });
+
+  it('should fetch the last messages', () => {
+    const result = queryExecutor.fetchMessagesWeb(threadID, 5, 45);
+    expect(result.length).toBe(5);
+    for (let i = 0; i < 5; i++) {
+      assertMessageEqual(result[i], 4 - i);
+    }
+  });
+
+  it('should check if thread ID matches', () => {
+    const result = queryExecutor.fetchMessagesWeb('000', 5, 45);
+    expect(result.length).toBe(0);
+  });
+
+  it('should fetch the remaining messages when limit exceeds the bounds', () => {
+    const result = queryExecutor.fetchMessagesWeb(threadID, 100, 40);
+    expect(result.length).toBe(10);
+    for (let i = 0; i < 10; i++) {
+      assertMessageEqual(result[i], 9 - i);
+    }
+  });
+
+  it('should return all the messages when limit is high enough', () => {
+    const result = queryExecutor.fetchMessagesWeb(threadID, 500, 0);
+    expect(result.length).toBe(50);
+    for (let i = 0; i < 50; i++) {
+      assertMessageEqual(result[i], 49 - i);
+    }
+  });
+});
diff --git a/web/shared-worker/types/entities.js b/web/shared-worker/types/entities.js
--- a/web/shared-worker/types/entities.js
+++ b/web/shared-worker/types/entities.js
@@ -180,4 +180,5 @@
   clientDBMessageInfoToWebMessage,
   webMessageToClientDBMessageInfo,
   createNullableString,
+  createNullableInt,
 };
diff --git a/web/shared-worker/types/sqlite-query-executor.js b/web/shared-worker/types/sqlite-query-executor.js
--- a/web/shared-worker/types/sqlite-query-executor.js
+++ b/web/shared-worker/types/sqlite-query-executor.js
@@ -48,7 +48,7 @@
   +version: number,
 };
 
-type MessageEntity = {
+export type MessageEntity = {
   +message: WebMessage,
   +medias: $ReadOnlyArray<Media>,
 };
@@ -63,6 +63,11 @@
   removeDrafts(ids: $ReadOnlyArray<string>): void;
 
   getInitialMessagesWeb(): $ReadOnlyArray<MessageEntity>;
+  fetchMessagesWeb(
+    threadID: string,
+    limit: number,
+    offset: number,
+  ): $ReadOnlyArray<MessageEntity>;
   removeAllMessages(): void;
   removeMessages(ids: $ReadOnlyArray<string>): void;
   removeMessagesForThreads(threadIDs: $ReadOnlyArray<string>): void;