I built a Chrome extension for managing TODOs using only vanilla JavaScript and localStorage to prove that modern web APIs are powerful enough without frameworks. This project demonstrates how to create a fully functional browser extension with zero dependencies, perfect for developers who want to understand the underlying extension architecture. You can explore more of my practical projects at suhailroushan.com.
Architecture Overview
The extension follows a simple but deliberate separation between the Chrome extension layers and the application logic. The manifest.json defines the structure, while a single popup.html serves as the UI container. All business logic lives in a popup.js file that handles user interactions, manages localStorage, and updates the DOM directly.
graph TD
A[manifest.json] --> B[popup.html]
B --> C[popup.js]
C --> D[DOM Manipulation]
C --> E[localStorage API]
E --> F[Persistent Task Data]
D --> G[Real-time UI Updates]
This flow ensures data persistence across browser sessions while keeping the UI responsive. The popup.js acts as a controller, listening for events, modifying state, and synchronizing the view with the stored data.
Key Technical Decisions
Using vanilla ES6 meant I had to implement patterns that frameworks usually provide. I chose a modular function-based approach instead of a monolithic script. For state management, I created a simple store abstraction over localStorage to handle serialization.
// Task store module
const TaskStore = {
getTasks: () => {
const tasks = localStorage.getItem('todo-extension-tasks');
return tasks ? JSON.parse(tasks) : [];
},
saveTasks: (tasks) => {
localStorage.setItem('todo-extension-tasks', JSON.stringify(tasks));
},
addTask: (text) => {
const tasks = TaskStore.getTasks();
tasks.push({ id: Date.now(), text, completed: false });
TaskStore.saveTasks(tasks);
return tasks;
}
};
Another critical decision was implementing a render function that completely repaints the task list. While not as efficient as virtual DOM diffing, it's perfectly adequate for a small list and eliminates state-to-view synchronization bugs.
function renderTaskList() {
const container = document.getElementById('task-list');
const tasks = TaskStore.getTasks();
container.innerHTML = ''; // Clear existing
tasks.forEach(task => {
const taskEl = document.createElement('div');
taskEl.className = `task ${task.completed ? 'completed' : ''}`;
taskEl.innerHTML = `
<input type="checkbox" ${task.completed ? 'checked' : ''} data-id="${task.id}">
<span>${escapeHtml(task.text)}</span>
<button data-id="${task.id}">×</button>
`;
container.appendChild(taskEl);
});
}
What Broke and How I Fixed It
The first major issue was popup lifecycle management. Chrome extensions unload the popup DOM when closed, which meant event listeners attached to dynamically created elements would be lost. My initial implementation used direct onclick attributes, but that felt clunky.
I fixed it by using event delegation on the static container element. This way, even as tasks are added and removed, a single listener handles all interactions.
document.getElementById('task-list').addEventListener('click', (e) => {
const taskId = parseInt(e.target.dataset.id);
if (e.target.type === 'checkbox') {
toggleTaskCompletion(taskId);
}
if (e.target.tagName === 'BUTTON') {
deleteTask(taskId);
}
});
The second problem was localStorage synchronization across multiple tabs. If a user had the extension open in two windows, changes in one wouldn't reflect in the other until refresh. I implemented a storage event listener to detect external changes and update the UI accordingly.
window.addEventListener('storage', (e) => {
if (e.key === 'todo-extension-tasks') {
renderTaskList(); // Re-render when data changes elsewhere
}
});
How to Build Something Similar
Start with the manifest.json configuration—this is the entry point Chrome uses to understand your extension. Use manifest version 3, declare your permissions minimally, and define your popup file.
{
"manifest_version": 3,
"name": "Simple Todo",
"version": "1.0",
"action": {
"default_popup": "popup.html"
},
"permissions": ["storage"]
}
Create your popup.html with a minimal structure. Include your CSS inline or in a separate file, and load your main JavaScript file at the bottom of the body. Build the UI functionality incrementally: first implement adding tasks, then persistence, then completion and deletion. Test frequently by loading the unpacked extension in Chrome's extensions manager.
Would I Build It the Same Way Again?
For a simple, single-purpose extension, yes—vanilla JS with localStorage remains the fastest path to a working product. The lack of build steps means immediate iteration, and the bundle size is essentially zero. However, for anything more complex than a TODO list with basic features, I'd consider a lightweight framework like Preact.
The Chrome Extension API has excellent documentation, but it requires careful attention to security policies and lifecycle events. I've found that starting simple and adding complexity only when needed prevents over-engineering. The localStorage API has clear limits (typically 5MB per origin), but that's more than sufficient for most extension use cases.
Always prototype the data structure first—once you've committed to localStorage schema, changing it requires migration code for existing users.