Handling large datasets in web applications can be challenging. The data is typically paginated via the antiquated method of splitting the data into small chunks, but this takes user action and interrupts browse flow. Infinite scrolling, however, loads asynchronously as the user scrolls, and browsing becomes an engaging experience.
Modern applications are designed to keep users glued to them for as long as possible. Social media, news feeds, and shopping websites all rely on seamless content consumption in order to realize maximum retention. Infinite scrolling is handled incredibly well with HTMX and Go. HTMX enhances HTML in a way that allows AJAX requests and lazy loading without the need for JavaScript, while Go efficiently serves dynamic content. Together, they provide an effortless but powerful means to build smooth and responsive applications.
In this post, we’ll show how to implement infinite scrolling in a Go-based web app using HTMX, exploring its benefits and trade-offs along the way. By the end, you’ll have a solid understanding of how to create a seamless browsing experience that keeps users engaged and satisfied.
Infinite scrolling improves user experience by eliminating the need for manual pagination controls. New content is dynamically loaded when the user reaches the end of the page, rather than by clicking “Next” or “Load More”. It is widely implemented in social media feeds, news sites, and e-commerce sites where continuous engagement is key.
Although infinite scrolling has a lot of advantages, it is not always the right choice. Some of its probable downsides are:
Infinite scrolling may not be a good choice for users who have intermittent internet connectivity or who experience high server latency, as disruptions can ruin the experience.
Moreover, infinite scrolling breaks the scroll bar by causing it to display the page length inaccurately. Believe it or not, some people still use the scroll bar. People rely on the scroll bar to see how much is left to read. Sometimes, it’s not nice to tell people that they’re almost done when they’re not [1]. It is crucial to know your audience when deciding between infinite scrolling, pagination or any other alternative.
That being said, infinite scrolling is a powerful tool when used correctly. It can significantly improve user experience and engagement, especially for applications with large dynamic content feeds. Let’s see how it works and how to implement it with Go and HTMX.
The infinite scrolling mechanism includes three important steps:
This is typically done with JavaScript by monitoring the scroll position and calling the API when necessary. However, HTMX simplifies that with declarative attributes that can handle these interactions without writing any custom JavaScript.
When designing for Infinite Scroll, it’s essential to prioritize the Principles of Good Design.
HTMX is a light JavaScript library that extends HTML to provide dynamic interactions without coding JavaScript. We can fetch and update content directly using attributes such as hx-get, hx-trigger, hx-swap and hx-target. Infinite scrolling can be implemented with HTMX with little effort by taking advantage of built-in triggers that determine when a user scrolls to a specific scroll position.
To demonstrate this, we’re going to build an employee listing with infinite scrolling. As the user scrolls, new employees will be fetched and displayed automatically without reloading the page.
For this implementation, we’ll be using:
With these technologies working together, we can build a smooth and efficient infinite scrolling experience. Let’s get started! 🚀
As we mentioned, we’ll use Leapkit to simplify the Go web development process. Leapkit provides a set of packages that make it easy to build web applications with Go. To get started, install Leapkit by running the following command:
$ go run rsc.io/tmp/gonew@latest go.leapkit.dev/template@latest infinite-scroll
This command will create a new project named infinite-scroll with the necessary project structure and dependencies.
Let’s start by creating a simple employee model. Create a new file named employee.go in the internal/employees directory.
// internal/employees/employee.go
package employees
type Employee struct {
Name string
Title string
Description string
Tags []string
}
type Employees []Employee
This model defines an employee struct with fields for the employee’s name, title, description, and tags. We also define an employees type as a slice of employee structs.
Next, let’s create mock data to simulate a list of employees in our application.
Create a new file named service.go in the internal/employees directory. This service will help us to fetch employees from the mock data.
// internal/employees/service.go
// Mock 50 employees with different roles and skills.
var employeesList = Employees{
{"Alice Johnson", "Software Engineer", "Passionate about backend development.", []string{"Go", "Microservices", "Cloud"}},
{"Bob Smith", "Frontend Developer", "Loves creating beautiful UI.", []string{"React", "CSS", "TypeScript"}},
{"Charlie Brown", "DevOps Engineer", "Ensures smooth CI/CD pipelines.", []string{"Docker", "Kubernetes", "Terraform"}},
{"David Wilson", "Product Manager", "Drives product vision and strategy.", []string{"Agile", "User Research", "Roadmaps"}},
{"Eve Adams", "Data Scientist", "Expert in AI and ML.", []string{"Python", "TensorFlow", "Data Analysis"}},
{"Frank White", "Security Engineer", "Focuses on cybersecurity best practices.", []string{"Penetration Testing", "Cryptography", "Compliance"}},
{"Grace Hall", "UX Designer", "Creates intuitive and accessible designs.", []string{"Figma", "User Experience", "Prototyping"}},
{"Henry King", "System Administrator", "Manages IT infrastructure.", []string{"Linux", "Networking", "Virtualization"}},
{"Isabel Scott", "Software Architect", "Designs scalable software solutions.", []string{"Go", "Distributed Systems", "Architecture"}},
{"Jack Lee", "QA Engineer", "Ensures software quality through testing.", []string{"Selenium", "Test Automation", "CI/CD"}},
//...
}
Let’s start by creating a simple employee listing page. First, create a new file named index.go.
// internal/employees/index.go
package employees
import (
"net/http"
"strconv"
"go.leapkit.dev/core/render"
"go.leapkit.dev/core/server"
)
func Index(w http.ResponseWriter, r *http.Request) {
rw := render.FromCtx(r.Context())
rw.Set("employees", employeesList)
err := rw.Render("employees/index.html")
if err != nil {
server.Errorf(w, http.StatusInternalServerError, "error rendering template: %s", err.Error())
}
}
This handler will render the template employees/index.html when the user visits the / route.
Next, create the index.html template file in the internal/employees directory.
<!-- internal/employees/index.html -->
<header class="mb-10">
<h1 class="text-4xl font-bold text-gray-800">Team Members</h1>
<p class="text-gray-600 mt-2">Explore our talented professionals</p>
</header>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<%= partial("employees/list.html") %>
</div>
employees/list.html that will display the list of employees.
Next, create the list.html partial template in the internal/employees directory.
<!-- internal/employees/list.html -->
<%= for (i, employee) in employees { %>
<div class="bg-white rounded-xl shadow-md hover:shadow-lg transition-all duration-300 flex flex-col h-full">
<div class="h-24 bg-gradient-to-r from-purple-500 to-indigo-600 rounded-t-xl"></div>
<div class=" p-6 flex flex-col gap-4 flex-grow">
<div>
<h3 class="text-xl font-bold text-gray-900"><%= employee.Name %></h3>
<p class="text-indigo-600 font-medium"><%= employee.Title %></p>
<p class="text-gray-500 text-sm mt-2"><%= employee.Description %></p>
<div class="mt-4 flex gap-2 flex-wrap">
<%= for (tag) in employee.Tags { %>
<span class="px-2 py-1 text-xs rounded-full bg-indigo-100 text-indigo-800"><%= tag %></span>
<% } %>
</div>
</div>
<div class="mt-auto flex justify-between items-center">
<a href="/" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">View Profile</a>
</div>
</div>
</div>
<% } %>
This list displays all employees, but loading a large number of records at once can cause performance issues. Here is where infinite scrolling comes into play.
To implement infinite scrolling, we need to load additional employees as the user scrolls down the page. We can achieve this using HTMX’s hx-get, hx-trigger, hx-swap, and hx-target attributes.
First, we need to create a function that returns a subset of employees based on a limit and offset. Add the following function to the service.go file.
// internal/employees/service.go
// employees retrieves a paginated list of employees from the employeesList array.
// The offset parameter determines where to start retrieving employees from the array.
// The limit parameter specifies the number of employees to display per page.
func employees(offset, limit int) Employees {
// If the offset is equal to the length of the array, it means there are no more records to fetch.
if offset >= len(employeesList) {
return Employees{}
}
// Calculate the end index for the page.
// min returns the minimum of two integers.
end := min(offset+limit, len(employeesList))
return employeesList[offset:end]
}
Next, update the Index handler to include the offset and limit parameters.
// internal/employees/index.go
// Index renders the home page of the application, displaying a paginated list of employees.
func Index(w http.ResponseWriter, r *http.Request) {
rw := render.FromCtx(r.Context())
// Offset is a parameter used for pagination. It determines where to start retrieving employees from the employeesList array.
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
limit := 15 // Number of employees to display per page.
nextOffset := offset + limit // Calculate the next offset for pagination.
total := len(employeesList) // Get the total number of employees.
hasMoreRecords := total > nextOffset // Determine if more employees exist beyond the current page.
// Fetch the employees for the current page.
employees := employees(offset, limit)
rw.Set("employees", employees)
rw.Set("hasMoreRecords", hasMoreRecords)
rw.Set("nextOffset", nextOffset)
// Check if the request is an HTMX request (partial rendering)
if r.Header.Get("HX-Request") != "" {
err := rw.RenderClean("employees/list.html")
if err != nil {
server.Errorf(w, http.StatusInternalServerError, "error rendering list template: %s", err.Error())
return
}
return
}
// Render the full page template for normal requests.
err := rw.Render("employees/index.html")
if err != nil {
server.Errorf(w, http.StatusInternalServerError, "error rendering template: %s", err.Error())
}
}
In this updated handler, we calculate the offset and limit based on the query parameters. We then fetch the employees for the current page using the employees function. We also determine if there are more records available beyond the current page.
If the request is an HTMX request (partial rendering), we render the employees/list.html template without the full page layout. Otherwise, we render the full page template.
Next, update the list.html template to include the infinite scrolling functionality.
<!-- internal/employees/list.html -->
<%= for (i, employee) in employees { %>
<div class="bg-white rounded-xl shadow-md hover:shadow-lg transition-all duration-300 flex flex-col h-full">
<div class="h-24 bg-gradient-to-r from-purple-500 to-indigo-600 rounded-t-xl"></div>
<div class=" p-6 flex flex-col gap-4 flex-grow">
<div>
<h3 class="text-xl font-bold text-gray-900"><%= employee.Name %></h3>
<p class="text-indigo-600 font-medium"><%= employee.Title %></p>
<p class="text-gray-500 text-sm mt-2"><%= employee.Description %></p>
<div class="mt-4 flex gap-2 flex-wrap">
<%= for (tag) in employee.Tags { %>
<span class="px-2 py-1 text-xs rounded-full bg-indigo-100 text-indigo-800"><%= tag %></span>
<% } %>
</div>
</div>
<div class="mt-auto flex justify-between items-center">
<a href="/" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium">View Profile</a>
</div>
</div>
</div>
<% } %>
<!-- If there are more records to load, this div is rendered -->
<%= if (hasMoreRecords) { %>
<!-- What does this do? -->
<!-- 1. Makes an HTMX request to load the next batch of employees -->
<!-- 2. Uses the Intersection Observer API to detect when the div enters the viewport -->
<!-- 3. Updates this div with the response from the server -->
<!-- 4. Replaces the entire div with the new content -->
<div
hx-get="/?offset=<%= nextOffset %>"
hx-trigger="intersect"
hx-target="this"
hx-swap="outerHTML"
></div>
<% } %>
In this updated template, we display the list of employees as before. However, we also include a div element that triggers an HTMX request when it becomes visible at the bottom of the page. This div element fetches the next batch of employees using the hx-get attribute and updates itself with the response from the server using the hx-swap attribute.
The hx-trigger="intersect" attribute uses the Intersection Observer API to detect when the div element enters the viewport. When this happens, an HTMX request is triggered to load the next batch of employees.
With that approach, we have implemented infinite scrolling in our application. Now, let’s test it out!
To run the application, execute the following command:
$ go tool dev
This command will start the development server, and you can access the application at http://localhost:3000.

🎉 And that’s all!
As you scroll down the page, you should see additional employees being loaded automatically without the need to click a “Load More” button. If you want to see the full code, you can check out the GitHub repository 📌.
Infinite scrolling enhances the user experience for applications with large dynamic content feeds such as social media sites, product pages, or news sites. It eliminates the need to paginate and helps the users engage more by loading new content uninterruptedly. It’s important to understand that implementation of infinite scrolling depends on context. It is best for scenarios in which users are expected to browse continuously versus being intended for specific searches. Knowing when to implement infinite scrolling keeps the balance between engagement and accessibility.