<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lê Thanh Trúc</title>
    <description>The latest articles on DEV Community by Lê Thanh Trúc (@lttruc1402).</description>
    <link>https://dev.to/lttruc1402</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1052772%2F28e8d0fc-98df-47bd-adcf-e34effe59dcd.jpeg</url>
      <title>DEV Community: Lê Thanh Trúc</title>
      <link>https://dev.to/lttruc1402</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lttruc1402"/>
    <language>en</language>
    <item>
      <title>Why Skip/Take gets slower on every page (and how keyset pagination fixes it)</title>
      <dc:creator>Lê Thanh Trúc</dc:creator>
      <pubDate>Tue, 07 Jul 2026 01:38:16 +0000</pubDate>
      <link>https://dev.to/lttruc1402/why-skiptake-gets-slower-on-every-page-and-how-keyset-pagination-fixes-it-2jmn</link>
      <guid>https://dev.to/lttruc1402/why-skiptake-gets-slower-on-every-page-and-how-keyset-pagination-fixes-it-2jmn</guid>
      <description>&lt;p&gt;A while back I was debugging an API where page 1 returned in 5ms and page&lt;br&gt;
50,000 took several seconds. Same query, same table, same indexes. The only&lt;br&gt;
difference was one number in the URL.&lt;/p&gt;

&lt;p&gt;This post is about why that happens, and how keyset pagination fixes it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem with OFFSET
&lt;/h2&gt;

&lt;p&gt;Here's what most of us write on day one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Products&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Skip&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToListAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;EF Core translates this to &lt;code&gt;OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY&lt;/code&gt;&lt;br&gt;
(or &lt;code&gt;LIMIT/OFFSET&lt;/code&gt; on Postgres and MySQL). Looks harmless. The problem is in&lt;br&gt;
how the database executes it: &lt;strong&gt;it cannot jump to row 1,000,000&lt;/strong&gt;. It walks&lt;br&gt;
the index from the beginning, reads every row before your offset, and throws&lt;br&gt;
them away.&lt;/p&gt;

&lt;p&gt;So the cost is O(offset + pageSize). Page 1 reads 20 rows. Page 50,000 reads&lt;br&gt;
a million rows to return the same 20. That's the whole bug — pagination&lt;br&gt;
depth becomes a hidden table scan.&lt;/p&gt;

&lt;p&gt;There's a second, sneakier problem: &lt;strong&gt;offset pagination is unstable under&lt;br&gt;
writes&lt;/strong&gt;. If someone inserts a row while your user is between page 3 and&lt;br&gt;
page 4, every row shifts by one. The user sees the last item of page 3 again&lt;br&gt;
at the top of page 4 — or worse, misses a row entirely. For feeds and&lt;br&gt;
exports this means duplicated and lost data.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keyset pagination (a.k.a. the seek method)
&lt;/h2&gt;

&lt;p&gt;Instead of telling the database "skip N rows", you tell it &lt;strong&gt;where the last&lt;br&gt;
page ended&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;TOP&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Products&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;lastCreatedAt&lt;/span&gt;
   &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;lastCreatedAt&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="n"&gt;lastId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With an index on &lt;code&gt;(CreatedAt DESC, Id ASC)&lt;/code&gt;, this is a single index seek.&lt;br&gt;
Cost: O(pageSize). Page 1 and page 1,000,000 are the same query with&lt;br&gt;
different parameters. And because the cursor points at an exact row (that's&lt;br&gt;
why you need a unique tie-breaker column like &lt;code&gt;Id&lt;/code&gt;), concurrent inserts&lt;br&gt;
can't shift your pages.&lt;/p&gt;

&lt;p&gt;The trade-off: you lose random access. There's no "jump to page 57" —&lt;br&gt;
only next and previous. For infinite scroll, feeds, exports, and sync APIs,&lt;br&gt;
that's exactly the access pattern anyway.&lt;/p&gt;
&lt;h2&gt;
  
  
  So why doesn't everyone do this?
&lt;/h2&gt;

&lt;p&gt;Because writing those WHERE clauses by hand hurts. The two-column example&lt;br&gt;
above is the easy case. Add a third sort column, mix ascending and&lt;br&gt;
descending directions, and the predicate explodes into nested ORs that are&lt;br&gt;
easy to get subtly wrong — and a subtly wrong keyset predicate doesn't&lt;br&gt;
throw, it just silently skips rows.&lt;/p&gt;

