Community Article
Community articles are authored by SitePoint Premium contributors. Content is screened before publication, and SitePoint reserves the right to moderate or remove articles that violate our guidelines. Views expressed are those of the authors and do not necessarily reflect those of SitePoint.
Building an Offline-First Spaced Repetition Engine with JavaScript and IndexedDB
SASaifullah AdenwallaPublished inAI·JavaScript·
August 27, 2026
·Updated:August 27, 2026
The AI briefing for <a href="https://tooltechblog.com/best-ways-developers-study-new-frameworks-using-flashcards/” title=”Best Ways Developers Study New Frameworks Using Flashcards”>Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
A flashcard application looks simple until you try to decide which card the user should see next.
Storing a question and an answer is straightforward:
const card ={question:"What does HTTP 404 mean?",answer:"The requested resource was not found."};The interesting part begins after the user answers it.
Should the card appear again in ten minutes? Tomorrow? Next week? What happens if the user studies on a train without internet access? How should review history be synchronized when connectivity returns? And how do we avoid recalculating an entire deck every time the application starts?
These questions turn a basic CRUD application into a useful exercise in browser storage, scheduling algorithms, state management and offline-first architecture.
In this tutorial, we’ll build the foundation of a spaced repetition engine using modern JavaScript and IndexedDB.
Our application will be able to:
Create cards↓Store them locally↓Select cards that are due↓Record review quality↓Calculate the next review↓Continue working offlineWe’re deliberately separating the scheduling engine from the interface. That makes the interesting parts easier to test and means the same core could later power a React application, Vue frontend, browser extension or plain JavaScript interface.
Start With the Data Model
Before thinking about algorithms, decide what a card needs to remember about itself.
A minimal card might look like:
{id:"card-101",front:"What is event delegation?",back:"Handling events on a parent instead of every child.",createdAt:1787817600000}That’s enough to display the card.
It isn’t enough to schedule reviews.
We also need information such as:
{interval:1,repetitions:0,easeFactor:2.5,dueAt:1787817600000,lastReviewedAt:null}functioncreateCard({front,back}){const now =Date.now();return{id: crypto.randomUUID(),front,back,createdAt: now,updatedAt: now,repetitions:0,interval:0,easeFactor:2.5,dueAt: now,lastReviewedAt:null};}A new card is immediately due:
dueAt: nowOnce the learner reviews it, the scheduling algorithm updates its state.
This is an important architectural decision.
We don’t want a global scheduler that has to reconstruct every card’s history to determine what happens next.
Each card stores enough scheduling state to answer:
When should I appear again?
Keep Card Creation Separate From Scheduling
It can be tempting to put everything into one large object:
classFlashcard{create(){}edit(){}render(){}schedule(){}save(){}sync(){}notify(){}}That becomes difficult to test quickly.
Card creation and spaced repetition solve different problems.
Front contentBack contentFormattingTagsImagesAttachmentsReview resultCurrent intervalDifficultyNext due dateReview historyKeeping those responsibilities separate also makes it easier to study how established learning products structure the experience. A modern flashcard maker may present note-taking, card creation and spaced repetition as one coherent workflow, but developers don’t have to implement those responsibilities as one tightly coupled module.
Our architecture can instead look like:
Card editor↓Card model↓Scheduler↓Persistence layerThe user experiences one product.
The codebase retains clear boundaries.
Implement a Small Scheduling Function
Production spaced-repetition systems can use sophisticated scheduling models, but we don’t need to begin there.
A simpler algorithm is more useful for understanding the architecture.
We’ll let users classify each review as:
AgainHardGoodEasyRepresent those choices numerically:
constRATINGS={AGAIN:0,HARD:1,GOOD:2,EASY:3};Now build a scheduling function.
functionscheduleReview(card,rating,now =Date.now()){const next ={...card};if(rating ===RATINGS.AGAIN){next.repetitions=0;next.interval=1;next.easeFactor=Math.max(1.3,next.easeFactor-0.2);}if(rating ===RATINGS.HARD){next.repetitions+=1;next.interval=Math.max(1,Math.round(Math.max(next.interval,1)*1.2));next.easeFactor=Math.max(1.3,next.easeFactor-0.15);}if(rating ===RATINGS.GOOD){next.repetitions+=1;if(next.repetitions===1){next.interval=1;}elseif(next.repetitions===2){next.interval=3;}else{next.interval=Math.round(next.interval*next.easeFactor);}}if(rating ===RATINGS.EASY){next.repetitions+=1;next.easeFactor+=0.15;next.interval=next.repetitions===1?4:Math.round(Math.max(next.interval,1)*next.easeFactor*1.3);}next.lastReviewedAt= now;next.dueAt=now +next.interval*24*60*60*1000;next.updatedAt= now;return next;}This isn’t intended to compete with advanced scheduling algorithms.
What matters is the shape of the function.
Previous card state+Review resultNew card stateThere are no database calls.
No DOM manipulation.
No network requests.
That makes the scheduler easy to test.
Scheduling Logic Should Be Deterministic
const updated =scheduleReview(card,RATINGS.GOOD,1787817600000);Because we’ve supplied the current time explicitly, our test doesn’t depend on the actual clock.
import{describe,it,expect}from"vitest";describe("scheduleReview",()=>{it("schedules the first good review for one day",()=>{const now =1787817600000;const card ={repetitions:0,interval:0,easeFactor:2.5};const result =scheduleReview(card,RATINGS.GOOD,now);expect(result.interval).toBe(1);expect(result.dueAt).toBe(now +86_400_000);});});Passing now into the function might look like a small detail.
It makes date-based code far easier to test.
Date.now()hidden throughout business logic with:
scheduleReview(card,rating,now)The latter lets tests control time rather than attempting to work around it.
Why IndexedDB Fits This Problem
We could store cards in localStorage.
For a prototype containing five strings, that would probably work.
A real learning application can contain hundreds or thousands of structured records, review histories, indexes and synchronization metadata.
IndexedDB is a much better fit.
It gives the browser a transactional object database capable of storing structured data.
SitePoint’s guide to storing browser data with IndexedDB provides a deeper introduction to the API and its storage model.
Our application needs two object stores:
cardsreviewsThe first stores current card state.
The second preserves individual review events.
Why store both?
card.intervaltells us the current result.
A review history tells us how we got there.
That distinction becomes useful later for analytics, debugging or changing scheduling algorithms.
Open the Database
Create a small persistence module:
constDB_NAME="spaced-repetition-demo";constDB_VERSION=1;functionopenDatabase(){returnnewPromise((resolve, reject)=>{const request =indexedDB.open(DB_NAME,DB_VERSION);request.onupgradeneeded=event=>{const db =event.target.result;if(!db.objectStoreNames.contains("cards")){const cards =db.createObjectStore("cards",{keyPath:"id"});cards.createIndex("dueAt","dueAt");}if(!db.objectStoreNames.contains("reviews")){const reviews =db.createObjectStore("reviews",{keyPath:"id"});reviews.createIndex("cardId","cardId");}};request.onsuccess=()=>{resolve(request.result);};request.onerror=()=>{reject(request.error);};});}The important part for our scheduler is:
cards.createIndex("dueAt","dueAt");This gives IndexedDB an index over review dates.
Later, we can ask the database for cards where:
dueAt <= nowwithout loading every card into JavaScript first.
IndexedDB’s native API is event-based and fairly verbose.
Don’t let those details leak through the entire application.
functionrequestToPromise(request){returnnewPromise((resolve, reject)=>{request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});}classCardRepository{constructor(db){this.db= db;}asyncsave(card){const transaction =this.db.transaction("cards","readwrite");const store =transaction.objectStore("cards");awaitrequestToPromise(store.put(card));return card;}asyncget(id){const transaction =this.db.transaction("cards","readonly");const store =transaction.objectStore("cards");returnrequestToPromise(store.get(id));}}await cards.save(card);rather than working directly with transactions and object stores everywhere.
Application↓Repository↓IndexedDBThe browser database becomes an implementation detail.
Query Cards That Are Actually Due
This is where our dueAt index becomes valuable.
asyncgetDueCards(now =Date.now()){const transaction =this.db.transaction("cards","readonly");const store =transaction.objectStore("cards");const index =store.index("dueAt");const range =IDBKeyRange.upperBound(now);returnrequestToPromise(index.getAll(range));}const dueCards =await cards.getDueCards();returns only cards whose scheduled review time has arrived.
The user doesn’t need to know that the database query is based on a timestamp index.
14 cards dueThis is a useful example of letting the data model support the product behavior directly.
We aren’t downloading every record and then writing:
allCards.filter(card=>card.dueAt<=Date.now());The storage layer performs the query.
Keep Review Events
When the learner answers a card, we should update its current scheduling state and record what happened.
A review event might look like:
functioncreateReview({cardId,rating,previousInterval,nextInterval,reviewedAt}){return{id:crypto.randomUUID(),cardId,rating,previousInterval,nextInterval,reviewedAt};}Now we need both writes to succeed together:
Update card+Insert reviewThis is exactly the sort of operation where IndexedDB transactions are useful.
asyncfunctionrecordReview(db,card,rating,now =Date.now()){const nextCard =scheduleReview(card,rating,now);const review =createReview({cardId: card.id,rating,previousInterval:card.interval,nextInterval:nextCard.interval,reviewedAt:now});returnnewPromise((resolve, reject)=>{const transaction =db.transaction(["cards","reviews"],"readwrite");const cardStore =transaction.objectStore("cards");const reviewStore =transaction.objectStore("reviews");cardStore.put(nextCard);reviewStore.put(review);transaction.oncomplete=()=>{resolve({card:nextCard,review});};transaction.onerror=()=>{reject(transaction.error);};});}This matters because we don’t want:
Card says review completedReview history says nothing happenedA transaction makes those changes one logical operation.
Build the Review Session as Plain JavaScript
Now we have enough infrastructure to create a study session.
classReviewSession{constructor({cards,recordReview}){this.cards= cards;this.recordReview=recordReview;this.queue=[];this.position=0;}asyncstart(){this.queue=awaitthis.cards.getDueCards();this.position=0;returnthis.current();}current(){return(this.queue[this.position]??null);}asyncanswer(rating){const card =this.current();if(!card){returnnull;}awaitthis.recordReview(card,rating);this.position+=1;returnthis.current();}}The UI doesn’t determine scheduling.
const card =await session.start();const next =await session.answer(RATINGS.GOOD);This boundary is surprisingly valuable.
A React component could consume the same session class as a vanilla JavaScript interface.
Render a Minimal Review Interface
<mainclass="review"><articleid="card"><pid="card-content"></p></article><buttonid="reveal">Show answer</button><divid="ratings"hidden><buttondata-rating="0">Again</button><buttondata-rating="1">Hard</button><buttondata-rating="2">Good</button><buttondata-rating="3">Easy</button></div></main>JavaScript can manage only the presentation state:
let currentCard =null;let showingAnswer =false;const content =document.querySelector("#card-content");const reveal =document.querySelector("#reveal");const ratings =document.querySelector("#ratings");functionrenderCard(card){currentCard = card;showingAnswer =false;if(!card){content.textContent="You're done for now.";reveal.hidden=true;ratings.hidden=true;return;}content.textContent=card.front;reveal.hidden=false;ratings.hidden=true;}reveal.addEventListener("click",()=>{if(!currentCard){return;}showingAnswer =true;content.textContent=currentCard.back;reveal.hidden=true;ratings.hidden=false;});ratings.addEventListener("click",asyncevent=>{const button =event.target.closest("[data-rating]");if(!button){return;}const rating =Number(button.dataset.rating);const next =await session.answer(rating);renderCard(next);});Notice what’s missing from the DOM code:
interval calculationsease-factor updatesIndexedDB transactionsdue-date queriesThat’s intentional.
UI code should mostly deal with UI.
Offline-First Is a Natural Fit
A study application has an unusually strong case for offline support.
in libraries with unstable Wi-Fi
in classrooms with overloaded networks
A review session shouldn’t collapse because a network request fails.
Our current architecture already helps because the cards and scheduling state live in IndexedDB.
await session.answer(RATINGS.GOOD);doesn’t require a server.
The next step is making the application shell available offline too.
A service worker can cache the HTML, JavaScript and CSS needed to launch the application.
SitePoint’s tutorial on offline web apps using service workers covers the broader offline-first approach.
if("serviceWorker"innavigator){navigator.serviceWorker.register("/service-worker.js").catch(error=>{console.error("Service worker registration failed",error);});}Then define a small application cache:
constCACHE_NAME="study-app-v1";constAPP_SHELL=["/","/index.html","/styles.css","/app.js"];self.addEventListener("install",event=>{event.waitUntil(caches.open(CACHE_NAME).then(cache=>cache.addAll(APP_SHELL)));});For application-shell requests:
self.addEventListener("fetch",event=>{event.respondWith(caches.match(event.request).then(cached=>{return(cached ??fetch(event.request));}));});This is deliberately minimal.
A production caching policy should distinguish between asset types and avoid blindly caching every response.
The important design decision is that studying doesn’t depend entirely on a live server connection.
Offline Doesn’t Mean Serverless
IndexedDB becoming the locald a backend
Multiple devicesAccount recoveryCloud backupShared contentCross-device progressThat creates a synchronization problem.
Imagine the learner reviews a card offline.
{id:"card-101",interval:8,dueAt:1788508800000,updatedAt:1787817600000,syncStatus:"pending"}We can explicitly track synchronization state:
functionmarkPending(card){return{...card,syncStatus:"pending"};}if(navigator.onLine){syncPendingChanges();}window.addEventListener("online",()=>{syncPendingChanges();});The difficult part isn’t detecting that we’re online.
It’s conflict resolution.
Sync Review Events, Not Just Final State
Suppose a user studies the same card independently on two devices.
interval = 5 daysinterval = 8 daysWhich record should win?
If we synchronize only the final card object, we don’t have much information.
This is one reason the review-event model is valuable.
Instead of synchronizing only:
{interval:8}{id:"review-918",cardId:"card-101",rating:2,reviewedAt:1787817600000}The server now has a history it can reconcile.
This architecture resembles event sourcing in a very small form:
Review event↓Scheduling function↓Current card stateWe’re not building a full event-
We’re simply preserving meaningful events rather than throwing them away after deriving current state.
That can make synchronization and debugging much easier.
Avoid Scheduling Every Card With Timers
A common first implementation might try:
setTimeout(showCard,card.dueAt-Date.now());Don’t do this for thousands of cards.
The browser doesn’t need thousands of long-running timers.
Wake up JavaScript at the exact millisecond this card becomes due.
When the learner opens the review queue, find everything currently due.
That’s exactly what our IndexedDB index already does:
await cards.getDueCards(Date.now());The distinction keeps the architecture much simpler.
Notifications Are a Separate Feature
You may eventually want to tell users:
18 cards are ready for review.That shouldn’t be part of the scheduling engine either.
Which cards are due?A notification module decides:
Should we interrupt the user?Browser notifications require permission and should be requested in response to meaningful user intent rather than automatically on the first page load.
SitePoint has a guide to the Web Notifications API if you want to extend the application in that direction.
asyncfunctionenableNotifications(){const permission =awaitNotification.requestPermission();return(permission ==="granted");}functionnotifyDueCards(count){if(Notification.permission!=="granted"){return;}newNotification("Ready for a quick review?",{body:`${count}cards are due.`});}Don’t let notification behavior leak into:
scheduleReview()That function should remain concerned only with scheduling.
A technically correct scheduler can still produce a poor experience.
Suppose a learner imports 4,000 cards and all are due immediately.
4,000 cards dueis mathematically accurate.
It’s not particularly useful.
The queue layer can apply product rules separately from scheduling.
asyncfunctionbuildDailyQueue({repository,limit =100}){const due =await repository.getDueCards();return due.slice(0,limit);}Now the scheduler remains honest about dates while the session layer decides how much work to present.
That’s another useful architectural separation:
Scheduler:When should this card be reviewed?Queue:Which due cards should appear today?They sound similar.
They’re different responsibilities.
Shuffle Carefully
Reviewing cards in the same order every day can create unwanted context.
cards.sort(()=>Math.random()-0.5);is common but doesn’t produce an unbiased shuffle.
functionshuffle(items){const result =[...items];for(let i =result.length-1;i >0;i--){const j =Math.floor(Math.random()*(i +1));[result[i],result[j]]=[result[j],result[i]];}return result;}this.queue=shuffle(awaitthis.cards.getDueCards());You may eventually want smarter ordering—for example, avoiding several related cards appearing consecutively—but that belongs in queue construction rather than the core scheduling algorithm.
Don’t Let Rich Content Compromise the App
Once users can create their own cards, somebody will ask for rich text.
Then HTML.
Then pasted content.
Be careful with rendering arbitrary HTML:
element.innerHTML=card.front;If card content can contain untrusted markup, that creates an injection risk.
element.textContent=card.front;If rich HTML is genuinely required, introduce an explicit sanitization layer rather than rendering raw user input.
This is another example of why the card model shouldn’t decide how content is rendered.
Persistence stores content.
The UI determines its safe presentation.
Make the Scheduling Engine Replaceable
The simple scheduling function we’ve written is deliberately modest.
A production product may eventually use a more advanced scheduling model.
Don’t let that future possibility infect the whole application.
scheduleReview(card,rating,now);We could formalize that boundary:
classScheduler{schedule(card,rating,now){returnscheduleReview(card,rating,now);}}const session =newReviewSession({cards,scheduler,recordReview});Now replacing the implementation doesn’t require changing:
IndexedDBUInotificationssyncservice workerThe rest of the application understands only:
Card + rating → updated scheduling stateThat is the contract.
Measure the System Without Optimizing for the Metric
Once review history exists, analytics are easy to imagine.
Reviews per dayAverage responseAgain percentageRetentionCards learnedReview workloadfunctioncalculateAgainRate(reviews){if(reviews.length===0){return0;}const again =reviews.filter(review=>review.rating===RATINGS.AGAIN).length;return(again /reviews.length);}Useful.
But be careful about turning every measurable behavior into a goal.
If you optimize the interface to maximize:
Cards reviewed per minuteyou might encourage shallow answering.
Longest study streakyou may encourage users to perform meaningless reviews just to preserve a number.
Instrumentation should help users understand the learning process, not manipulate them into generating better dashboard metrics.
The Architecture We’ve Built
Our application now has several deliberately small pieces.
Card model│├── content└── scheduling stateScheduler│└── calculates next reviewIndexedDB repository│├── stores cards├── indexes due dates└── stores review eventsReview session│├── builds queue├── selects current card└── records answersUI│├── renders questions├── reveals answers└── captures ratingsService worker│└── keeps application shell available offlineSync layer│└── eventually sends local review events to serverNone of the pieces is especially sophisticated by itself.
That’s what makes the architecture manageable.
A review button doesn’t need to understand IndexedDB.
IndexedDB doesn’t need to understand the DOM.
The service worker doesn’t calculate review intervals.
The scheduler doesn’t know whether the learner is online.
Each layer has one reason to change.
Where to Go From Here
A real spaced-repetition application could grow substantially from this foundation.
MarkdownCode snippetsImagesAudioMath notationCloze deletionThe scheduler could evolve.
Review queues could become topic-aware.
A backend could synchronize study history across devices.
Authentication could separate multiple learners.
Background synchronization could send offline events when connectivity returns.
A PWA manifest could make the experience installable.
But those additions don’t require us to throw away the architecture we’ve already created.
That’s the benefit of starting with boundaries rather than features.
Final Thoughts
A spaced-repetition application is a surprisingly useful JavaScript architecture exercise because several web-platform concerns meet in one small product.
You need structured browser storage.
You need date-based business logic.
You need deterministic functions that can be tested.
You need transactions when state changes together.
You need to think about offline behavior.
Eventually, you need synchronization and conflict handling.
The most important lesson isn’t the particular interval formula we used.
It’s where we put it.
Instead of mixing scheduling with rendering, persistence and network behavior, we turned it into a small deterministic operation:
Current card state+Review rating+Current time↓New card stateEverything else can be built around that contract.
That’s a useful pattern far beyond flashcards.
Whenever an application contains important business rules, keeping those rules independent of the browser UI and storage implementation tends to make the software easier to test, replace and understand.
And in this case, it also means the user can close their laptop, lose Wi-Fi, come back tomorrow, and still find exactly the cards they’re supposed to review.


