Using the Reading List API
Add, query, update and remove Chrome Reading List entries from an MV3 extension with chrome.readingList: permissions, URL rules, read state, change events, and fallbacks for Firefox and Safari.
Table of Contents
Your read-it-later extension keeps its own list of saved articles, and users ask why the articles do not appear in Chrome’s own Reading List in the side panel — the one that syncs to Chrome on their phone. Since Chrome 120, extensions can read and write that list through chrome.readingList. Integrating with it means saved articles show up in the browser’s UI and on mobile, and read state set there flows back to your extension. The API is small, but it has rules about which URLs it accepts and how entries are identified. This guide covers them. It belongs to browser data APIs for bookmarks, history and downloads.
The model
The Reading List is a flat list of entries, each identified by its URL — there is no separate id. An entry has a url, a title, a hasBeenRead flag and creation and update timestamps. chrome.readingList.addEntry({ url, title, hasBeenRead }) adds one; adding a URL that already exists throws. query(info) returns entries matching a URL, title or read state. updateEntry({ url, title?, hasBeenRead? }) changes an existing entry; removeEntry({ url }) deletes it. Events — onEntryAdded, onEntryUpdated, onEntryRemoved — fire for changes from any source, including the user marking an article read in the side panel and Chrome Sync applying changes from another device. Only http and https URLs are accepted.
Step-by-step: integrate with the Reading List
1. Declare the permission and require Chrome 120
1{
2 "minimum_chrome_version": "120",
3 "permissions": ["readingList", "storage"]
4}
Execution context: the manifest. readingList shows an install warning (“Read and change entries in the reading list”). If the reading list is an optional integration rather than the core of your extension, put it in optional_permissions and request it when the user enables the feature, so users who never want it see no warning.
2. Add entries safely
1// sw.js
2export async function saveToReadingList(url, title) {
3 const u = new URL(url);
4 if (!["http:", "https:"].includes(u.protocol)) return { ok: false, reason: "unsupported-url" };
5 u.hash = ""; // one entry per page, not per anchor
6 const [existing] = await chrome.readingList.query({ url: u.href });
7 if (existing) return { ok: true, existed: true };
8 await chrome.readingList.addEntry({ url: u.href, title: title.slice(0, 500) || u.hostname, hasBeenRead: false });
9 return { ok: true, existed: false };
10}
Execution context: the service worker or an extension page. Querying first avoids the error addEntry throws for duplicates. Normalising the URL — at least dropping the fragment — prevents near-duplicates that differ only by anchor. Titles should never be empty; fall back to the hostname. The call needs no host permission for the saved URL.
3. Mirror read state into your own store
1chrome.readingList.onEntryUpdated.addListener(async (entry) => {
2 const { articles = {} } = await chrome.storage.local.get("articles");
3 const a = articles[entry.url];
4 if (!a) return;
5 if (a.read !== entry.hasBeenRead) {
6 articles[entry.url] = { ...a, read: entry.hasBeenRead, readAt: entry.hasBeenRead ? Date.now() : null };
7 await chrome.storage.local.set({ articles });
8 }
9});
10
11chrome.readingList.onEntryRemoved.addListener(async (entry) => {
12 const { articles = {} } = await chrome.storage.local.get("articles");
13 if (articles[entry.url]) { articles[entry.url].inReadingList = false; await chrome.storage.local.set({ articles }); }
14});
Execution context: the service worker, with listeners at the top level so a change made on the user’s phone — arriving through Chrome Sync — wakes the worker and updates your store. Whether removal from the reading list should delete the article from your extension is a product decision; marking it rather than deleting it is safer.
4. Push your read state the other way
1export async function markRead(url, read = true) {
2 const [entry] = await chrome.readingList.query({ url });
3 if (entry && entry.hasBeenRead !== read) {
4 await chrome.readingList.updateEntry({ url, hasBeenRead: read });
5 }
6}
Execution context: the service worker, called when the user finishes an article in your reader view. Checking current state before updating avoids an event loop: your update fires onEntryUpdated, whose handler sees no change and does nothing. Without the check, two-way sync between stores can ping-pong.
5. Import existing entries on first enable
1export async function importReadingList() {
2 const entries = await chrome.readingList.query({});
3 const { articles = {} } = await chrome.storage.local.get("articles");
4 for (const e of entries) {
5 articles[e.url] ??= { url: e.url, title: e.title, savedAt: e.creationTime, read: e.hasBeenRead, inReadingList: true };
6 }
7 await chrome.storage.local.set({ articles });
8}
Execution context: the service worker, run once when the integration is enabled. Users who already use Chrome’s reading list expect those entries to appear in your extension. creationTime is milliseconds since the epoch.
6. Fall back where the API is missing
1export const hasReadingList = typeof chrome.readingList?.addEntry === "function";
2// Without it: keep entries only in your own store and offer your own sync.
Execution context: any extension context. Firefox and Safari have no extension reading-list API. The extension’s own store remains the source of truth everywhere; the Chrome integration is an enhancement. Do not hide core features behind it.
7. Offer the integration as a setting
1// options.js
2toggle.addEventListener("change", async () => {
3 if (toggle.checked) {
4 const ok = await chrome.permissions.request({ permissions: ["readingList"] });
5 toggle.checked = ok;
6 if (ok) await chrome.runtime.sendMessage({ type: "readingList:import" });
7 } else {
8 await chrome.storage.local.set({ readingListSync: false });
9 }
10});
Execution context: the options page, inside the change handler so the user gesture is live for the permission prompt. Making the integration opt-in keeps the install warning off the store listing for users who never want it, and gives users who do a clear moment to understand what will happen: entries saved in the extension will appear in Chrome’s Reading List on all their signed-in devices. When the toggle is turned off, stop mirroring but leave existing reading-list entries alone — they belong to the user now, and deleting them would be surprising.
Common mistakes
- Adding duplicates.
addEntrythrows for an existing URL; query first. - Inconsistent URL normalisation. If your store keys by one form and the reading list by another, sync breaks.
- Saving non-http URLs.
chrome://,file://and extension pages are rejected. - Ping-pong updates. Update the other side only when state actually differs.
- Making it mandatory. Users on other browsers, and users who dislike the extra warning, should still have a working extension.
Cross-browser variation
- Chrome / Edge:
chrome.readingListfrom Chrome 120; Edge has its own reading features and may not expose the API — feature-detect. - Firefox: no reading list API; Pocket integration is not available to extensions.
- Safari: Safari’s Reading List is not accessible to web extensions.
Verification
- Save an article from the extension; it appears in Chrome’s side panel Reading List.
- Mark it read in the side panel; the extension’s popup shows it as read.
- Save the same URL twice; the second call reports
existed: truewithout an error. - Sign in to Chrome Sync on a second device, mark an article read there, and confirm the desktop extension updates.
FAQ
Can I set a custom favicon or thumbnail?
No. Chrome derives display details itself; the API accepts URL, title and read state only.
Is there a limit on entries?
No documented hard limit for typical use, but very large lists slow the side panel; do not bulk-import thousands of URLs without asking.
Does removing the extension remove its entries?
No. Entries belong to the user’s reading list, not to the extension.
Can a content script add the current page directly?
chrome.readingList is not exposed to content scripts. Have the content script (or a context menu click, or the action) message the service worker with the page’s URL and title, and let the worker call addEntry. That also keeps URL normalisation in one place.
How should conflicts between the two lists be resolved?
Treat Chrome’s reading list as authoritative for the fields it owns — URL, title, read state — and your store as authoritative for everything else. That rule is simple to explain and never leaves the two disagreeing about the same field.
Related
- Reading and writing bookmarks safely — the other user-owned list.
- Listening for bookmark changes — event handling patterns.
- Requesting optional permissions at runtime — making the integration opt-in.
- Browser data APIs: bookmarks, history and downloads — the parent topic.