&lt;p&gt;You also need to serialize the cursor position to the client somehow,&lt;br&gt;
ideally without leaking your key values, and handle paging backwards.&lt;/p&gt;

&lt;p&gt;After writing this by hand a few times, I built a library so I'd never have&lt;br&gt;
to again.&lt;/p&gt;
&lt;h2&gt;
  
  
  SeekKit.EntityFramework
&lt;/h2&gt;

&lt;p&gt;MIT licensed, &lt;a href="https://www.nuget.org/packages/SeekKit.EntityFramework" rel="noopener noreferrer"&gt;on NuGet&lt;/a&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Program.cs&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSeekKit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Strategy&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DatabaseStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForSqlServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefaultPageSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// anywhere you have an IQueryable&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;seek&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SeekAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsActive&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;SeekRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Token&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PageSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderByDescending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OrderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// unique tie-breaker last&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result carries &lt;code&gt;Items&lt;/code&gt;, &lt;code&gt;NextToken&lt;/code&gt;, &lt;code&gt;PreviousToken&lt;/code&gt;, &lt;code&gt;HasNext&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;HasPrevious&lt;/code&gt;. Tokens are opaque Base64url strings — clients pass them back&lt;br&gt;
to navigate, without ever seeing your key values.&lt;/p&gt;

&lt;p&gt;A few design decisions I want to highlight, because they're where the actual&lt;br&gt;
work went:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each database gets different SQL.&lt;/strong&gt; PostgreSQL supports row-value&lt;br&gt;
comparison — &lt;code&gt;WHERE (a, b) &amp;gt; (@a, @b)&lt;/code&gt; — which the planner turns into a&lt;br&gt;
clean index seek, so SeekKit uses it there. SQL Server doesn't support&lt;br&gt;
row-values in comparisons, so it gets a &lt;code&gt;UNION ALL&lt;/code&gt; + &lt;code&gt;TOP N&lt;/code&gt; pattern&lt;br&gt;
instead, which optimizes better than nested ORs. MySQL, Oracle, and SQLite&lt;br&gt;
have their own strategies. Everything is built as LINQ expression trees, so&lt;br&gt;
your EF Core provider does the final translation — no raw SQL anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mixed sort directions just work.&lt;/strong&gt; &lt;code&gt;Status ASC, Priority DESC, Id ASC&lt;/code&gt; is&lt;br&gt;
fine. When a fast strategy can't handle the shape (e.g. tuple comparison&lt;br&gt;
requires uniform direction), it falls back automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tokens can be signed.&lt;/strong&gt; By default tokens are opaque but not&lt;br&gt;
tamper-proof. One config line turns on HMAC-SHA256 signing, and forged or&lt;br&gt;
modified tokens are rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSeekKit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Strategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DatabaseStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ForSqlServer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;  &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseHmacSigning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"SeekKit:TokenKey"&lt;/span&gt;&lt;span class="p"&gt;]!));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It targets .NET 8/9/10 (EF Core 8/9/10) and .NET Standard 2.1 (EF Core 5).&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the benchmark yourself
&lt;/h2&gt;

&lt;p&gt;The repo has a runnable example: a .NET 10 minimal API with two endpoints —&lt;br&gt;
&lt;code&gt;/products&lt;/code&gt; (keyset) and &lt;code&gt;/products/offset&lt;/code&gt; (offset) — both returning&lt;br&gt;
&lt;code&gt;elapsedMs&lt;/code&gt;, plus a docker compose file for SQL Server and a resumable seed&lt;br&gt;
script that can generate millions (or billions, if your disk dares) of rows:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/lttruc1402/SeekKit.EntityFramework" rel="noopener noreferrer"&gt;https://github.com/lttruc1402/SeekKit.EntityFramework&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Seed a few million rows, hit a deep offset page, then walk the same distance&lt;br&gt;
with tokens. The difference sells itself.&lt;/p&gt;




&lt;p&gt;This is my first open-source library, and I'd love feedback — on the API,&lt;br&gt;
the per-database strategy approach, or edge cases I haven't thought of.&lt;br&gt;
And I'm curious: how do you handle pagination on big tables? Comments open 👇&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>database</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
