diff --git a/.github/workflows/auto-merge-dependabot.yml b/.github/workflows/auto-merge-dependabot.yml
index cf9bf5f..2df3a68 100644
--- a/.github/workflows/auto-merge-dependabot.yml
+++ b/.github/workflows/auto-merge-dependabot.yml
@@ -13,7 +13,7 @@ jobs:
     steps:
       - name: Fetch Dependabot metadata
         id: dependabot-metadata
-        uses: dependabot/fetch-metadata@dbb049abf0d677abbd7f7eee0375145b417fdd34
+        uses: dependabot/fetch-metadata@dbb049abf0d677abbd7f7eee0375145b417fdd34 # v2.2.0
       - name: Enable auto-merge for minor updates
         if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}
         env:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3e4d7a4..ef9d7ac 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -19,9 +19,9 @@ jobs:
     timeout-minutes: 1
     steps:
       - name: Checkout repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
       - name: Setup Java
-        uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018
+        uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
         with:
           distribution: temurin
           java-version: 17
@@ -40,15 +40,15 @@ jobs:
       security-events: write
     steps:
       - name: Checkout repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
       - name: Setup Java
-        uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018
+        uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
         with:
           distribution: temurin
           java-version: 17
           cache: maven
       - name: Initialize CodeQL
-        uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a
+        uses: github/codeql-action/init@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
         with:
           languages: java
           queries: security-and-quality
@@ -57,4 +57,4 @@ jobs:
       - name: Compile project
         run: ./mvnw compile
       - name: Perform CodeQL Analysis
-        uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a
\ No newline at end of file
+        uses: github/codeql-action/analyze@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 51a54e3..5871ab2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,9 +13,9 @@ jobs:
       packages: write
     steps:
       - name: Checkout repository
-        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
+        uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
       - name: Setup Java
-        uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018
+        uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
         with:
           distribution: temurin
           java-version: 17
diff --git a/codewars-sdk-client/pom.xml b/codewars-sdk-client/pom.xml
index 096dfbe..c40c057 100644
--- a/codewars-sdk-client/pom.xml
+++ b/codewars-sdk-client/pom.xml
@@ -50,6 +50,11 @@
       <artifactId>junit-jupiter</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/CodewarsClient.java b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/CodewarsClient.java
index 26e0d2c..c06fbf9 100644
--- a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/CodewarsClient.java
+++ b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/CodewarsClient.java
@@ -8,7 +8,7 @@
 import dev.noid.codewars.client.model.CompletedChallenges;
 import dev.noid.codewars.client.model.User;
 import java.util.List;
