Tìm hiểu cách xây danh sách dashboard nhanh với 100k hàng bằng phân trang, virtualization, lọc thông minh và truy vấn tinh gọn để công cụ nội bộ vẫn mượt.

Màn hình danh sách thường vẫn ổn cho đến khi không còn ổn nữa. Người dùng bắt đầu thấy gián đoạn nhỏ tích tụ: cuộn bị giật, trang đứng một lúc sau mỗi cập nhật, bộ lọc phản hồi chậm vài giây, và bạn thấy spinner sau mỗi nhấp chuột. Đôi khi tab trình duyệt có vẻ treo vì luồng UI đang bận.
100k hàng là một ngưỡng phổ biến vì nó gây áp lực lên mọi phần của hệ thống cùng lúc. Dữ liệu vẫn bình thường với database, nhưng đủ lớn để làm lộ các bất lợi nhỏ trong trình duyệt và trên mạng. Nếu bạn cố hiển thị mọi thứ cùng lúc, một màn hình đơn giản biến thành một pipeline nặng.
Mục tiêu không phải là render tất cả các hàng. Mục tiêu là giúp người dùng tìm nhanh những gì họ cần: 50 hàng phù hợp, trang tiếp theo, hoặc một lát hẹp dựa trên bộ lọc.
Nên chia công việc thành bốn phần:
Nếu bất kỳ phần nào tốn kém, cả màn hình đều cảm thấy chậm. Một ô tìm kiếm đơn giản có thể kích hoạt truy vấn sắp xếp 100k hàng, trả về hàng nghìn bản ghi, rồi ép trình duyệt phải render tất cả. Đó là lý do việc gõ phím trở nên ì ạch.
Khi các đội xây dựng công cụ nội bộ nhanh (kể cả với các nền tảng vibe-coding như Koder.ai), màn hình danh sách thường là nơi đầu tiên khi dữ liệu thực tế phơi bày khoảng cách giữa “chạy tốt trên dataset demo” và “cảm thấy tức thì mỗi ngày.”
Trước khi tối ưu, hãy quyết định “nhanh” nghĩa là gì cho màn hình này. Nhiều đội truy đuổi throughput (tải mọi thứ) trong khi người dùng chủ yếu cần latency thấp (thấy cập nhật nhanh). Một danh sách có thể cảm thấy tức thì ngay cả khi không bao giờ tải hết 100k hàng, miễn là nó phản hồi nhanh khi cuộn, sắp xếp và lọc.
Mục tiêu thực tế là thời gian đến hàng đầu (time to first row), không phải thời gian tải toàn bộ. Người dùng tin tưởng trang khi họ thấy 20–50 hàng đầu nhanh và các tương tác vẫn mượt.
Chọn một vài con số nhỏ để theo dõi mỗi khi bạn thay đổi:
COUNT(*) và các SELECT rộng)Chúng ánh xạ tới các triệu chứng phổ biến. Nếu CPU trình duyệt tăng vọt khi cuộn, frontend đang làm quá nhiều việc cho mỗi hàng. Nếu spinner chờ lâu nhưng cuộn thì ổn, thường là backend hoặc mạng. Nếu request nhanh nhưng trang vẫn đứng, thường là việc render hoặc xử lý nặng phía client.
Thử một thí nghiệm đơn giản: giữ UI như cũ, nhưng tạm thời giới hạn backend chỉ trả 20 hàng với cùng bộ lọc. Nếu trở nên nhanh, nút cổ chai là kích thước tải hoặc thời gian truy vấn. Nếu vẫn chậm, hãy xem rendering, định dạng và các component xử lý trên mỗi hàng.
Ví dụ: màn hình Orders nội bộ chậm khi gõ tìm kiếm. Nếu API trả 5.000 hàng và trình duyệt lọc chúng trên mỗi phím bấm, việc gõ sẽ lag. Nếu API mất 2 giây vì một truy vấn COUNT trên bộ lọc không có index, bạn sẽ thấy phải chờ trước khi bất kỳ hàng nào thay đổi. Các cách sửa khác nhau, cùng một than phiền người dùng.
Trình duyệt thường là nút cổ chai đầu tiên. Một danh sách có thể cảm thấy chậm ngay cả khi API nhanh, đơn giản vì trang đang cố paint quá nhiều. Quy tắc đầu tiên: đừng render hàng nghìn hàng trong DOM cùng lúc.
Ngay cả trước khi thêm virtualization đầy đủ, giữ mỗi hàng nhẹ. Một hàng với nhiều wrapper lồng nhau, icon, tooltip và style điều kiện phức tạp ở mỗi ô sẽ tốn chi phí khi cuộn và mỗi cập nhật. Ưu tiên văn bản thuần, vài badge nhỏ, và chỉ một hoặc hai yếu tố tương tác trên mỗi hàng.
Chiều cao hàng ổn định giúp nhiều hơn bạn nghĩ. Khi mọi hàng cùng chiều cao, trình duyệt dự đoán layout dễ hơn và cuộn mượt. Hàng cao biến đổi (mô tả xuống dòng, ghi chú mở rộng, avatar lớn) kích hoạt đo lường thêm và reflow. Nếu cần chi tiết thêm, cân nhắc panel bên hoặc khu vực có thể bung/thu, không phải hàng nhiều dòng.
Định dạng là một khoản phí thầm lặng khác. Ngày tháng, tiền tệ và thao tác chuỗi nặng cộng lại khi lặp nhiều ô.
Nếu một giá trị không hiển thị, đừng tính nó ngay. Cache kết quả định dạng tốn kém và tính khi cần, ví dụ khi hàng trở nên hiển thị hoặc khi người dùng mở hàng.
Một số bước nhanh thường mang lại lợi ích rõ:
Ví dụ: bảng Invoices nội bộ định dạng 12 cột tiền và ngày sẽ giật khi cuộn. Cache giá trị định dạng cho mỗi hóa đơn và trì hoãn công việc cho hàng ngoài màn hình có thể làm nó cảm thấy tức thì, ngay cả trước khi tối ưu backend sâu hơn.
Virtualization nghĩa là bảng chỉ vẽ những hàng bạn thực sự thấy (cộng buffer nhỏ trên/dưới). Khi cuộn, nó tái sử dụng cùng DOM và thay dữ liệu bên trong. Điều đó ngăn trình duyệt phải paint hàng chục nghìn component hàng cùng lúc.
Virtualization phù hợp khi bạn có danh sách dài, bảng rộng hoặc hàng nặng (avatar, chip trạng thái, menu hành động, tooltip). Nó cũng hữu ích khi người dùng cuộn nhiều và mong đợi một trải nghiệm liên tục thay vì nhảy trang.
Nó không phải phép màu. Một vài vấn đề thường gây bất ngờ:
Cách đơn giản nhất thường là nhàm chán: chiều cao hàng cố định, cột dự đoán được, và không quá nhiều widget tương tác trong mỗi hàng.
Bạn có thể kết hợp: dùng phân trang (hoặc load-more theo cursor) để giới hạn dữ liệu fetch từ server, và virtualization để giữ render rẻ bên trong lát đã fetch.
Mẫu thực tế là fetch kích thước trang bình thường (thường 100–500 hàng), virtualize trong trang đó, và cung cấp điều khiển rõ ràng để chuyển giữa các trang. Nếu dùng infinite scroll, thêm chỉ báo "Đã tải X trên Y" để người dùng hiểu họ chưa nhìn thấy tất cả.
Nếu cần màn hình danh sách dùng được khi dữ liệu lớn, phân trang thường là mặc định an toàn. Nó dự đoán được, phù hợp cho workflow admin (xem xét, chỉnh sửa, phê duyệt), và hỗ trợ nhu cầu như export "trang 3 với bộ lọc này" mà không ngạc nhiên. Nhiều đội quay lại phân trang sau khi thử cuộn cầu kỳ.
Infinite scroll có thể dễ chịu cho duyệt thông thường, nhưng có chi phí tiềm ẩn. Mọi người mất cảm giác vị trí, back button thường không trả họ về đúng chỗ, và phiên dài có thể tích tụ bộ nhớ khi nhiều hàng được tải. Một giải pháp trung gian là nút Load more vẫn dùng cơ chế phân trang, để người dùng giữ định hướng.
Offset là cách cổ điển page=10&size=50. Nó đơn giản, nhưng có thể chậm trên bảng lớn vì database phải bỏ qua nhiều hàng để tới các trang sau. Nó cũng có thể khiến mọi thứ dịch trang khi có hàng mới.
Keyset pagination (hay cursor) hỏi cho "50 hàng tiếp theo sau mục đã thấy cuối cùng", thường dùng id hoặc created_at. Nó thường giữ nhanh vì không cần đếm và bỏ qua nhiều hàng.
Quy tắc thực tế:
Người dùng thích thấy tổng, nhưng một count all matching rows có thể tốn kém với bộ lọc nặng. Các lựa chọn: cache counts cho bộ lọc phổ biến, cập nhật count ở nền sau sau khi trang tải, hoặc hiển thị số gần đúng (ví dụ "10.000+").
Ví dụ: màn hình Orders nội bộ có thể hiện kết quả ngay bằng keyset pagination, sau đó điền tổng chính xác chỉ khi người dùng dừng thay đổi bộ lọc một giây.
Nếu bạn xây dựng trong Koder.ai, xử lý phân trang và hành vi count như một phần của spec màn hình sớm, để backend và trạng thái UI sinh ra không đối đầu nhau sau này.
Hầu hết màn hình danh sách chậm vì chúng bắt đầu mở rộng: tải mọi thứ rồi bắt người dùng thu hẹp. Hãy đảo lại. Bắt đầu với mặc định hợp lý trả về một lát nhỏ hữu ích (ví dụ: 7 ngày gần nhất, My items, Status: Open), và làm rõ ràng lựa chọn All time.
Tìm kiếm văn bản là bẫy phổ biến khác. Nếu bạn chạy truy vấn trên mỗi phím bấm, bạn tạo backlog request và UI chớp nháy. Debounce input tìm kiếm để chỉ query khi người dùng tạm dừng, và huỷ các request cũ khi request mới bắt đầu. Quy tắc đơn giản: nếu người dùng còn đang gõ, đừng gọi server.
Lọc chỉ cảm thấy nhanh khi rõ ràng. Hiển thị chip bộ lọc ở đầu bảng để người dùng thấy điều gì đang bật và có thể bỏ bằng một click. Giữ nhãn chip thân thiện, không phải tên trường thô (ví dụ Owner: Sam thay vì owner_id=42). Khi ai đó nói "kết quả của tôi biến mất", thường là có bộ lọc vô hình.
Các mẫu giúp danh sách lớn vẫn phản hồi mà không làm UI phức tạp:
Saved views là người hùng thầm lặng. Thay vì dạy người dùng build combo bộ lọc một lần, cho họ một vài preset khớp workflow thực tế. Team ops có thể chuyển giữa Failed payments today và High-value customers bằng một click.
Nếu bạn xây công cụ nội bộ trong builder chat như Koder.ai, coi bộ lọc là phần của flow sản phẩm, không phải thêm vào. Bắt đầu từ các câu hỏi phổ biến, sau đó thiết kế view mặc định và saved views quanh đó.
Màn hình danh sách hiếm khi cần dữ liệu như trang chi tiết. Nếu API trả mọi thứ về mọi thứ, bạn trả tiền hai lần: database làm nhiều việc hơn, và trình duyệt nhận và render nhiều hơn mức cần. Query shaping là thói quen chỉ yêu cầu những gì danh sách cần ngay bây giờ.
Bắt đầu bằng cách chỉ trả các cột cần để render mỗi hàng. Với hầu hết dashboard, đó là id, vài nhãn, trạng thái, người sở hữu và timestamps. Văn bản lớn, JSON blob và trường tính toán có thể chờ tới khi người dùng mở hàng.
Tránh join nặng cho paint đầu tiên. Join ổn khi dùng index và trả kết quả nhỏ, nhưng sẽ tốn khi join nhiều bảng rồi sắp xếp/lọc trên dữ liệu join. Mẫu đơn giản: fetch danh sách từ một bảng nhanh, rồi load chi tiết liên quan theo nhu cầu (hoặc batch-load cho các hàng đang hiển thị).
Giữ giới hạn các tuỳ chọn sắp xếp và sắp theo cột có index. "Sort by anything" nghe tiện nhưng thường buộc sắp chậm trên dataset lớn. Ưu tiên vài lựa chọn dễ đoán như created_at, updated_at, hoặc status, và đảm bảo các cột đó có index.
Cẩn thận với aggregation phía server. COUNT(*) trên tập lọc lớn, DISTINCT trên cột rộng, hoặc tính tổng số trang có thể chiếm hết thời gian phản hồi.
Một cách tiếp cận thực tế:
COUNT và DISTINCT như tùy chọn, cache hoặc xấp xỉ khi có thểNếu bạn build công cụ nội bộ trên Koder.ai, định nghĩa truy vấn danh sách nhẹ tách biệt với truy vấn chi tiết trong giai đoạn lập kế hoạch, để UI giữ được độ mượt khi dữ liệu tăng.
Nếu bạn muốn màn hình danh sách nhanh ở mức 100k hàng, database phải làm ít việc hơn cho mỗi request. Hầu hết danh sách chậm không phải vì "dữ liệu quá lớn" mà vì mô hình truy cập dữ liệu sai.
Bắt đầu với index khớp hành vi người dùng thực tế. Nếu danh sách thường lọc theo status và sắp theo created_at, bạn cần index hỗ trợ cả hai, theo thứ tự đó. Nếu không, database có thể scan nhiều hàng hơn bạn nghĩ rồi mới sắp, và điều đó rất tốn.
Sửa sau đây thường mang lại lợi ích lớn:
tenant_id, status, created_at).OFFSET sâu. OFFSET bắt database đi qua nhiều hàng chỉ để bỏ qua.Ví dụ đơn giản: bảng Orders nội bộ hiển thị tên khách, trạng thái, số tiền, và ngày. Đừng join mọi bảng liên quan và kéo hết ghi chú order cho view danh sách. Trả về chỉ các cột dùng trong bảng, và load phần còn lại trong request riêng khi người dùng click.
Nếu bạn build với nền tảng như Koder.ai, giữ mindset này ngay cả khi UI được sinh từ chat. Đảm bảo các endpoint API sinh ra hỗ trợ cursor pagination và chọn trường, để công việc database dễ dự đoán khi bảng lớn.
Nếu một trang danh sách cảm thấy chậm hôm nay, đừng bắt đầu bằng việc viết lại toàn bộ. Bắt đầu bằng khoá chặt hành vi sử dụng bình thường, rồi tối ưu đường đi đó.
Định nghĩa view mặc định. Chọn bộ lọc mặc định, thứ tự sắp xếp và cột hiển thị. Danh sách chậm khi cố hiện mọi thứ mặc định.
Chọn phong cách phân trang phù hợp. Nếu người dùng chủ yếu xem vài trang đầu, phân trang cổ điển ổn. Nếu họ nhảy sâu (trang 200+) hoặc cần hiệu năng ổn định dù đi xa, dùng keyset pagination (dựa trên sắp xếp ổn định như created_at cộng id).
Thêm virtualization cho phần thân bảng. Ngay cả khi backend nhanh, trình duyệt vẫn có thể nghẽn khi render quá nhiều hàng.
Làm cho tìm kiếm và bộ lọc phản hồi tức thì. Debounce khi gõ để không fire request mỗi phím. Giữ trạng thái bộ lọc trong URL hoặc store chung để refresh, back button và chia sẻ view hoạt động đúng. Cache kết quả thành công gần nhất để bảng không nhấp nháy trống.
Đo, rồi tinh chỉnh truy vấn và index. Log thời gian server, thời gian database, kích thước payload và thời gian render. Sau đó cắt truy vấn: select chỉ cột hiển thị, áp dụng filter sớm, và thêm index phù hợp với filter + sort mặc định.
Ví dụ: dashboard hỗ trợ 100k ticket. Mặc định là Open, gán cho team tôi, sắp theo mới nhất, hiển thị sáu cột, và chỉ fetch ticket id, subject, assignee, status, và timestamps. Với keyset pagination và virtualization, bạn giữ cả database và UI dự đoán được.
Nếu bạn xây trong Koder.ai, kế hoạch này phù hợp với workflow iterate-and-check: điều chỉnh view, test cuộn và tìm kiếm, rồi tune truy vấn cho đến khi trang mượt.
Cách nhanh nhất làm danh sách như phá là đối xử với 100k hàng như một trang dữ liệu bình thường. Hầu hết dashboard chậm có vài bẫy lặp lại.
Một bẫy lớn là render mọi thứ rồi ẩn bằng CSS. Dù chỉ có 50 hàng nhìn thấy, trình duyệt vẫn tốn chi phí tạo 100k node DOM, đo chúng, và repaint khi cuộn. Nếu cần danh sách dài, chỉ render những gì người dùng có thể nhìn thấy (virtualization) và giữ component hàng đơn giản.
Tìm kiếm cũng có thể âm thầm phá hiệu năng khi mỗi phím bấm kích hoạt full table scan. Điều đó xảy ra khi filter không có index, khi bạn search trên quá nhiều cột, hoặc khi chạy contains trên cột text lớn mà không có kế hoạch. Quy tắc tốt: bộ lọc đầu tiên người dùng tìm tới nên rẻ trong database, không chỉ tiện trong UI.
Một lỗi nữa là fetch toàn bộ record khi danh sách chỉ cần tóm tắt. Hàng trong danh sách thường cần 5–12 trường, không phải toàn object, không phải mô tả dài, và không phải dữ liệu liên quan. Kéo dữ liệu thừa làm tăng công việc DB, thời gian mạng và parsing phía frontend.
Export và tổng có thể làm treo UI nếu bạn tính trên main thread hoặc chờ request nặng trước khi phản hồi. Giữ UI tương tác: bắt đầu export ở nền, hiển thị tiến trình, và tránh tính lại tổng trên mỗi thay đổi bộ lọc.
Cuối cùng, quá nhiều tuỳ chọn sắp xếp có thể phản tác dụng. Nếu người dùng có thể sort theo bất kỳ cột nào, bạn sẽ sắp những tập kết quả lớn trong memory hoặc buộc database vào kế hoạch chậm. Giữ sort trong tập cột có index và làm cho thứ tự sắp xếp mặc định khớp index thực.
Kiểm tra nhanh trực giác:
Hãy coi hiệu năng danh sách như một tính năng sản phẩm, không phải tweak một lần. Một màn hình danh sách chỉ nhanh khi nó cảm nhận nhanh trong thao tác thực sự: cuộn, lọc, sắp xếp trên dữ liệu thực.
Dùng checklist này để xác nhận bạn đã sửa đúng chỗ:
Kiểm tra thực tế: mở danh sách, cuộn trong 10 giây, rồi áp một bộ lọc phổ biến (ví dụ Status: Open). Nếu UI đứng, vấn đề thường là render (quá nhiều DOM rows) hoặc transform nặng phía client (sắp xếp, nhóm, định dạng) xảy ra trên mỗi cập nhật.
Bước tiếp theo, theo thứ tự, để không nhảy giữa các sửa:
Nếu bạn xây với Koder.ai (koder.ai), bắt đầu ở Planning Mode: định nghĩa chính xác cột danh sách, trường bộ lọc và dạng phản hồi API trước. Rồi lặp và rollback khi một thử nghiệm làm chậm màn hình.
Hãy đổi mục tiêu từ “tải hết” sang “hiển thị các hàng hữu ích đầu tiên nhanh”. Tối ưu thời gian đến hàng đầu và trải nghiệm mượt khi lọc, sắp xếp và cuộn, ngay cả khi toàn bộ dữ liệu không được tải cùng lúc.
Đo thời gian đến hàng đầu sau khi tải hoặc thay đổi bộ lọc, thời gian để bộ lọc/sắp xếp hiển thị kết quả, kích thước payload phản hồi, các truy vấn chậm ở database (đặc biệt là các SELECT rộng và COUNT(*)), và các đột biến trên main-thread của trình duyệt. Những con số này phản ánh trực tiếp cảm nhận độ trễ của người dùng.
Giới hạn API tạm thời trả về chỉ 20 hàng với cùng bộ lọc và sắp xếp. Nếu nhanh lên, bạn đang trả giá cho chi phí truy vấn hoặc kích thước payload; nếu vẫn chậm, nút cổ chai thường là việc render, định dạng, hoặc xử lý trên mỗi hàng phía client.
Đừng render hàng nghìn DOM một lúc, giữ component hàng đơn giản và ưu tiên chiều cao hàng cố định. Trì hoãn/đệm việc định dạng nặng cho các hàng ngoài màn hình; chỉ tính và cache khi hàng hiển thị hoặc khi người dùng mở hàng đó.
Virtualization chỉ vẽ những hàng bạn thực sự thấy (cộng buffer nhỏ) và tái sử dụng DOM khi cuộn. Phù hợp khi người dùng cuộn nhiều hoặc hàng nặng, nhưng dễ vận hành nhất khi chiều cao hàng ổn định và bố cục bảng dự đoán được.
Phân trang là lựa chọn an toàn cho hầu hết workflow quản trị vì nó giúp người dùng định vị và giới hạn công việc server. Infinite scroll phù hợp cho việc duyệt thoải mái nhưng có chi phí: mất định vị, back button khó trả lại vị trí, và bộ nhớ tích tụ nếu không quản lý kỹ.
OFFSET đơn giản (page=10&size=50) nhưng có thể chậm ở trang sâu vì database phải bỏ qua nhiều hàng. Keyset (cursor) pagination nhanh và ổn định hơn khi dữ liệu lớn và có nhiều insert, nhưng khó để nhảy tới một số trang chính xác.
Không gọi truy vấn trên từng phím bấm. Debounce input, huỷ các request đang chạy khi có request mới, và mặc định dùng các bộ lọc hẹp (ví dụ: 7 ngày gần nhất, My items) để truy vấn ban đầu nhỏ và hữu ích.
API danh sách nên trả đúng những trường cần để hiển thị hàng: id, nhãn, trạng thái, người sở hữu, timestamps. Các text lớn, JSON blob và dữ liệu liên quan khác hãy tải khi mở chi tiết.
Cho mặc định bộ lọc và sắp xếp phản ánh hành vi thực tế, rồi thêm index phù hợp—thường là index composite kết hợp tenant/filter với cột sắp xếp. Biểu tổng chính xác có thể là tuỳ chọn: cache, tiền tính, hoặc hiển thị gần đúng để không chặn phản hồi chính.