The Summarizer API
So. The Summarizer API is the one you reach for when the job is dead simple:
long text in, short text out. You could ask the raw Prompt API to "summarize
this in three bullets" and hope it holds the shape — but this is the same Gemini
Nano running a task-specific LoRA adapter and a decoder config pointed at exactly that
one task, so it holds. Key points, a TL;DR, a headline, a teaser. By the end of
this page you'll gate it on availability(), stream a summary a few words at a
time, and steer its tone with a single line of sharedContext.
What you'll build
- Gate
Summarizer.create()behind anavailability()check and a downloadmonitor. - Pick the output shape with
type,format, andlength— key-points,tldr, teaser, or headline. - Summarize in one call with
summarize(), or stream deltas withsummarizeStreaming()and append them. - Frame every summary in your domain with
sharedContext, and refine a single input with per-callcontext. - Survive a
QuotaExceededErroron very long input, anddestroy()the summarizer on teardown.
Desktop Chrome with built-in AI switched on. If Summarizer isn't on self
yet, work through Setup & the availability lifecycle
first, and keep the compatibility matrix handy
for what's stable where. This page assumes you already know the four
availability states — it won't re-teach them.
Create a summarizer
Same four-step loop as everything else in this course: ask if it's there, create
it, use it, tear it down. Feature-detect the Summarizer global, check
availability(), then create() with the shape you want. Summarizer went
stable in Chrome 138 and needs no flag on current builds — the old
#summarization-api-for-gemini-nano toggle only matters on ancient ones. The
model downloads on the first create(), the same multi-GB one-time hit as the
Prompt API, so wire the monitor from the start.
- JavaScript
- TypeScript
if (typeof Summarizer === 'undefined') {
// No built-in Summarizer here — degrade gracefully (see lesson 2).
}
const status = await Summarizer.availability();
// "unavailable" | "downloadable" | "downloading" | "available"
// Starting the model download needs a user gesture, so run create() from a click
// handler (or guard on navigator.userActivation.isActive when it's downloadable).
if (status !== 'unavailable') {
const summarizer = await Summarizer.create({
type: 'key-points', // 'key-points' | 'tldr' | 'teaser' | 'headline'
format: 'markdown', // 'markdown' | 'plain-text'
length: 'medium', // 'short' | 'medium' | 'long'
monitor(m) {
m.addEventListener('downloadprogress', (e) => {
// e.loaded is a 0..1 fraction; e.total exists and is always 1.
console.log(`Downloading model… ${Math.round(e.loaded * 100)}%`);
});
},
});
const summary = await summarizer.summarize('… long article text …');
console.log(summary);
summarizer.destroy();
}
type Availability = 'unavailable' | 'downloadable' | 'downloading' | 'available';
const status: Availability = await Summarizer.availability();
if (status !== 'unavailable') {
const summarizer = await Summarizer.create({
type: 'key-points',
format: 'markdown',
length: 'medium',
monitor(m: CreateMonitor) {
m.addEventListener('downloadprogress', (e: ProgressEvent) => {
console.log(`Downloading model… ${Math.round(e.loaded * 100)}%`);
});
},
});
const summary: string = await summarizer.summarize('… long article text …');
console.log(summary);
summarizer.destroy();
}
First create() on a cold machine blocks on that download. That's the whole
reason the monitor goes in on line one, not bolted on later.
Pick a type and a length
Four shapes, one size knob. type decides what the summary is; length decides
how much of it you get. Don't overthink it — pick the shape that matches where
the text is going.
type | What you get |
|---|---|
key-points | A bulleted list of the main claims. The default, and the one you'll reach for most. |
tldr | A sentence or two — the classic above-the-fold summary. |
teaser | A hook that makes someone want to read the full thing. |
headline | One line, news-desk cadence. |
Here's the one that trips people: the TL;DR type is spelled 'tldr' — no
semicolon. Not 'tl;dr', not 'tl-dr'. Write the semicolon and create()
throws a TypeError — 'tl;dr' is not a valid SummarizerType — before you
ever see a summary. Copy it from the docs, not from memory.
- JavaScript
- TypeScript
// A one-line headline for a news card:
const headliner = await Summarizer.create({ type: 'headline', length: 'short' });
const tldr = await Summarizer.create({ type: 'tldr', length: 'short' }); // no semicolon
// Roomy key-points for a docs page:
const notes = await Summarizer.create({
type: 'key-points',
format: 'markdown',
length: 'long',
});
// The valid values, as unions:
type SummaryType = 'key-points' | 'tldr' | 'teaser' | 'headline';
type SummaryLength = 'short' | 'medium' | 'long';
const headliner = await Summarizer.create({ type: 'headline', length: 'short' });
const tldr = await Summarizer.create({ type: 'tldr', length: 'short' }); // no semicolon
const notes = await Summarizer.create({
type: 'key-points',
format: 'markdown',
length: 'long',
});
Four more knobs ride on create() when you need them: a preference performance
hint ('auto' | 'speed' | 'capability'), plus the language tags
expectedInputLanguages, expectedContextLanguages, and outputLanguage —
BCP-47 codes that tell the model what's coming in and what you want back. Ask for
a language combo the build can't serve and create() rejects with a
NotSupportedError. Whatever you pass here, pass the same to availability() so
the check matches what you'll actually create.
Summarize once, or stream the deltas
Two ways out, same as the Prompt API. summarize() hands you the whole summary
when it's done — one await, one string. summarizeStreaming() hands you a
ReadableStream you loop over with for await, and here's the part that bites:
the chunks are deltas, not the full summary each time. Each chunk is the next
slice.
Append. Don't replace.
- JavaScript
- TypeScript
// Whole summary at once:
const summary = await summarizer.summarize(article);
// Or stream incremental deltas and append them:
let text = '';
const stream = summarizer.summarizeStreaming(article);
for await (const chunk of stream) {
text += chunk; // deltas — append, never replace
render(text);
}
const summary: string = await summarizer.summarize(article);
let text = '';
const stream: ReadableStream<string> = summarizer.summarizeStreaming(article);
for await (const chunk of stream) {
text += chunk; // deltas — append, never replace
render(text);
}
Frame it with sharedContext
This is where the Summarizer stops being generic. sharedContext is a string
you set once at create() that rides along on every summarize() call on that
instance — a system-prompt-level frame telling the model what this text is and
what to protect. Same article, different frame, different summary. When one
specific input needs its own steer, pass a per-call context and skip building a
whole new instance.
- JavaScript
- TypeScript
const clinical = await Summarizer.create({
type: 'key-points',
sharedContext: 'A section of a medical journal article. Preserve drug names and dosages exactly.',
});
// A per-call context refines one specific input without a new instance:
const summary = await clinical.summarize(caseReport, {
context: 'Reader is a triage nurse skimming for red flags.',
});
const clinical = await Summarizer.create({
type: 'key-points',
sharedContext: 'A section of a medical journal article. Preserve drug names and dosages exactly.',
});
const summary: string = await clinical.summarize(caseReport, {
context: 'Reader is a triage nurse skimming for red flags.',
});
Without it, the summary is correct and lifeless — the kind of bullet list that reads the same whether it's compressing a Stripe incident report, a Notion meeting note, or a Postgres post-mortem. With it, the output starts sounding like it belongs in your product. One string. Load-bearing.
Destroy the summarizer
A live summarizer pins GPU memory the same way a LanguageModel session does,
and Nano is not small. Leave a fistful of them hanging and you'll starve the next
feature that asks the model for anything. So destroy() when you're done, and
wire it to teardown so a closed tab doesn't leak.
Free it.
- JavaScript
- TypeScript
summarizer.destroy();
// Wire it to teardown so a closed tab doesn't leak GPU memory:
window.addEventListener('beforeunload', () => summarizer?.destroy());
// Summarizing after destroy() throws AbortError — create a new one instead.
summarizer.destroy();
window.addEventListener('beforeunload', () => summarizer?.destroy());
// Summarizing after destroy() throws AbortError — create a new one instead.
Run it locally: open 06-summarizer/index.html from the
chrome-ai-course repo in desktop
Chrome. Or use the hosted demo:
on-device summarizer (with
the API walkthrough).
Expected: you paste text, pick a type and length, and the summary streams in a
few words at a time; switch the type to headline and re-run to watch the same
text collapse to a single line.
Requires: desktop Chrome with Gemini Nano available — see Setup & the availability lifecycle.
Gotchas & troubleshooting
Most of these are the Prompt API's gotchas with the serial numbers filed off: you didn't wait for the download, you overran the quota, or you treated a streaming delta like a snapshot.
Symptom: the first create() hangs with no error. Cause: the multi-GB model is
downloading and you didn't wire a monitor, so there's nothing to wait on. Fix:
pass monitor(m) and read e.loaded (a 0..1 fraction) for progress. create()
resolves when the download finishes.
Symptom: create() throws before you hold a summarizer. Cause: an invalid enum
value — usually type: 'tl;dr' (with the semicolon) instead of 'tldr'. Fix:
use the exact enum values — 'tldr' has no semicolon — and copy them from the
docs, not from memory.
Symptom: create() rejects with NotSupportedError. Cause: a language combo the
build can't serve — an expectedInputLanguages / expectedContextLanguages /
outputLanguage tag it doesn't support. (A device that can't run built-in AI at
all reports availability() === 'unavailable', not this.) Fix: probe the exact
config with availability({ type, format, length, expectedInputLanguages })
before you commit to it, and pass availability() the same options as create().
Symptom: summarize() rejects with QuotaExceededError. Cause: the input is
longer than the model can take in one pass. Fix: chunk it — split the document
into sections, summarize each, then summarize the summaries.
Symptom: the output flickers and ends on a fragment. Cause: you're replacing the
output with each chunk, but summarizeStreaming() yields deltas, not the full
summary so far. Fix: accumulate — text += chunk — then render the running
text.
Symptom: summarize() throws AbortError. Cause: you already called destroy()
on that summarizer. Fix: a destroyed summarizer is gone — create a new one. And
always destroy() on teardown so orphaned instances don't exhaust GPU memory.
Recap
Summarizer.availability()gatescreate(); wire amonitorfor the first-run download.type,format, andlengthset the shape — and the TL;DR type is spelled'tldr', no semicolon.summarize()returns the whole thing;summarizeStreaming()yields deltas you append.sharedContextframes every call on the instance; per-callcontextrefines a single input.destroy()frees the model, and a destroyed summarizer doesn't come back.
Everyone else is paying a server to shorten a sentence.
Next steps
- The Prompt API (LanguageModel) — drop down to the raw model when you need JSON, tools, or conversation instead of a fixed shape.
- Writer & Rewriter — the sibling task APIs for drafting new text and reshaping existing text.
- Shipping & compatibility — what's stable, what's still flagged, and what stays desktop-only.