Engineering

Optimizing SQLite for Speed: Lessons from Shariah Signal

Learn how to optimize SQLite and Room indexing to handle thousands of local records with near-instant performance in data-heavy Android apps.

Amin Shekh · October 22, 2026
Share

When we began building Shariah Signal, we faced a specific technical challenge: providing a near-instant screening experience for thousands of global stocks. In an app where users expect to filter by compliance status, sector, and financial ratios, waiting even two seconds for a query to return is unacceptable. Most small-scale Android apps get away with unoptimized databases because their record counts are in the hundreds. However, once you cross the threshold of several thousand entries with complex multi-column filters, the performance of SQLite starts to degrade visibly unless you apply specific engineering principles.

In this guide, we will break down the lessons we learned in optimizing Room and SQLite for speed. We will move beyond basic CRUD operations and look at how indexing, query planning, and database structure impact the user experience in a data-heavy environment. This is the same technical foundation that allows us to maintain an offline-first design while keeping the UI responsive.

The Cost of a Full Table Scan

By default, if you query a table for a specific stock ticker without an index, SQLite performs a 'Full Table Scan.' It starts at the first row and examines every single entry until it finds a match. For a small app like Trophy Tykes, where a child might have ten chores, this is instantaneous. But for Shariah Signal, which tracks thousands of equities, a full scan is a performance killer.

Indices are essentially side tables that store a sorted version of a column’s data along with pointers to the original rows. While they increase the database size on disk and slightly slow down write operations (because the index must be updated), the read performance gains are exponential. In a read-heavy app where data is synced periodically rather than every second, this trade-off is always worth it.

Implementing Strategic Indices in Room

In Android’s Room persistence library, you define indices within the @Entity annotation. The mistake many developers make is indexing every column. This is counter-productive. You should only index columns used in WHERE, JOIN, or ORDER BY clauses.

For Shariah Signal, we identified three primary ways users query data:

  1. 1.Searching for a specific ticker (e.g., "AAPL").
  2. 2.Filtering by compliance status (e.g., "Compliant" vs. "Non-Compliant").
  3. 3.Sorting by market cap or debt ratios.

Here is how we structured the indices for our stock entity:

``kotlin @Entity( tableName = "stocks", indices = [ Index(value = ["ticker"], unique = true), Index(value = ["compliance_status"]), Index(value = ["market_cap"]) ] ) data class StockEntity( @PrimaryKey val id: Long, val ticker: String, val companyName: String, val complianceStatus: String, val marketCap: Long ) ``

Composite Indices for Complex Filters

A common pattern in our app is filtering by status AND sector simultaneously. A single-column index on compliance_status helps, but the database still has to manually filter the results for the sector. A Composite Index (an index on multiple columns) solves this.

If you frequently run SELECT * FROM stocks WHERE compliance_status = :status AND sector = :sector, a composite index on both columns is significantly faster than two individual indices.

Index TypeSearch ScenarioPerformance Gain
Single ColumnSearching by TickerHigh
CompositeFiltering by Compliance + SectorVery High
Unique IndexPreventing Duplicate TickersHigh + Data Integrity
No IndexWildcard search on non-indexed textLow (Table Scan)

The Power of the EXPLAIN QUERY PLAN

You cannot optimize what you cannot measure. SQLite provides a tool called EXPLAIN QUERY PLAN. When running queries in the Android Studio App Inspection tool, you should prepending your SQL with these words to see how the engine handles your request.

If the output says SCAN TABLE, you have a bottleneck. If it says SEARCH TABLE ... USING INDEX, you are on the right track. In Shariah Signal, we realized that our 'Search as you type' feature was initially performing a scan because we were using a leading wildcard (e.g., WHERE name LIKE '%tech'). SQLite cannot use a standard index for leading wildcards. By restructuring our search logic to favor prefix matching (tech%), we allowed the index to trigger, reducing search latency from 400ms to under 20ms.

Handling Data Updates Without UI Lag

Data-heavy apps often struggle with 'Write-Ahead Logging' (WAL). When Shariah Signal fetches updated financial ratios from the server, we might be writing 2,000 rows at once. If this happens on the main thread, the UI freezes. If it happens on a background thread without proper configuration, it can still lock the database and prevent the UI from reading data.

We utilize Room’s support for WAL, which allows multiple readers and one writer to operate simultaneously. However, even with WAL, long-running transactions can cause issues. We follow these three rules for bulk updates:

  1. 1.Use Transactions: Never insert 1,000 rows individually. Wrap them in a @Transaction to ensure SQLite only performs one disk sync.
  2. 2.Yield to the UI: If an update is massive, we break it into chunks of 500 records. This gives the database engine a 'breather' to handle any pending read requests from the UI.
  3. 3.Prioritize the DAO: Keep your Data Access Objects clean. Avoid complex logic inside the DAO; fetch the data, then process it in a Repository or UseCase.

If your app requires searching through long descriptions (like a stock's business summary), standard indices won't be enough. SQLite’s Full-Text Search (FTS4 or FTS5) creates a specialized virtual table optimized for text.

In Shariah Signal, we use FTS for searching company names and descriptions. This allows for 'fuzzy' matching and is significantly faster than the LIKE operator. Implementing this in Room requires a separate entity marked with @Fts4:

``kotlin @Fts4 @Entity(tableName = "stocks_search") data class StockSearchEntity( val companyName: String, val businessSummary: String ) ``

You can then link this Fts4 table to your main stocks table using the rowid. This separation keeps the primary data table lean while providing high-performance search capabilities.

Optimizing the Schema for Memory

Every byte counts when you are loading thousands of records into memory for a List or RecyclerView. We noticed that using String for categories that only have a few possible values (like compliance status: 'Compliant', 'Non-Compliant', 'Doubtful') was wasteful.

Instead, we map these to Int constants at the database level using a Room TypeConverter. A table with 10,000 integers is smaller and faster to sort than a table with 10,000 strings. Small optimizations like this, combined with proper state management, ensure that the UI remains fluid even on entry-level Android devices.

Checklist for Database Performance

Before shipping a data-heavy update, we run through this checklist to ensure we aren't introducing regressions:

  • Verify Indices: Are all WHERE and ORDER BY columns indexed?
  • *Avoid SELECT :** Do we really need every column for this specific screen? Selecting only the ticker and price is faster than selecting the full 30-column row.
  • Check Transaction Size: Are we batching inserts to avoid excessive disk I/O?
  • Review TypeConverters: Are we storing complex objects as JSON strings when they should be in a related table or simplified to an Int?
  • Analyze Query Plans: Has EXPLAIN QUERY PLAN confirmed that we are searching indices instead of scanning tables?

Conclusion

Speed in a data-heavy app isn't just about a fast processor; it’s about how efficiently you can retrieve data from the disk. By moving away from default table behaviors and implementing structured indices, composite searches, and FTS tables, we transformed Shariah Signal from a functional app into a high-performance tool. Whether you are building a stock screener or a complex prayer tracker like Salah Companion, these SQLite principles ensure that your app feels light, no matter how much data it carries.

What to do next

  1. 1.Audit your DAOs: Open your most-used Room queries and identify which columns are missing indices.
  2. 2.Run a Trace: Use the Android Studio Profiler to check if database operations are causing 'Jank' (dropped frames) on the main thread during list scrolling.
  3. 3.Test with Scale: Populate your local database with 5x the amount of data you expect an average user to have. If the app slows down, your indexing strategy needs refinement.
#android engineering#sqlite#room database#performance

One email per launch. No spam, unsubscribe any time.