●SDK58 — The Expo SDK 58 beta is open. It ships the React Native 0.88 release candidate, and the beta period is stated as three to four weeks●11/01 — For anyone who requested an extension, Google Play's target API deadline lands on November 1. Forty-four days out●EASENV — A long-open report: secrets handed to a local build arrive as the literal variable name rather than its value, and the damage surfaces much later●NEW — The replacement the table recommended had already shut down. A record of reconciling all 74 rows of the deprecation list●UISCENE — iOS 27 requires the new scene lifecycle. SDK 57 makes it something you opt into; it only becomes the default in 58●CREDIT — What "AI errors don't cost credits" actually covers becomes clear once you record a day of asking for the same fix more than once●SDK58 — The Expo SDK 58 beta is open. It ships the React Native 0.88 release candidate, and the beta period is stated as three to four weeks●11/01 — For anyone who requested an extension, Google Play's target API deadline lands on November 1. Forty-four days out●EASENV — A long-open report: secrets handed to a local build arrive as the literal variable name rather than its value, and the damage surfaces much later●NEW — The replacement the table recommended had already shut down. A record of reconciling all 74 rows of the deprecation list●UISCENE — iOS 27 requires the new scene lifecycle. SDK 57 makes it something you opt into; it only becomes the default in 58●CREDIT — What "AI errors don't cost credits" actually covers becomes clear once you record a day of asking for the same fix more than once
The recommended replacement was already gone — auditing all 74 rows of Gemini's deprecation table
Cross-checking Gemini's deprecation table turned up five rows where the recommended replacement had itself already shut down, including gemini-2.5-flash-image, which goes away on October 2. Here is how to verify a migration target, and how to catch the 404 before your users do.
I was changing the model name behind the category classifier for my wallpaper app one morning, and I fumbled it — the old ID stayed in place, the run went through, and every image came back with an empty category.
The cause took a minute to find. What kept me at the desk was the deprecation page I opened next. The model I had picked as my migration target had a date beside it, and the date was in the past.
I had assumed the "recommended replacement" column always pointed at something still running. That assumption was the expensive part of the morning.
Only a handful of rows actually carry a date
I pulled the Gemini deprecations page apart as a table. Sorting its 74 rows (page last updated 2026-09-15) against today's date, 2026-09-17, gives this:
Bucket
Rows
What it means for you
Shutdown date already passed
39
Calling it is not an option
Shutdown date in the future
4
You can plan against a date
No shutdown date announced
31
You cannot plan against a date
Four rows out of seventy-four have a future date on them:
Model
Shutdown
Days from today
gemini-omni-flash-preview
2026-09-30
13
gemini-2.5-flash-image
2026-10-02
15
gemini-3.1-flash-lite
2027-05-07
232
gemini-embedding-001
2028-05-14
605
One correction while we are here. You will hear that "the 2.5 family all goes away in October." On today's table, gemini-2.5-flash, gemini-2.5-pro and gemini-2.5-flash-lite each read "no shutdown date announced." The October 2 date belongs to the image model, gemini-2.5-flash-image, and to nothing else in that family.
So the slice of the problem you can handle with a calendar is much thinner than it looks. Putting a reminder in your calendar works for four rows. For the other seventy, there is no date to remind you of.
Copying the replacement column sends you to a dead model
Then I went one step further with the same table. I took each model ID written in the "recommended replacement" column, looked it up as a row of its own, and checked whether it had a shutdown date.
Five rows came back. Five entries recommend a replacement that has itself already shut down.
Model
Its shutdown
Recommended replacement
Replacement's shutdown
gemini-2.5-flash-image
2026-10-02
gemini-3.1-flash-image-preview
2026-06-25 (passed)
imagen-3.0-generate-002
2025-11-10
imagen-4.0-generate-001
2026-08-17 (passed)
imagen-4.0-generate-preview-06-06
2026-02-17
imagen-4.0-generate-001
2026-08-17 (passed)
imagen-4.0-ultra-generate-preview-06-06
2026-02-17
imagen-4.0-ultra-generate-001
2026-08-17 (passed)
gemini-robotics-er-1.5-preview
2026-04-30
gemini-robotics-er-1.6-preview
2026-08-31 (passed)
Look at the first row. The model disappearing in fifteen days points you at gemini-3.1-flash-image-preview, and that preview stopped on June 25. Anyone who copies the replacement column straight across migrates onto something that is already gone.
The target that is actually alive is the GA release, gemini-3.1-flash-image, with the preview suffix dropped. The table does say so — on a different row. You will not learn it by reading the gemini-2.5-flash-image row.
Six more rows recommend a replacement that has a future shutdown date of its own, which is to say the place you move to is also on a clock. And one row recommends a model that has no row at all: gemini-robotics-er-1.6-preview points at gemini-robotics-er-2-preview, which does not appear in the table.
The replacement column tells you where the family is heading. It does not tell you what is running today. Miss that distinction and you will do the same migration twice.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to verify, on your own, whether an officially recommended replacement is still alive — including whether it has a shutdown date of its own
✦You will be able to put a landing pad under your AI calls so that a 404 arriving before the published date never turns into a silent failure in front of a user
✦You will be able to decide whether a model ID belongs inside your shipped app at all, knowing that only 4 of 74 rows carry a future shutdown date
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
A small audit script so you never diff this by hand
Once you have seen it, the check is mechanical. Reading seventy-four rows with your eyes every quarter is not, so I added about forty lines to the scripts I already run.
It takes a file listing the model IDs you actually call, plus a TSV copied from the deprecation table, and prints only the rows that should worry you.
#!/usr/bin/env python3"""Cross-check the models you call against Gemini's deprecation table.deprecations.tsv (tab separated, no header): model<TAB>shutdown(YYYY-MM-DD or empty)<TAB>recommended_replacement(optional)Exit code 0 = clean, 1 = action needed, so it fails a CI job as-is."""import sysfrom datetime import date, timedeltaWARN_WINDOW = 90 # how far ahead counts as "soon"def load_table(path): table = {} with open(path, encoding="utf-8") as f: for line in f: row = line.rstrip("\n").split("\t") if len(row) < 3 or not row[0].strip(): continue model, shutdown, replacement = (c.strip() for c in row[:3]) table[model] = ( date.fromisoformat(shutdown) if shutdown else None, replacement or None, ) return tabledef audit(in_use, table, today): """Returns a list of (severity, model, one-line message).""" findings = [] for model in in_use: if model not in table: # Absent means "not announced as deprecated" -- or a typo in the ID. findings.append(("WARN", model, "no row in the deprecation table; check the spelling")) continue shutdown, replacement = table[model] if shutdown and shutdown <= today: findings.append(("FAIL", model, f"shutdown date {shutdown} has passed")) elif shutdown and shutdown - today <= timedelta(days=WARN_WINDOW): findings.append(("FAIL", model, f"shutdown {shutdown} ({(shutdown - today).days} days left)")) # This is the part that caught me out: is the exit door still open? if replacement: if replacement not in table: findings.append(("WARN", model, f"replacement {replacement} is not in the table")) else: rep_shutdown = table[replacement][0] if rep_shutdown and rep_shutdown <= today: findings.append(("FAIL", model, f"replacement {replacement} shut down on {rep_shutdown}")) elif rep_shutdown: findings.append(("WARN", model, f"replacement {replacement} also has a date: {rep_shutdown}")) return findingsdef main(): table = load_table(sys.argv[1]) with open(sys.argv[2], encoding="utf-8") as f: in_use = [l.strip() for l in f if l.strip() and not l.startswith("#")] findings = audit(in_use, table, date.today()) for level, model, message in findings: print(f"{level}\t{model}\t{message}") if not findings: print("OK\t-\tnothing pending for the models you call") sys.exit(1 if any(l == "FAIL" for l, _, _ in findings) else 0)if __name__ == "__main__": main()
Four steps to put it in place:
Open the deprecations page and copy the rows for the families you use into deprecations.tsv as tab-separated lines. There are 74 rows in total today; you only need the ones you touch.
Pull the IDs out of your app and server code with grep -rhoE 'gemini-[0-9][a-z0-9.-]*', dedupe, and write them to models_in_use.txt.
Run python3 audit_models.py deprecations.tsv models_in_use.txt.
Put that same line in a weekly CI job and let exit code 1 fail it. Now you have a way of noticing when the table changes.
Run it with a single line containing gemini-2.5-flash-image and today's date gives you:
FAIL gemini-2.5-flash-image shutdown 2026-10-02 (15 days left)FAIL gemini-2.5-flash-image replacement gemini-3.1-flash-image-preview shut down on 2026-06-25
The second line is the one I missed by eye. I kept audit() as its own function so that swapping the TSV for a live model listing later leaves the decision logic untouched.
One deliberate choice: a missing row is a WARN, not a FAIL. The deprecation table only lists things on their way out, so your current models are supposed to be absent from it. Make that a failure and you get an alarm that rings every single day.
The published date is the earliest possible date, not a promise
There is a note near the top of the page that is easy to scroll past. The dates in the table are described as the earliest possible dates a model might be retired, with the exact date to be communicated separately.
My first reading of that was reassuring: fine, so in practice we get longer. That reading was the dangerous one.
So the date misses in both directions. Things break before it and keep working after it — either way, a plan built on the date alone is a plan built on sand.
I moved my migration trigger from the date to the behaviour. The calendar entry stays, because it reminds me to do the work. It is not what protects the app. What protects the app is what happens when a 404 comes back.
Ship a logical name, not a model ID
If your Rork app calls Gemini directly, the model ID lives in client code — and once that build is in the store, I cannot change it. Neither can you.
The server side is small — a map from logical name to an ordered list of candidates. The same shape works on Cloudflare Workers or a Supabase Edge Function.
// Logical name -> candidate model IDs, tried in order.// Editing this file changes the behaviour of builds already in the store.const MODEL_CHAINS: Record<string, string[]> = { "image.generate": [ "gemini-3.1-flash-image", // GA. No shutdown date as of 2026-09-17. "gemini-2.5-flash-image", // Shuts down 2026-10-02. Kept only as a bridge. ], "vision.classify": ["gemini-3.8-flash", "gemini-3.6-flash"],};type Resolved = { model: string; chain: string[] };export function resolveModel(logical: string): Resolved { const chain = MODEL_CHAINS[logical]; if (!chain || chain.length === 0) { // Never swallow an unknown logical name -- a config typo should be loud. throw new Error(`unknown logical model: ${logical}`); } return { model: chain[0], chain };}export async function callWithFallback( logical: string, body: unknown, apiKey: string,): Promise<Response> { const { chain } = resolveModel(logical); let lastGone: Response | null = null; for (const model of chain) { const res = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, { method: "POST", headers: { "content-type": "application/json", "x-goog-api-key": apiKey }, body: JSON.stringify(body), }, ); // 404 means "this model is gone". Fall through to the next candidate. if (res.status === 404) { lastGone = res; console.warn(`model gone: ${model} (logical=${logical})`); continue; } // Anything else goes back untouched. 429 and 5xx belong to the retry layer. return res; } // Running out of candidates is an operations failure, not a user error. console.error(`all models gone for logical=${logical}, chain=${chain.join(",")}`); return lastGone ?? new Response("no model available", { status: 503 });}
The list is ordered rather than a single value because I want two rails for the few days around a shutdown. Put the new GA model first, leave the old one behind it, and whichever one drops first, the screen keeps working. When things settle, deleting one line finishes the migration.
That model gone log line exists so I can count, afterwards, the day the second rail started carrying traffic. That day is the real shutdown date — not the one in the table.
A 404 is the one error you must not retry
Here is the distinction I had blurred, and it cost me twice before I fixed it.
Failures over a network split into the kind that a retry cures and the kind it never will. A deprecation 404 is firmly the second kind. I was still feeding it into the same generic retry layer as everything else — and the result was not good. Exponential backoff dutifully tried three times, so users waited three times as long to receive exactly the same failure.
Now the call site sorts failures into three buckets.
Kind
Typical response
Behaviour
Transient
429 / 503 / timeout
Back off and retry
Permanent (deprecated)
404 / NOT_FOUND / no longer available
Do not retry; take the alternate path
Our own mistake
400 / 403
Do not retry; fix the code
On the app side that is all of it:
type Outcome = | { kind: "ok"; data: unknown } | { kind: "retry" } // caller backs off and tries again | { kind: "unavailable" } // permanent; offer the user another route | { kind: "bug"; detail: string }; // should have been caught in developmentexport async function askAi(logical: string, payload: unknown): Promise<Outcome> { const res = await fetch("/api/ai", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ logical, payload }), }); if (res.ok) return { kind: "ok", data: await res.json() }; if (res.status === 429 || res.status >= 500) return { kind: "retry" }; if (res.status === 404) return { kind: "unavailable" }; return { kind: "bug", detail: `${res.status} ${await res.text()}` };}
What unavailable shows on screen depends on the feature. For image generation I hide the generate button for that session and point at manual upload instead. For classification I keep last run's result on screen and add a line saying it could not be refreshed.
The thing not to do is return an empty result in silence. That is precisely what bit me at the top of this piece: the classifier came back empty, nothing on screen changed, and empty categories went out to people for as long as it took me to notice. The same quiet shape turns up with deprecated OS APIs too — I collected examples of it in Ask Rork for an iOS 27 feature and it quietly falls back to the old path.
You cannot reach the builds you have already shipped
Moving the model ID onto the server does nothing for the versions already out there. That part is simply beyond reach.
Running apps as an indie developer for a long stretch teaches you how long old builds survive. People with auto-update off, people on a phone they have not replaced, people saving their data allowance — every app has a share of them.
For any build that shipped with the ID baked in, you get three choices: keep a proxy route alive on the same ID, push a forced-update prompt, or fold that one feature away behind a flag.
I reach for the flag first. A forced update is a card I would rather keep in reserve, and "keep it alive" without an end date turns into keeping it alive forever.
Keep model IDs outside the app, and prepare for the 404 rather than the date. That is the line I want to remember first, next time a newer model tempts me.
If you pick one thing: write down the model IDs you currently call, one per line, and run them through the audit script above. Ten minutes is enough.
If two FAIL lines come back, you are standing exactly where I was — one for the shutdown date, one for the replacement that had already gone.
Thank you for reading this far. If you turn up a dead exit door of your own in that table, I would genuinely like to hear about it; I am watching the same page.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.