-import java.util.function.IntFunction;
+import java.util.Map;
 
 public final class CodewarsClient {
 
@@ -41,9 +41,11 @@ public List<AuthoredChallenge> listAuthoredChallenges(String idOrUsername) throw
 
   public List<CompletedChallenge> listCompletedChallenges(String idOrUsername) throws ApiException {
     CompletedChallenges firstPage = usersApi.listCompletedChallenges(idOrUsername, 0);
-    List<CompletedChallenge> recentChallenges = firstPage.getData();
-    IntFunction<List<CompletedChallenge>> pageLoader = page -> usersApi.listCompletedChallenges(idOrUsername, page).getData();
-    return new LazyList<>(pageLoader, recentChallenges, recentChallenges.size(), firstPage.getTotalItems());
+    return new PaginatedList<>(
+        page -> usersApi.listCompletedChallenges(idOrUsername, page).getData(),
+        firstPage.getTotalPages(),
+        firstPage.getTotalItems(),
+        Map.of(0, firstPage.getData()));
   }
 
   public CodeChallenge getCodeChallenge(String idOrSlag) throws ApiException {
diff --git a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/LazyList.java b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/LazyList.java
deleted file mode 100644
index 64cb425..0000000
--- a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/LazyList.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package dev.noid.codewars.client;
-
-import java.util.AbstractList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.function.IntFunction;
-
-public class LazyList<E> extends AbstractList<E> {
-
-  private final Map<Integer, E> loaded = new LinkedHashMap<>();
-  private final IntFunction<List<E>> pageLoader;
-  private final int totalItems;
-  private final int pageSize;
-
-  /**
-   * Create a LazyList backed by the given query, using pageSize results per page, and expecting numResults from the
-   * query.
-   */
-  public LazyList(IntFunction<List<E>> pageLoader, List<E> preload, int pageSize, int totalItems) {
-    this.pageLoader = pageLoader;
-    this.pageSize = pageSize;
-    this.totalItems = totalItems;
-    addItems(0, preload);
-  }
-
-  /**
-   * Fetch an item, loading it from the query results if it hasn't already been.
-   */
-  @Override
-  public E get(int i) {
-    if (i < 0 || i >= size()) {
-      throw new IllegalArgumentException(i + " is not valid index.");
-    }
-    if (!loaded.containsKey(i)) {
-      List<E> page = pageLoader.apply(i / pageSize);
-      addItems(i, page);
-    }
-    return loaded.get(i);
-  }
-
-  @Override
-  public int size() {
-    return totalItems;
-  }
-
-  private void addItems(int offset, List<E> items) {
-    for (int i = 0; i < items.size(); i++) {
-      loaded.put(offset + i, items.get(i));
-    }
-  }
-}
\ No newline at end of file
diff --git a/codewars-sdk-client/src/main/java/dev/noid/codewars/client/PaginatedList.java b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/PaginatedList.java
new file mode 100644
index 0000000..5a84d7f
--- /dev/null
+++ b/codewars-sdk-client/src/main/java/dev/noid/codewars/client/PaginatedList.java
@@ -0,0 +1,42 @@
+package dev.noid.codewars.client;
+
+import java.util.AbstractList;
+import java.util.List;
+import java.util.Map;
+import java.util.function.IntFunction;
+
+class PaginatedList<E> extends AbstractList<E> {
+
+  private final IntFunction<List<E>> pageLoader;
+  private final List<E>[] cache;
+  private final int totalItems;
+  private final int pageSize;
+
+  PaginatedList(IntFunction<List<E>> pageLoader, int totalPages, int totalItems, Map<Integer, List<E>> preloaded) {
+    this.pageLoader = pageLoader;
+    this.cache = new List[totalPages];
+    preloaded.forEach((pageNumber, page) -> cache[pageNumber] = page);
+    this.totalItems = totalItems;
+    this.pageSize = (int) Math.ceil((double) totalItems / totalPages);
+  }
+
+  @Override
+  public E get(int i) {
+    if (i < 0 || i >= size()) {
+      throw new IndexOutOfBoundsException();
+    }
+
+    int pageNumber = Math.floorDiv(i, pageSize);
+    List<E> page = cache[pageNumber];
+    if (page == null) {
+      page = pageLoader.apply(pageNumber);
+      cache[pageNumber] = page;
+    }
+    return page.get(i % pageSize);
+  }
+
+  @Override
+  public int size() {
+    return totalItems;
+  }
+}
diff --git a/codewars-sdk-client/src/test/java/dev/noid/codewars/client/LazyListTest.java b/codewars-sdk-client/src/test/java/dev/noid/codewars/client/LazyListTest.java
deleted file mode 100644
index b2a0129..0000000
--- a/codewars-sdk-client/src/test/java/dev/noid/codewars/client/LazyListTest.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package dev.noid.codewars.client;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class LazyListTest {
-
-}
\ No newline at end of file
diff --git a/codewars-sdk-client/src/test/java/dev/noid/codewars/client/PaginatedListTest.java b/codewars-sdk-client/src/test/java/dev/noid/codewars/client/PaginatedListTest.java
new file mode 100644
index 0000000..d09a131
--- /dev/null
+++ b/codewars-sdk-client/src/test/java/dev/noid/codewars/client/PaginatedListTest.java
@@ -0,0 +1,141 @@
+package dev.noid.codewars.client;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+class PaginatedListTest {
+
+  private final List<List<String>> remotePages = Mockito.spy(List.of(
+      List.of("a", "b", "c", "d", "e"),
+      List.of("f", "g", "h", "i", "j"),
+      List.of("k", "l", "m", "n", "o"),
+      List.of("p", "q", "r", "s", "t"),
+      List.of("u", "v", "w", "x", "y"),
+      List.of("z")
+  ));
+
+  @Test
+  void load_all_remote_pages() {
+    assertEquals(List.of(
+        "a", "b", "c", "d", "e",
+        "f", "g", "h", "i", "j",
+        "k", "l", "m", "n", "o",
+        "p", "q", "r", "s", "t",
+        "u", "v", "w", "x", "y",
+        "z"
+    ), getList(6, 26, Map.of()));
+
+    Mockito.verify(remotePages, Mockito.times(remotePages.size())).get(anyInt());
+    for (int page = 0; page < remotePages.size(); page++) {
+      Mockito.verify(remotePages, Mockito.times(1)).get(page);
+    }
+  }
+
+  @Test
+  void preload_first_page() {
+    assertEquals(List.of(
+        "1", "2", "3", "4", "5",
+        "f", "g", "h", "i", "j",
+        "k", "l", "m", "n", "o",
+        "p", "q", "r", "s", "t",
+        "u", "v", "w", "x", "y",
+        "z"
+    ), getList(6, 26, Map.of(0, List.of("1", "2", "3", "4", "5"))));
+
+    Mockito.verify(remotePages, Mockito.times(remotePages.size() - 1)).get(anyInt());
+    for (int page = 1; page < remotePages.size(); page++) {
+      Mockito.verify(remotePages, Mockito.times(1)).get(page);
+    }
+  }
+
+  @Test
+  void preload_last_page() {
+    assertEquals(List.of(
+        "a", "b", "c", "d", "e",
+        "f", "g", "h", "i", "j",
+        "k", "l", "m", "n", "o",
+        "p", "q", "r", "s", "t",
+        "u", "v", "w", "x", "y",
+        "1"
+    ), getList(6, 26, Map.of(5, List.of("1", "2", "3", "4", "5"))));
+
+    Mockito.verify(remotePages, Mockito.times(remotePages.size() - 1)).get(anyInt());
+    for (int page = 0; page < remotePages.size() - 1; page++) {
+      Mockito.verify(remotePages, Mockito.times(1)).get(page);
+    }
+  }
+
+  @Test
+  void load_no_remote_pages() {
+    Map<Integer, List<String>> preloaded = Map.of(
+        0, List.of("1", "2", "3", "4", "5"),
+        1, List.of("6", "7", "8", "9", "0"),
+        2, List.of("~", "!", "@", "#", "$"),
+        3, List.of("%", "^", "&", "*", "("),
+        4, List.of(")", "_", "+", "`", "-"),
+        5, List.of("=")
+    );
+
+    assertEquals(List.of(
+        "1", "2", "3", "4", "5",
+        "6", "7", "8", "9", "0",
+        "~", "!", "@", "#", "$",
+        "%", "^", "&", "*", "(",
+        ")", "_", "+", "`", "-",
+        "="
+    ), getList(6, 26, preloaded));
+
+    Mockito.verify(remotePages, Mockito.times(0)).get(anyInt());
+  }
+
+  @Test
+  void limit_pages_access() {
+    assertEquals(List.of(
+        "a", "b", "c", "d", "e",
+        "f", "g", "h", "i", "j",
+        "k", "l", "m"
+    ), getList(3, 13, Map.of()));
+
+    Mockito.verify(remotePages, Mockito.times(3)).get(anyInt());
+    Mockito.verify(remotePages, Mockito.times(1)).get(0);
+    Mockito.verify(remotePages, Mockito.times(1)).get(1);
+    Mockito.verify(remotePages, Mockito.times(1)).get(2);
+  }
+
+  @Test
+  void read_from_empty() {
+    List<String> list = getList(0, 0, Map.of());
+    assertTrue(list.isEmpty());
+    assertEquals(List.of(), list);
+  }
+
+  @Test
+  void constant_size() {
+    List<String> list = getList(6, 26, Map.of());
+    assertEquals(26, list.size());
+    for (String ignored : list) {
+      assertEquals(26, list.size());
+    }
+    assertEquals(26, list.size());
+  }
+
+  @Test
+  void immutability() {
+    List<String> list = getList(1, 1, Map.of());
+    assertThrows(UnsupportedOperationException.class, () -> list.add(0, "!"));
+    assertThrows(UnsupportedOperationException.class, () -> list.set(0, "!"));
+    assertThrows(UnsupportedOperationException.class, () -> list.remove(0));
+    assertThrows(UnsupportedOperationException.class, () -> list.remove("a"));
+  }
+
+  private List<String> getList(int totalPages, int totalItems, Map<Integer, List<String>> preloaded) {
+    return new PaginatedList<>(remotePages::get, totalPages, totalItems, preloaded);
+  }
+}
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 97f43a6..77269c0 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,6 +38,7 @@
     <jsr305.version>3.0.2</jsr305.version>
     <swagger.version>1.6.14</swagger.version>
     <junit.version>5.10.3</junit.version>
+    <mockito.version>5.12.0</mockito.version>
   </properties>
 
   <dependencyManagement>
@@ -63,6 +64,15 @@
         <groupId>org.junit.jupiter</groupId>
         <artifactId>junit-jupiter</artifactId>
         <version>${junit.version}</version>
+        <type>pom</type>
+        <scope>import</scope>
+      </dependency>
+      <dependency>
+        <groupId>org.mockito</groupId>
+        <artifactId>mockito-bom</artifactId>
+        <version>${mockito.version}</version>
+        <type>pom</type>
+        <scope>import</scope>
       </dependency>
     </dependencies>
   </dependencyManagement>